initial commit
This commit is contained in:
commit
b308df349c
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
dist/*.js
|
||||
dist/*.js.map
|
||||
dist/*.html
|
||||
package-lock.json
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,366 @@
|
|||
@font-face {
|
||||
font-family: "Source Han Sans";
|
||||
src: url("SourceHanSansSC-Regular.otf");
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: #faf9f8;
|
||||
font-family: "Source Han Sans", sans-serif;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ms-ContextualMenu-link, .ms-Button {
|
||||
cursor: default;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ms-Nav-link, .ms-Nav-chevronButton {
|
||||
font-size: 12px;
|
||||
line-height: 32px;
|
||||
height: 32px;
|
||||
background-color: transparent;
|
||||
color: #323130;
|
||||
}
|
||||
i.ms-Nav-chevron {
|
||||
line-height: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.ms-Nav-groupContent {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.ms-ActivityItem-activityTypeIcon, .ms-ActivityItem-timeStamp {
|
||||
user-select: none;
|
||||
}
|
||||
.ms-Label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root > nav {
|
||||
height: 32px;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
#root > nav span.title {
|
||||
font-size: 12px;
|
||||
line-height: 32px;
|
||||
vertical-align: top;
|
||||
letter-spacing: 2px;
|
||||
margin: 0 4px;
|
||||
display: inline-block;
|
||||
max-width: 300px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
.btn-group {
|
||||
display: inline-block;
|
||||
user-select: none;
|
||||
-webkit-app-region: none;
|
||||
}
|
||||
.btn-group .seperator {
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
font-size: 12px;
|
||||
color: #c8c6c4;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.btn-group .seperator::before {
|
||||
content: "|";
|
||||
}
|
||||
.btn-group .btn {
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 32px;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
line-height: 32px;
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.btn-group .btn.system {
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
}
|
||||
.btn-group .btn:hover {
|
||||
background-color: #0001;
|
||||
}
|
||||
.btn-group .btn:active {
|
||||
background-color: #0002;
|
||||
}
|
||||
.btn-group .btn.disabled, .btn-group .btn.fetching {
|
||||
background-color: unset;
|
||||
color: #797775;
|
||||
}
|
||||
.btn-group .btn.fetching {
|
||||
animation: rotating linear 1.5s infinite;
|
||||
}
|
||||
@keyframes rotating {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.btn-group .btn.close:hover {
|
||||
background-color: #e81123;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-group .btn.close:active {
|
||||
background-color: #f1707a;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-group .btn.system.on {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #0008;
|
||||
backdrop-filter: saturate(150%) blur(20px);
|
||||
}
|
||||
.menu-container .menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
background-color: #faf9f8;
|
||||
}
|
||||
.menu-container .menu .nav-wrapper {
|
||||
max-height: calc(100% - 32px);
|
||||
overflow: hidden auto;
|
||||
}
|
||||
.menu-container .menu p.subs-header {
|
||||
font-size: 12px;
|
||||
color: #797775;
|
||||
margin: 2px 8px;
|
||||
animation-name: css-1;
|
||||
animation-duration: 0.367s;
|
||||
animation-timing-function: cubic-bezier(0.1, 0.25, 0.75, 0.9);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #edebe9;
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-container .settings {
|
||||
margin: 64px auto 0;
|
||||
width: 680px;
|
||||
height: calc(100% - 64px);
|
||||
background-color: #fff;
|
||||
box-shadow: 0 6.4px 14.4px 0 rgba(0,0,0,.132), 0 1.2px 3.6px 0 rgba(0,0,0,.108);
|
||||
}
|
||||
div[role="toolbar"] {
|
||||
height: 100%;
|
||||
}
|
||||
div[role="tabpanel"] {
|
||||
height: calc(100% - 44px);
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
.settings .loading {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #fffa;
|
||||
z-index: 6;
|
||||
}
|
||||
.settings .loading .ms-Spinner {
|
||||
margin-top: 180px;
|
||||
}
|
||||
.tab-body .ms-StackItem {
|
||||
margin-right: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tab-body .ms-StackItem:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
img.favicon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ms-DetailsList-contentWrapper {
|
||||
max-height: 400px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.main {
|
||||
height: calc(100% - 32px);
|
||||
position: relative;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.main::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
margin-bottom: -32px;
|
||||
background: linear-gradient(#faf9f8ff, #faf9f800);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cards-feed-container {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
.cards-feed-container > div {
|
||||
flex-grow: 1;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.cards-feed-container > div.load-more-wrapper {
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.flex-fix {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.info {
|
||||
position: relative;
|
||||
margin: 10px 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.info img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.info span.name {
|
||||
font-size: 12px;
|
||||
vertical-align: top;
|
||||
display: inline-block;
|
||||
max-width: 144px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.info span.time {
|
||||
float: right;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.read-indicator {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
float: right;
|
||||
}
|
||||
.read-indicator::after {
|
||||
content: "";
|
||||
vertical-align: top;
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: 5px;
|
||||
border-radius: 3px;
|
||||
background-color: #ffaa44;
|
||||
font-size: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 256px;
|
||||
height: 264px;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
overflow: hidden;
|
||||
box-shadow: #0004 0px 5px 20px;
|
||||
margin: 18px 12px;
|
||||
color: #161514;
|
||||
user-select: none;
|
||||
transition: box-shadow linear .08s;
|
||||
transform: scale(1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.card:hover {
|
||||
box-shadow: #0006 0px 5px 40px;
|
||||
}
|
||||
.card:active {
|
||||
transform: scale(.97);
|
||||
}
|
||||
.card .bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.card img.bg {
|
||||
object-fit: cover;
|
||||
filter: saturate(150%) blur(20px);
|
||||
}
|
||||
.card div.bg {
|
||||
background-color: #fffb;
|
||||
}
|
||||
.card img.head {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 144px;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.card img.head, .card p, .card h3 {
|
||||
transition: transform ease-out .12s;
|
||||
}
|
||||
.card.transform:hover img.head, .card.transform:hover p, .card.transform:hover h3 {
|
||||
transform: translateY(-144px);
|
||||
}
|
||||
.card h3.title {
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
font-weight: 700;
|
||||
margin: 10px 12px;
|
||||
position: relative;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.card p.snippet {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
margin: 10px 12px;
|
||||
display: -webkit-box;
|
||||
position: relative;
|
||||
-webkit-line-clamp: 7;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
transform: translateY(64px);
|
||||
}
|
||||
.card:hover p.snippet {
|
||||
transform: translateY(-144px);
|
||||
}
|
||||
.card p.snippet.show {
|
||||
transform: none;
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "rss-reader",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "webpack --config ./webpack.config.js",
|
||||
"electron": "electron ./dist/electron.js",
|
||||
"start": "npm run build && npm run electron"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@fluentui/react": "^7.115.3",
|
||||
"@types/electron": "^1.6.10",
|
||||
"@types/nedb": "^1.8.9",
|
||||
"@types/react": "^16.9.35",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react-redux": "^7.1.9",
|
||||
"@types/redux": "^3.6.0",
|
||||
"@types/redux-thunk": "^2.1.0",
|
||||
"@types/reselect": "^2.2.0",
|
||||
"electron": "^8.3.0",
|
||||
"electron-react-devtools": "^0.5.3",
|
||||
"favicon": "0.0.2",
|
||||
"html-webpack-plugin": "^4.3.0",
|
||||
"http-proxy-agent": "^4.0.1",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"nedb": "^1.8.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-redux": "^7.2.0",
|
||||
"redux": "^4.0.5",
|
||||
"redux-devtools": "^3.5.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"reselect": "^4.0.0",
|
||||
"rss-parser": "^3.8.0",
|
||||
"ts-loader": "^7.0.4",
|
||||
"typescript": "^3.9.2",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-cli": "^3.3.11"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import * as React from "react"
|
||||
import { openExternal } from "../../scripts/utils"
|
||||
import { RSSSource } from "../../scripts/models/source"
|
||||
import { RSSItem } from "../../scripts/models/item"
|
||||
|
||||
export interface CardProps {
|
||||
item: RSSItem
|
||||
source: RSSSource
|
||||
markRead: Function
|
||||
contextMenu: Function
|
||||
}
|
||||
|
||||
export class Card extends React.Component<CardProps> {
|
||||
openInBrowser = () => {
|
||||
this.props.markRead(this.props.item)
|
||||
openExternal(this.props.item.link)
|
||||
}
|
||||
|
||||
onClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
this.openInBrowser()
|
||||
}
|
||||
|
||||
onMouseUp = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
switch (e.button) {
|
||||
case 1:
|
||||
this.openInBrowser()
|
||||
break
|
||||
case 2:
|
||||
this.props.contextMenu(this.props.item, e)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import * as React from "react"
|
||||
import { Card } from "./card"
|
||||
import Time from "../time"
|
||||
|
||||
class DefaultCard extends Card {
|
||||
render() {
|
||||
return (
|
||||
<div className={"card"+(this.props.item.snippet&&this.props.item.thumb?" transform":"")}
|
||||
onClick={this.onClick} onMouseUp={this.onMouseUp} >
|
||||
{this.props.item.thumb ? (
|
||||
<img className="bg" src={this.props.item.thumb} />
|
||||
) : null}
|
||||
<div className="bg"></div>
|
||||
{this.props.item.thumb ? (
|
||||
<img className="head" src={this.props.item.thumb} />
|
||||
) : null}
|
||||
<p className="info">
|
||||
{this.props.source.iconurl ? <img src={this.props.source.iconurl} /> : null}
|
||||
<span className="name">{this.props.source.name}</span>
|
||||
<Time date={this.props.item.date} />
|
||||
{this.props.item.hasRead ? null : <span className="read-indicator"></span>}
|
||||
</p>
|
||||
<h3 className="title">{this.props.item.title}</h3>
|
||||
<p className={"snippet"+(this.props.item.thumb?"":" show")}>{this.props.item.snippet}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default DefaultCard
|
|
@ -0,0 +1,75 @@
|
|||
import * as React from "react"
|
||||
import { clipboard } from "electron"
|
||||
import { openExternal } from "../scripts/utils"
|
||||
import { ContextualMenu, IContextualMenuItem, ContextualMenuItemType } from "office-ui-fabric-react/lib/ContextualMenu"
|
||||
import { ContextMenuType } from "../scripts/models/app"
|
||||
import { RSSItem } from "../scripts/models/item"
|
||||
import { ContextReduxProps } from "../containers/context-menu-container"
|
||||
|
||||
export type ContextMenuProps = ContextReduxProps & {
|
||||
type: ContextMenuType
|
||||
event?: MouseEvent
|
||||
item?: RSSItem
|
||||
markRead: Function
|
||||
markUnread: Function
|
||||
close: Function
|
||||
}
|
||||
|
||||
export class ContextMenu extends React.Component<ContextMenuProps> {
|
||||
getItems = (): IContextualMenuItem[] => {
|
||||
switch (this.props.type) {
|
||||
case ContextMenuType.Item: return [
|
||||
{
|
||||
key: "openInBrowser",
|
||||
text: "在浏览器中打开",
|
||||
iconProps: { iconName: "NavigateExternalInline" },
|
||||
onClick: () => {
|
||||
this.props.markRead(this.props.item)
|
||||
openExternal(this.props.item.link)
|
||||
}
|
||||
},
|
||||
this.props.item.hasRead
|
||||
? {
|
||||
key: "markAsUnread",
|
||||
text: "标为未读",
|
||||
iconProps: { iconName: "StatusCircleInner", style: { fontSize: 12, textAlign: "center" } },
|
||||
onClick: () => { this.props.markUnread(this.props.item) }
|
||||
}
|
||||
: {
|
||||
key: "markAsRead",
|
||||
text: "标为已读",
|
||||
iconProps: { iconName: "StatusCircleRing" },
|
||||
onClick: () => { this.props.markRead(this.props.item) }
|
||||
},
|
||||
{
|
||||
key: "markBelowAsRead",
|
||||
text: "将以下标为已读"
|
||||
},
|
||||
{
|
||||
key: "divider_1",
|
||||
itemType: ContextualMenuItemType.Divider,
|
||||
},
|
||||
{
|
||||
key: "copyTitle",
|
||||
text: "复制标题",
|
||||
onClick: () => { clipboard.writeText(this.props.item.title) }
|
||||
},
|
||||
{
|
||||
key: "copyURL",
|
||||
text: "复制链接",
|
||||
onClick: () => { clipboard.writeText(this.props.item.link) }
|
||||
}
|
||||
]
|
||||
default: return []
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.type == ContextMenuType.Hidden ? null : (
|
||||
<ContextualMenu
|
||||
items={this.getItems()}
|
||||
target={this.props.event}
|
||||
onDismiss={() => this.props.close()} />
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react"
|
||||
import { Feed } from "./feed"
|
||||
import DefaultCard from "../cards/default-card"
|
||||
import { PrimaryButton } from 'office-ui-fabric-react';
|
||||
|
||||
class CardsFeed extends Feed {
|
||||
state = { width: window.innerWidth - 12 }
|
||||
|
||||
updateWidth = () => {
|
||||
this.setState({ width: window.innerWidth - 12 });
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('resize', this.updateWidth);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.updateWidth);
|
||||
}
|
||||
|
||||
flexFix = () => {
|
||||
let elemPerRow = Math.floor(this.state.width / 280)
|
||||
let elemLastRow = this.props.items.length % elemPerRow
|
||||
let fixes = new Array<JSX.Element>()
|
||||
for (let i = 0; i < elemPerRow - elemLastRow; i += 1) {
|
||||
fixes.push(<div className="flex-fix" key={"f-"+i}></div>)
|
||||
}
|
||||
return fixes
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.feed.loaded && (
|
||||
<div className="cards-feed-container">
|
||||
{
|
||||
this.props.items.map(item => (
|
||||
<div key={item.id}>
|
||||
<DefaultCard
|
||||
item={item}
|
||||
source={this.props.sourceMap[item.source]}
|
||||
markRead={this.props.markRead}
|
||||
contextMenu={this.props.contextMenu} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{ this.flexFix() }
|
||||
{
|
||||
(this.props.feed.loaded && !this.props.feed.allLoaded)
|
||||
? <div className="load-more-wrapper"><PrimaryButton
|
||||
text="加载更多"
|
||||
disabled={this.props.feed.loading}
|
||||
onClick={() => this.props.loadMore(this.props.feed)} /></div>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default CardsFeed
|
|
@ -0,0 +1,15 @@
|
|||
import * as React from "react"
|
||||
import { RSSItem } from "../../scripts/models/item"
|
||||
import { FeedReduxProps } from "../../containers/feed-container"
|
||||
import { RSSFeed } from "../../scripts/models/feed"
|
||||
|
||||
type FeedProps = FeedReduxProps & {
|
||||
feed: RSSFeed
|
||||
items: RSSItem[]
|
||||
sourceMap: Object
|
||||
markRead: Function
|
||||
contextMenu: Function
|
||||
loadMore: Function
|
||||
}
|
||||
|
||||
export class Feed extends React.Component<FeedProps> { }
|
|
@ -0,0 +1,42 @@
|
|||
import * as React from "react"
|
||||
import { Callout, ActivityItem, Icon, DirectionalHint } from "@fluentui/react"
|
||||
import { AppLog, AppLogType } from "../scripts/models/app"
|
||||
import { LogsReduxProps } from "../containers/log-menu-container"
|
||||
import Time from "./time"
|
||||
|
||||
type LogMenuProps = LogsReduxProps & {
|
||||
display: boolean,
|
||||
logs: AppLog[]
|
||||
close: Function
|
||||
}
|
||||
|
||||
class LogMenu extends React.Component<LogMenuProps> {
|
||||
activityItems = () => this.props.logs.map((l, i) => ({
|
||||
key: i,
|
||||
activityDescription: <b>{l.title}</b>,
|
||||
comments: l.details,
|
||||
activityIcon: <Icon iconName={l.type == AppLogType.Info ? "Info" : "Warning"} />,
|
||||
timeStamp: <Time date={l.time} />,
|
||||
})).reverse()
|
||||
|
||||
render () {
|
||||
return this.props.display && (
|
||||
<Callout
|
||||
target="#log-toggle"
|
||||
role="log-menu"
|
||||
directionalHint={DirectionalHint.bottomCenter}
|
||||
calloutWidth={320}
|
||||
calloutMaxHeight={240}
|
||||
onDismiss={() => this.props.close()}
|
||||
>
|
||||
{ this.props.logs.length == 0
|
||||
? <p style={{ textAlign: "center" }}>无消息</p>
|
||||
: this.activityItems().map((item => (
|
||||
<ActivityItem {...item} key={item.key} style={{ margin: 12 }} />
|
||||
))) }
|
||||
</Callout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default LogMenu
|
|
@ -0,0 +1,101 @@
|
|||
import * as React from "react"
|
||||
import { Icon } from "@fluentui/react/lib/Icon"
|
||||
import { Nav, INavLink, INavStyles, INavLinkGroup } from "office-ui-fabric-react/lib/Nav"
|
||||
import { MenuStatus } from "../scripts/models/app"
|
||||
import { SourceGroup } from "../scripts/models/page"
|
||||
import { SourceState, RSSSource } from "../scripts/models/source"
|
||||
import { MenuReduxProps } from "../containers/menu-container"
|
||||
import { ALL } from "../scripts/models/feed"
|
||||
|
||||
export type MenuProps = MenuReduxProps & {
|
||||
status: MenuStatus,
|
||||
selected: string,
|
||||
sources: SourceState,
|
||||
groups: SourceGroup[],
|
||||
closeMenu: () => void,
|
||||
allArticles: () => void,
|
||||
selectSourceGroup: (group: SourceGroup, menuKey: string) => void,
|
||||
selectSource: (source: RSSSource) => void
|
||||
}
|
||||
|
||||
export class Menu extends React.Component<MenuProps> {
|
||||
getItems = (): INavLinkGroup[] => [
|
||||
{
|
||||
links: [
|
||||
{
|
||||
name: "主页",
|
||||
key: "home",
|
||||
icon: "Home",
|
||||
url: null
|
||||
},
|
||||
{
|
||||
name: "全部文章",
|
||||
key: ALL,
|
||||
icon: "TextDocument",
|
||||
onClick: this.props.allArticles,
|
||||
url: null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
getGroups = (): INavLinkGroup[] => [{
|
||||
links: this.props.groups.filter(g => g.sids.length > 0).map((g, i) => {
|
||||
if (g.isMultiple) {
|
||||
return {
|
||||
name: g.name,
|
||||
key: "g-" + i,
|
||||
url: "#",
|
||||
isExpanded: true,
|
||||
onClick: () => this.props.selectSourceGroup(g, "g-" + i),
|
||||
links: g.sids.map(sid => this.props.sources[sid]).map(this.getSource)
|
||||
}
|
||||
} else {
|
||||
return this.getSource(this.props.sources[g.sids[0]])
|
||||
}
|
||||
}
|
||||
)}]
|
||||
|
||||
getSource = (s: RSSSource): INavLink => ({
|
||||
name: s.name,
|
||||
key: "s-" + s.sid,
|
||||
onClick: () => this.props.selectSource(s),
|
||||
iconProps: s.iconurl ? this.getIconStyle(s.iconurl) : null,
|
||||
url: "#"
|
||||
})
|
||||
|
||||
|
||||
getIconStyle = (url: string) => ({
|
||||
style: { width: 16 },
|
||||
imageProps: {
|
||||
style: { width:"100%" },
|
||||
src: url
|
||||
}
|
||||
})
|
||||
|
||||
_onRenderGroupHeader(group: INavLinkGroup): JSX.Element {
|
||||
return <p className="subs-header">{group.name}</p>;
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.status == MenuStatus.Hidden ? null : (
|
||||
<div className="menu-container" onClick={this.props.closeMenu}>
|
||||
<div className="menu" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="btn-group">
|
||||
<a className="btn" title="关闭菜单" onClick={this.props.closeMenu}><Icon iconName="Back" /></a>
|
||||
</div>
|
||||
<div className="nav-wrapper">
|
||||
<Nav
|
||||
groups={this.getItems()}
|
||||
selectedKey={this.props.selected}
|
||||
onRenderGroupHeader={this._onRenderGroupHeader} />
|
||||
<p className="subs-header">订阅源</p>
|
||||
<Nav
|
||||
selectedKey={this.props.selected}
|
||||
groups={this.getGroups()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import * as React from "react"
|
||||
import { ipcRenderer } from "electron"
|
||||
import { Icon } from "@fluentui/react/lib/Icon"
|
||||
import { AppState, MenuStatus } from "../scripts/models/app"
|
||||
import { NavReduxProps } from "../containers/nav-container"
|
||||
|
||||
type NavProps = NavReduxProps & {
|
||||
state: AppState,
|
||||
fetch: () => void,
|
||||
menu: () => void,
|
||||
logs: () => void,
|
||||
settings: () => void
|
||||
}
|
||||
|
||||
class Nav extends React.Component<NavProps> {
|
||||
ipcSend(message:string) {
|
||||
ipcRenderer.send(message)
|
||||
}
|
||||
|
||||
canFetch = () => this.props.state.sourceInit && this.props.state.feedInit && !this.props.state.fetchingItems
|
||||
fetching = () => !this.canFetch() ? " fetching" : ""
|
||||
menuOn = () => this.props.state.menu == MenuStatus.Open ? " on" : ""
|
||||
|
||||
fetch = () => {
|
||||
if (this.canFetch()) this.props.fetch()
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<nav>
|
||||
<div className="btn-group">
|
||||
<a className="btn" title="菜单" onClick={this.props.menu}><Icon iconName="GlobalNavButton" /></a>
|
||||
</div>
|
||||
<span className="title">{this.props.state.title}</span>
|
||||
<div className="btn-group" style={{float:"right"}}>
|
||||
<a className={"btn"+this.fetching()} onClick={this.fetch} title="刷新"><Icon iconName="Refresh" /></a>
|
||||
<a className="btn" title="全部标为已读"><Icon iconName="InboxCheck" /></a>
|
||||
<a className="btn" id="log-toggle" title="消息" onClick={this.props.logs}>
|
||||
{this.props.state.logMenu.notify ? <Icon iconName="RingerSolid" /> : <Icon iconName="Ringer" />}
|
||||
</a>
|
||||
<a className="btn" title="视图"><Icon iconName="View" /></a>
|
||||
<a className="btn" title="选项" onClick={this.props.settings}><Icon iconName="Settings" /></a>
|
||||
<span className="seperator"></span>
|
||||
<a className={"btn system"+this.menuOn()} title="最小化" onClick={() => this.ipcSend("minimize")} style={{fontSize: 12}}><Icon iconName="Remove" /></a>
|
||||
<a className={"btn system"+this.menuOn()} title="最大化" onClick={() => this.ipcSend("maximize")} style={{fontSize: 10}}><Icon iconName="Checkbox" /></a>
|
||||
<a className={"btn system close"+this.menuOn()} title="关闭" onClick={() => this.ipcSend("close")}><Icon iconName="Cancel" /></a>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Nav
|
|
@ -0,0 +1,25 @@
|
|||
import * as React from "react"
|
||||
import { connect } from 'react-redux'
|
||||
import { FeedContainer } from "../containers/feed-container"
|
||||
import { ContextMenuContainer } from "../containers/context-menu-container"
|
||||
import { closeContextMenu } from "../scripts/models/app"
|
||||
import { MenuContainer } from "../containers/menu-container"
|
||||
import { NavContainer } from "../containers/nav-container"
|
||||
import { LogMenuContainer } from "../containers/log-menu-container"
|
||||
import { SettingsContainer } from "../containers/settings-container"
|
||||
|
||||
|
||||
const Root = ({ dispatch }) => (
|
||||
<div id="root" onMouseDown={() => dispatch(closeContextMenu())}>
|
||||
<NavContainer />
|
||||
<div className="main">
|
||||
<FeedContainer />
|
||||
</div>
|
||||
<LogMenuContainer />
|
||||
<MenuContainer />
|
||||
<SettingsContainer />
|
||||
<ContextMenuContainer />
|
||||
</div>
|
||||
)
|
||||
|
||||
export default connect()(Root)
|
|
@ -0,0 +1,49 @@
|
|||
import * as React from "react"
|
||||
import { Icon } from "@fluentui/react/lib/Icon"
|
||||
import { AnimationClassNames } from "@fluentui/react/lib/Styling"
|
||||
import { SettingsReduxProps } from "../containers/settings-container"
|
||||
import AboutTab from "./settings/about"
|
||||
import { Pivot, PivotItem, Spinner } from "@fluentui/react"
|
||||
import { SourcesTabContainer } from "../containers/settings/sources-container"
|
||||
import GroupsTab from "./settings/groups"
|
||||
|
||||
type SettingsProps = SettingsReduxProps & {
|
||||
display: boolean,
|
||||
blocked: boolean,
|
||||
exitting: boolean,
|
||||
close: () => void
|
||||
}
|
||||
|
||||
class Settings extends React.Component<SettingsProps> {
|
||||
constructor(props){
|
||||
super(props)
|
||||
}
|
||||
|
||||
render = () => this.props.display && (
|
||||
<div className="settings-container">
|
||||
<div className={"settings " + AnimationClassNames.slideUpIn20}>
|
||||
{this.props.blocked && <div className="loading">
|
||||
<Spinner label="正在更新订阅源,请稍候…" />
|
||||
</div>}
|
||||
<div className="btn-group" style={{position: "absolute", top: 6, left: -64}}>
|
||||
<a className={"btn" + (this.props.exitting ? " disabled" : "")} title="退出设置" onClick={this.props.close}>
|
||||
<Icon iconName="Back" />
|
||||
</a>
|
||||
</div>
|
||||
<Pivot>
|
||||
<PivotItem headerText="订阅源" itemIcon="Source">
|
||||
<SourcesTabContainer />
|
||||
</PivotItem>
|
||||
<PivotItem headerText="分组与排序" itemIcon="GroupList">
|
||||
<GroupsTab />
|
||||
</PivotItem>
|
||||
<PivotItem headerText="关于" itemIcon="Info">
|
||||
<AboutTab />
|
||||
</PivotItem>
|
||||
</Pivot>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
|
@ -0,0 +1,11 @@
|
|||
import * as React from "react"
|
||||
|
||||
class AboutTab extends React.Component {
|
||||
render = () => (
|
||||
<div className="tab-body">
|
||||
<p>RSS Reader v0.1.0</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AboutTab
|
|
@ -0,0 +1,11 @@
|
|||
import * as React from "react"
|
||||
|
||||
class GroupsTab extends React.Component {
|
||||
render = () => (
|
||||
<div className="tab-body">
|
||||
<p>Groups</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupsTab
|
|
@ -0,0 +1,97 @@
|
|||
import * as React from "react"
|
||||
import { Label, DefaultButton, TextField, Stack, PrimaryButton, DetailsList, Spinner, IColumn, SelectionMode, IRefObject, ITextField } from "@fluentui/react"
|
||||
import { SourcesTabReduxProps } from "../../containers/settings/sources-container"
|
||||
import { SourceState, RSSSource } from "../../scripts/models/source"
|
||||
import { urlTest } from "../../scripts/utils"
|
||||
|
||||
type SourcesTabProps = SourcesTabReduxProps & {
|
||||
sources: SourceState
|
||||
}
|
||||
|
||||
type SourcesTabState = {
|
||||
[formName: string]: string
|
||||
}
|
||||
|
||||
const columns: IColumn[] = [
|
||||
{
|
||||
key: "favicon",
|
||||
name: "图标",
|
||||
fieldName: "name",
|
||||
isIconOnly: true,
|
||||
iconName: "ImagePixel",
|
||||
minWidth: 16,
|
||||
maxWidth: 16,
|
||||
onRender: (s: RSSSource) => s.iconurl && (
|
||||
<img src={s.iconurl} className="favicon" />
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
name: "名称",
|
||||
fieldName: "name",
|
||||
minWidth: 200,
|
||||
data: 'string',
|
||||
isRowHeader: true
|
||||
},
|
||||
{
|
||||
key: "url",
|
||||
name: "URL",
|
||||
fieldName: "url",
|
||||
minWidth: 280,
|
||||
data: 'string'
|
||||
}
|
||||
]
|
||||
|
||||
class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
newUrl: ""
|
||||
}
|
||||
}
|
||||
|
||||
handleInputChange = (event) => {
|
||||
const name: string = event.target.name
|
||||
this.setState({[name]: event.target.value})
|
||||
}
|
||||
|
||||
render = () => (
|
||||
<div className="tab-body">
|
||||
<Label>OPML文件</Label>
|
||||
<Stack horizontal>
|
||||
<Stack.Item>
|
||||
<PrimaryButton text="导入文件" />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<DefaultButton text="导出文件" />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
|
||||
<Label>添加订阅源</Label>
|
||||
<Stack horizontal>
|
||||
<Stack.Item grow>
|
||||
<TextField
|
||||
onGetErrorMessage={v => urlTest(v) ? "" : "请正确输入URL"}
|
||||
validateOnLoad={false}
|
||||
placeholder="输入URL"
|
||||
value={this.state.newUrl}
|
||||
name="newUrl"
|
||||
onChange={this.handleInputChange} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<PrimaryButton disabled={!urlTest(this.state.newUrl)} text="添加" />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
|
||||
<Label>订阅源列表</Label>
|
||||
<DetailsList
|
||||
items={Object.values(this.props.sources)}
|
||||
columns={columns}
|
||||
selectionMode={SelectionMode.single} />
|
||||
|
||||
<Label>选中订阅源</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SourcesTab
|
|
@ -0,0 +1,42 @@
|
|||
import * as React from "react"
|
||||
|
||||
interface TimeProps {
|
||||
date: Date
|
||||
}
|
||||
|
||||
class Time extends React.Component<TimeProps> {
|
||||
timerID: NodeJS.Timeout
|
||||
state = { now: new Date() }
|
||||
|
||||
componentDidMount() {
|
||||
this.timerID = setInterval(
|
||||
() => this.tick(),
|
||||
60000
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.timerID);
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.setState({ now: new Date() });
|
||||
}
|
||||
|
||||
displayTime(past: Date, now: Date): string {
|
||||
// difference in seconds
|
||||
let diff = (now.getTime() - past.getTime()) / 60000
|
||||
if (diff < 1) return "now"
|
||||
else if (diff < 60) return Math.floor(diff) + "m"
|
||||
else if (diff < 1440) return Math.floor(diff / 60) + "h"
|
||||
else return Math.floor(diff / 1440) + "d"
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<span className="time">{ this.displayTime(this.props.date, this.state.now) }</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Time
|
|
@ -0,0 +1,34 @@
|
|||
import { connect } from "react-redux"
|
||||
import { createSelector } from "reselect"
|
||||
import { RootState } from "../scripts/reducer"
|
||||
import { ContextMenuType, closeContextMenu } from "../scripts/models/app"
|
||||
import { ContextMenu } from "../components/context-menu"
|
||||
import { RSSItem, markRead, markUnread } from "../scripts/models/item"
|
||||
|
||||
const getContext = (state: RootState) => state.app.contextMenu
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
[getContext],
|
||||
(context) => {
|
||||
switch (context.type) {
|
||||
case ContextMenuType.Item: return {
|
||||
type: context.type,
|
||||
event: context.event,
|
||||
item: context.target as RSSItem
|
||||
}
|
||||
default: return { type: ContextMenuType.Hidden }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
markRead: item => dispatch(markRead(item)),
|
||||
markUnread: item => dispatch(markUnread(item)),
|
||||
close: () => dispatch(closeContextMenu())
|
||||
}
|
||||
}
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps)
|
||||
export type ContextReduxProps = typeof connector
|
||||
export const ContextMenuContainer = connector(ContextMenu)
|
|
@ -0,0 +1,37 @@
|
|||
import { connect } from "react-redux"
|
||||
import { createSelector } from "reselect"
|
||||
import { RootState } from "../scripts/reducer"
|
||||
import CardsFeed from "../components/feeds/cards-feed"
|
||||
import { markRead, markUnread } from "../scripts/models/item"
|
||||
import { openItemMenu } from "../scripts/models/app"
|
||||
import { FeedIdType, loadMore } from "../scripts/models/feed"
|
||||
|
||||
interface FeedContainerProps {
|
||||
feedId: FeedIdType
|
||||
}
|
||||
|
||||
const getSources = (state: RootState) => state.sources
|
||||
const getItems = (state: RootState) => state.items
|
||||
const getFeed = (state: RootState) => state.feeds[state.page.feedId]
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
return createSelector(
|
||||
[getSources, getItems, getFeed],
|
||||
(sources, items, feed) => ({
|
||||
feed: feed,
|
||||
items: feed.iids.map(iid => items[iid]),
|
||||
sourceMap: sources
|
||||
})
|
||||
)
|
||||
}
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
markRead: item => dispatch(markRead(item)),
|
||||
contextMenu: (item, e) => dispatch(openItemMenu(item, e)),
|
||||
loadMore: feed => dispatch(loadMore(feed))
|
||||
}
|
||||
}
|
||||
|
||||
const connector = connect(makeMapStateToProps, mapDispatchToProps)
|
||||
export type FeedReduxProps = typeof connector
|
||||
export const FeedContainer = connector(CardsFeed)
|
|
@ -0,0 +1,17 @@
|
|||
import { connect } from "react-redux"
|
||||
import { createSelector } from "reselect"
|
||||
import { RootState } from "../scripts/reducer"
|
||||
import { toggleLogMenu } from "../scripts/models/app"
|
||||
import LogMenu from "../components/log-menu"
|
||||
|
||||
const getLogs = (state: RootState) => state.app.logMenu
|
||||
|
||||
const mapStateToProps = createSelector(getLogs, logs => logs)
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return { close: () => dispatch(toggleLogMenu()) }
|
||||
}
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps)
|
||||
export type LogsReduxProps = typeof connector
|
||||
export const LogMenuContainer = connector(LogMenu)
|
|
@ -0,0 +1,43 @@
|
|||
import { connect } from "react-redux"
|
||||
import { createSelector } from "reselect"
|
||||
import { RootState } from "../scripts/reducer"
|
||||
import { Menu } from "../components/menu"
|
||||
import { closeMenu } from "../scripts/models/app"
|
||||
import { selectAllArticles, selectSources, SourceGroup } from "../scripts/models/page"
|
||||
import { initFeeds } from "../scripts/models/feed"
|
||||
import { RSSSource } from "../scripts/models/source"
|
||||
|
||||
const getStatus = (state: RootState) => state.app.menu
|
||||
const getKey = (state: RootState) => state.app.menuKey
|
||||
const getSources = (state: RootState) => state.sources
|
||||
const getGroups = (state: RootState) => state.page.sourceGroups
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
[getStatus, getKey, getSources, getGroups],
|
||||
(status, key, sources, groups) => ({
|
||||
status: status,
|
||||
selected: key,
|
||||
sources: sources,
|
||||
groups: groups
|
||||
})
|
||||
)
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
closeMenu: () => dispatch(closeMenu()),
|
||||
allArticles: () => {
|
||||
dispatch(selectAllArticles()),
|
||||
dispatch(initFeeds())
|
||||
},
|
||||
selectSourceGroup: (group: SourceGroup, menuKey: string) => {
|
||||
dispatch(selectSources(group.sids, menuKey, group.name))
|
||||
dispatch(initFeeds())
|
||||
},
|
||||
selectSource: (source: RSSSource) => {
|
||||
dispatch(selectSources([source.sid], "s-"+source.sid, source.name))
|
||||
dispatch(initFeeds())
|
||||
}
|
||||
})
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps)
|
||||
export type MenuReduxProps = typeof connector
|
||||
export const MenuContainer = connector(Menu)
|
|
@ -0,0 +1,23 @@
|
|||
import { connect } from "react-redux"
|
||||
import { createSelector } from "reselect"
|
||||
import { RootState } from "../scripts/reducer"
|
||||
import { fetchItems } from "../scripts/models/item"
|
||||
import { openMenu, toggleLogMenu, toggleSettings } from "../scripts/models/app"
|
||||
import Nav from "../components/nav"
|
||||
|
||||
const getState = (state: RootState) => state.app
|
||||
|
||||
const mapStateToProps = createSelector(getState, (state) => ({
|
||||
state: state
|
||||
}))
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
fetch: () => dispatch(fetchItems()),
|
||||
menu: () => dispatch(openMenu()),
|
||||
logs: () => dispatch(toggleLogMenu()),
|
||||
settings: () => dispatch(toggleSettings())
|
||||
})
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps)
|
||||
export type NavReduxProps = typeof connector
|
||||
export const NavContainer = connector(Nav)
|
|
@ -0,0 +1,25 @@
|
|||
import { connect } from "react-redux"
|
||||
import { createSelector } from "reselect"
|
||||
import { RootState } from "../scripts/reducer"
|
||||
import { exitSettings} from "../scripts/models/app"
|
||||
import Settings from "../components/settings"
|
||||
|
||||
const getApp = (state: RootState) => state.app
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
[getApp],
|
||||
(app) => ({
|
||||
display: app.settings.display,
|
||||
blocked: !app.sourceInit || app.fetchingItems,
|
||||
exitting: app.settings.saving
|
||||
}))
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
close: () => dispatch(exitSettings())
|
||||
}
|
||||
}
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps)
|
||||
export type SettingsReduxProps = typeof connector
|
||||
export const SettingsContainer = connector(Settings)
|
|
@ -0,0 +1,23 @@
|
|||
import { connect } from "react-redux"
|
||||
import { createSelector } from "reselect"
|
||||
import { RootState } from "../../scripts/reducer"
|
||||
import SourcesTab from "../../components/settings/sources"
|
||||
|
||||
const getSources = (state: RootState) => state.sources
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
[getSources],
|
||||
(sources) => ({
|
||||
sources: sources
|
||||
})
|
||||
)
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps)
|
||||
export type SourcesTabReduxProps = typeof connector
|
||||
export const SourcesTabContainer = connector(SourcesTab)
|
|
@ -0,0 +1,45 @@
|
|||
import { app, ipcMain, BrowserWindow } from "electron"
|
||||
|
||||
let mainWindow: BrowserWindow
|
||||
|
||||
function createWindow() {
|
||||
// Create the browser window.
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 700,
|
||||
minWidth: 992,
|
||||
minHeight: 600,
|
||||
frame: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: true
|
||||
}
|
||||
});
|
||||
|
||||
// and load the index.html of the app.
|
||||
mainWindow.loadFile('index.html');
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
|
||||
app.on('ready', createWindow);
|
||||
|
||||
app.on('window-all-closed', function () {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('activate', function () {
|
||||
if (mainWindow === null) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('minimize', e => mainWindow.minimize());
|
||||
ipcMain.on('maximize', e => {
|
||||
if (mainWindow.isMaximized()) {
|
||||
mainWindow.unmaximize()
|
||||
} else {
|
||||
mainWindow.maximize()
|
||||
}
|
||||
});
|
||||
ipcMain.on('close', e => mainWindow.close());
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; style-src 'self' 'unsafe-inline'; font-src 'self' https://static2.sharepointonline.com">
|
||||
<title>Hello World!</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" style="height: 100%;"></div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,33 @@
|
|||
import * as React from "react"
|
||||
import * as ReactDOM from "react-dom"
|
||||
import { Provider } from "react-redux"
|
||||
import { createStore, applyMiddleware, AnyAction } from "redux"
|
||||
import thunkMiddleware, { ThunkDispatch } from "redux-thunk"
|
||||
import { loadTheme } from '@fluentui/react'
|
||||
import { initializeIcons } from "@fluentui/react/lib/Icons"
|
||||
import { rootReducer, RootState } from "./scripts/reducer"
|
||||
import { initSources, addSource } from "./scripts/models/source"
|
||||
import { fetchItems } from "./scripts/models/item"
|
||||
import Root from "./components/root"
|
||||
import { initFeeds } from "./scripts/models/feed"
|
||||
|
||||
loadTheme({ defaultFontStyle: { fontFamily: '"Source Han Sans", sans-serif' } })
|
||||
initializeIcons("icons/")
|
||||
|
||||
const store = createStore(
|
||||
rootReducer,
|
||||
applyMiddleware<ThunkDispatch<RootState, undefined, AnyAction>, RootState>(thunkMiddleware)
|
||||
)
|
||||
|
||||
store.dispatch(initSources()).then(() => store.dispatch(initFeeds())).then(() => store.dispatch(fetchItems()))
|
||||
/* store.dispatch(addSource("https://www.gcores.com/rss"))
|
||||
.then(() => store.dispatch(addSource("https://www.ifanr.com/feed")))
|
||||
.then(() => store.dispatch(addSource("https://www.vgtime.com/rss.jhtml")))
|
||||
.then(() => store.dispatch(fetchItems())) */
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<Root />
|
||||
</Provider>,
|
||||
document.getElementById("app")
|
||||
)
|
|
@ -0,0 +1,22 @@
|
|||
interface PromiseFulfilledResult<T> {
|
||||
status: "fulfilled";
|
||||
value: T;
|
||||
}
|
||||
|
||||
interface PromiseRejectedResult {
|
||||
status: "rejected";
|
||||
reason: any;
|
||||
}
|
||||
|
||||
type PromiseSettledResult<T> = PromiseFulfilledResult<T> | PromiseRejectedResult;
|
||||
|
||||
declare interface PromiseConstructor {
|
||||
/**
|
||||
* Creates a Promise that is resolved with an array of results when all
|
||||
* of the provided Promises resolve or reject.
|
||||
* @param values An array of Promises.
|
||||
* @returns A new Promise.
|
||||
*/
|
||||
allSettled<T extends readonly unknown[] | readonly [unknown]>(values: T):
|
||||
Promise<{ -readonly [P in keyof T]: PromiseSettledResult<T[P] extends PromiseLike<infer U> ? U : T[P]> }>;
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import Datastore = require("nedb")
|
||||
import { RSSSource } from "./models/source"
|
||||
import { RSSItem } from "./models/item"
|
||||
|
||||
export const sdb = new Datastore<RSSSource>({
|
||||
filename: "sources",
|
||||
autoload: true,
|
||||
onload: (err) => {
|
||||
if (err) window.console.log(err)
|
||||
}
|
||||
})
|
||||
sdb.ensureIndex({ fieldName: "sid", unique: true })
|
||||
//sdb.remove({}, { multi: true })
|
||||
|
||||
export const idb = new Datastore<RSSItem>({
|
||||
filename: "items",
|
||||
autoload: true,
|
||||
onload: (err) => {
|
||||
if (err) window.console.log(err)
|
||||
}
|
||||
})
|
||||
idb.ensureIndex({ fieldName: "id", unique: true })
|
||||
//idb.remove({}, { multi: true })
|
|
@ -0,0 +1,249 @@
|
|||
import { RSSSource, INIT_SOURCES, SourceActionTypes } from "./source"
|
||||
import { RSSItem, ItemActionTypes, FETCH_ITEMS, fetchItems } from "./item"
|
||||
import { ActionStatus, AppThunk } from "../utils"
|
||||
import { INIT_FEEDS, FeedActionTypes, ALL, initFeeds } from "./feed"
|
||||
import { PageActionTypes, SELECT_PAGE, PageType, selectAllArticles } from "./page"
|
||||
|
||||
export enum ContextMenuType {
|
||||
Hidden, Item
|
||||
}
|
||||
|
||||
export enum MenuStatus {
|
||||
Hidden, Open, Pinned
|
||||
}
|
||||
|
||||
export enum AppLogType {
|
||||
Info, Warning, Failure
|
||||
}
|
||||
|
||||
export class AppLog {
|
||||
type: AppLogType
|
||||
title: string
|
||||
details: string
|
||||
time: Date
|
||||
|
||||
constructor(type: AppLogType, title: string, details: string=null) {
|
||||
this.type = type
|
||||
this.title = title
|
||||
this.details = details
|
||||
this.time = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
export class AppState {
|
||||
sourceInit = false
|
||||
feedInit = false
|
||||
fetchingItems = false
|
||||
menu = MenuStatus.Hidden
|
||||
menuKey = ALL
|
||||
title = "全部文章"
|
||||
settings = {
|
||||
display: false,
|
||||
changed: false,
|
||||
saving: false
|
||||
}
|
||||
logMenu = {
|
||||
display: false,
|
||||
notify: false,
|
||||
logs: new Array<AppLog>()
|
||||
}
|
||||
|
||||
contextMenu: {
|
||||
type: ContextMenuType,
|
||||
event?: MouseEvent | string,
|
||||
target?: RSSItem | RSSSource
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.contextMenu = {
|
||||
type: ContextMenuType.Hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CLOSE_CONTEXT_MENU = "CLOSE_CONTEXT_MENU"
|
||||
export const OPEN_ITEM_MENU = "OPEN_ITEM_MENU"
|
||||
|
||||
interface CloseContextMenuAction {
|
||||
type: typeof CLOSE_CONTEXT_MENU
|
||||
}
|
||||
|
||||
interface OpenItemMenuAction {
|
||||
type: typeof OPEN_ITEM_MENU
|
||||
event: MouseEvent
|
||||
item: RSSItem
|
||||
}
|
||||
|
||||
export type ContextMenuActionTypes = CloseContextMenuAction | OpenItemMenuAction
|
||||
|
||||
export const TOGGLE_LOGS = "TOGGLE_LOGS"
|
||||
export interface LogMenuActionType { type: typeof TOGGLE_LOGS }
|
||||
|
||||
export const OPEN_MENU = "OPEN_MENU"
|
||||
export const CLOSE_MENU = "CLOSE_MENU"
|
||||
|
||||
export interface MenuActionTypes {
|
||||
type: typeof OPEN_MENU | typeof CLOSE_MENU
|
||||
}
|
||||
|
||||
export const TOGGLE_SETTINGS = "TOGGLE_SETTINGS"
|
||||
export const SAVE_SETTINGS = "SAVE_SETTINGS"
|
||||
|
||||
export interface SettingsActionTypes {
|
||||
type: typeof TOGGLE_SETTINGS | typeof SAVE_SETTINGS
|
||||
}
|
||||
|
||||
export function closeContextMenu(): ContextMenuActionTypes {
|
||||
return { type: CLOSE_CONTEXT_MENU }
|
||||
}
|
||||
|
||||
export function openItemMenu(item: RSSItem, event: React.MouseEvent): ContextMenuActionTypes {
|
||||
return {
|
||||
type: OPEN_ITEM_MENU,
|
||||
event: event.nativeEvent,
|
||||
item: item
|
||||
}
|
||||
}
|
||||
|
||||
export const openMenu = () => ({ type: OPEN_MENU })
|
||||
export const closeMenu = () => ({ type: CLOSE_MENU })
|
||||
export const toggleLogMenu = () => ({ type: TOGGLE_LOGS })
|
||||
export const toggleSettings = () => ({ type: TOGGLE_SETTINGS })
|
||||
export const saveSettings = () => ({ type: SAVE_SETTINGS })
|
||||
|
||||
export function exitSettings(): AppThunk {
|
||||
return (dispatch, getState) => {
|
||||
if (!getState().app.settings.saving) {
|
||||
if (getState().app.settings.changed) {
|
||||
dispatch(saveSettings())
|
||||
dispatch(selectAllArticles(true))
|
||||
dispatch(fetchItems())
|
||||
.then(() =>{
|
||||
dispatch(toggleSettings())
|
||||
dispatch(initFeeds(true))
|
||||
})
|
||||
} else {
|
||||
dispatch(toggleSettings())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function appReducer(
|
||||
state = new AppState(),
|
||||
action: SourceActionTypes | ItemActionTypes | ContextMenuActionTypes | SettingsActionTypes
|
||||
| MenuActionTypes | LogMenuActionType | FeedActionTypes | PageActionTypes
|
||||
): AppState {
|
||||
switch (action.type) {
|
||||
case INIT_SOURCES:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Success: return {
|
||||
...state,
|
||||
sourceInit: true
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
case INIT_FEEDS:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Request: return {
|
||||
...state,
|
||||
feedInit: false
|
||||
}
|
||||
default: return {
|
||||
...state,
|
||||
feedInit: true
|
||||
}
|
||||
}
|
||||
case FETCH_ITEMS:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Request: return {
|
||||
...state,
|
||||
fetchingItems: true
|
||||
}
|
||||
case ActionStatus.Failure: return {
|
||||
...state,
|
||||
logMenu: {
|
||||
...state.logMenu,
|
||||
notify: true,
|
||||
logs: [...state.logMenu.logs, new AppLog(
|
||||
AppLogType.Failure,
|
||||
`无法加载订阅源“${action.errSource.name}”`,
|
||||
String(action.err)
|
||||
)]
|
||||
}
|
||||
}
|
||||
case ActionStatus.Success: return {
|
||||
...state,
|
||||
fetchingItems: false,
|
||||
logMenu: action.items.length == 0 ? state.logMenu : {
|
||||
...state.logMenu,
|
||||
logs: [...state.logMenu.logs, new AppLog(
|
||||
AppLogType.Info,
|
||||
`成功加载 ${action.items.length} 篇文章`
|
||||
)]
|
||||
}
|
||||
}
|
||||
}
|
||||
case SELECT_PAGE:
|
||||
switch (action.pageType) {
|
||||
case PageType.AllArticles: return {
|
||||
...state,
|
||||
menu: MenuStatus.Hidden,
|
||||
menuKey: ALL,
|
||||
title: "全部文章"
|
||||
}
|
||||
case PageType.Sources: return {
|
||||
...state,
|
||||
menu: MenuStatus.Hidden,
|
||||
menuKey: action.menuKey,
|
||||
title: action.title
|
||||
}
|
||||
}
|
||||
case CLOSE_CONTEXT_MENU: return {
|
||||
...state,
|
||||
contextMenu: {
|
||||
type: ContextMenuType.Hidden
|
||||
}
|
||||
}
|
||||
case OPEN_ITEM_MENU: return {
|
||||
...state,
|
||||
contextMenu: {
|
||||
type: ContextMenuType.Item,
|
||||
event: action.event,
|
||||
target: action.item
|
||||
}
|
||||
}
|
||||
case OPEN_MENU: return {
|
||||
...state,
|
||||
menu: MenuStatus.Open
|
||||
}
|
||||
case CLOSE_MENU: return {
|
||||
...state,
|
||||
menu: MenuStatus.Hidden
|
||||
}
|
||||
case SAVE_SETTINGS: return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
saving: true
|
||||
}
|
||||
}
|
||||
case TOGGLE_SETTINGS: return {
|
||||
...state,
|
||||
settings: {
|
||||
display: !state.settings.display,
|
||||
changed: false,
|
||||
saving: false
|
||||
}
|
||||
}
|
||||
case TOGGLE_LOGS: return {
|
||||
...state,
|
||||
logMenu: {
|
||||
...state.logMenu,
|
||||
display: !state.logMenu.display,
|
||||
notify: false
|
||||
}
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
}
|
|
@ -0,0 +1,265 @@
|
|||
import * as db from "../db"
|
||||
import { SourceActionTypes, INIT_SOURCES, ADD_SOURCE } from "./source"
|
||||
import { ItemActionTypes, FETCH_ITEMS, RSSItem } from "./item"
|
||||
import { ActionStatus, AppThunk } from "../utils"
|
||||
import { PageActionTypes, SELECT_PAGE, PageType } from "./page"
|
||||
|
||||
export const ALL = "ALL"
|
||||
export const SOURCE = "SOURCE"
|
||||
export type FeedIdType = number | string
|
||||
|
||||
const LOAD_QUANTITY = 50
|
||||
|
||||
export class RSSFeed {
|
||||
id: FeedIdType
|
||||
loaded: boolean
|
||||
loading: boolean
|
||||
allLoaded: boolean
|
||||
sids: number[]
|
||||
iids: number[]
|
||||
|
||||
constructor (id: FeedIdType, sids=[]) {
|
||||
this.id = id
|
||||
this.sids = sids
|
||||
this.iids = []
|
||||
this.loaded = false
|
||||
this.allLoaded = false
|
||||
}
|
||||
|
||||
static loadFeed(feed: RSSFeed, init = false): Promise<RSSItem[]> {
|
||||
return new Promise<RSSItem[]>((resolve, reject) => {
|
||||
db.idb.find({ source: { $in: feed.sids } })
|
||||
.sort({ date: -1 })
|
||||
.skip(init ? 0 : feed.iids.length)
|
||||
.limit(LOAD_QUANTITY)
|
||||
.exec((err, docs) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve(docs)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type FeedState = {
|
||||
[id in FeedIdType]: RSSFeed
|
||||
}
|
||||
|
||||
export const INIT_FEEDS = 'INIT_FEEDS'
|
||||
export const INIT_FEED = 'INIT_FEED'
|
||||
export const LOAD_MORE = 'LOAD_MORE'
|
||||
|
||||
interface initFeedsAction {
|
||||
type: typeof INIT_FEEDS
|
||||
status: ActionStatus
|
||||
}
|
||||
|
||||
interface initFeedAction {
|
||||
type: typeof INIT_FEED
|
||||
status: ActionStatus
|
||||
feed?: RSSFeed
|
||||
items?: RSSItem[]
|
||||
err?
|
||||
}
|
||||
|
||||
interface loadMoreAction {
|
||||
type: typeof LOAD_MORE
|
||||
status: ActionStatus
|
||||
feed: RSSFeed
|
||||
items?: RSSItem[]
|
||||
err?
|
||||
}
|
||||
|
||||
export type FeedActionTypes = initFeedAction | initFeedsAction | loadMoreAction
|
||||
|
||||
export function initFeedsRequest(): FeedActionTypes {
|
||||
return {
|
||||
type: INIT_FEEDS,
|
||||
status: ActionStatus.Request
|
||||
}
|
||||
}
|
||||
export function initFeedsSuccess(): FeedActionTypes {
|
||||
return {
|
||||
type: INIT_FEEDS,
|
||||
status: ActionStatus.Success
|
||||
}
|
||||
}
|
||||
|
||||
export function initFeedSuccess(feed: RSSFeed, items: RSSItem[]): FeedActionTypes {
|
||||
return {
|
||||
type: INIT_FEED,
|
||||
status: ActionStatus.Success,
|
||||
items: items,
|
||||
feed: feed
|
||||
}
|
||||
}
|
||||
|
||||
export function initFeedFailure(err): FeedActionTypes {
|
||||
return {
|
||||
type: INIT_FEED,
|
||||
status: ActionStatus.Failure,
|
||||
err: err
|
||||
}
|
||||
}
|
||||
|
||||
export function initFeeds(force = false): AppThunk<Promise<void>> {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(initFeedsRequest())
|
||||
let promises = new Array<Promise<void>>()
|
||||
for (let feed of Object.values(getState().feeds)) {
|
||||
if (!feed.loaded || force) {
|
||||
let p = RSSFeed.loadFeed(feed, force).then(items => {
|
||||
dispatch(initFeedSuccess(feed, items))
|
||||
}).catch(err => {
|
||||
console.log(err)
|
||||
dispatch(initFeedFailure(err))
|
||||
})
|
||||
promises.push(p)
|
||||
}
|
||||
}
|
||||
return Promise.allSettled(promises).then(() => {
|
||||
dispatch(initFeedsSuccess())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function loadMoreRequest(feed: RSSFeed): FeedActionTypes {
|
||||
return {
|
||||
type: LOAD_MORE,
|
||||
status: ActionStatus.Request,
|
||||
feed: feed
|
||||
}
|
||||
}
|
||||
|
||||
export function loadMoreSuccess(feed: RSSFeed, items: RSSItem[]): FeedActionTypes {
|
||||
return {
|
||||
type: LOAD_MORE,
|
||||
status: ActionStatus.Success,
|
||||
feed: feed,
|
||||
items: items
|
||||
}
|
||||
}
|
||||
|
||||
export function loadMoreFailure(feed: RSSFeed, err): FeedActionTypes {
|
||||
return {
|
||||
type: LOAD_MORE,
|
||||
status: ActionStatus.Failure,
|
||||
feed: feed,
|
||||
err: err
|
||||
}
|
||||
}
|
||||
|
||||
export function loadMore(feed: RSSFeed): AppThunk<Promise<void>> {
|
||||
return (dispatch) => {
|
||||
if (feed.loaded && !feed.loading && !feed.allLoaded) {
|
||||
dispatch(loadMoreRequest(feed))
|
||||
return RSSFeed.loadFeed(feed).then(items => {
|
||||
dispatch(loadMoreSuccess(feed, items))
|
||||
}).catch(e => {
|
||||
console.log(e)
|
||||
dispatch(loadMoreFailure(feed, e))
|
||||
})
|
||||
}
|
||||
return new Promise((_, reject) => { reject() })
|
||||
}
|
||||
}
|
||||
|
||||
export function feedReducer(
|
||||
state: FeedState = { [ALL]: new RSSFeed(ALL) },
|
||||
action: SourceActionTypes | ItemActionTypes | FeedActionTypes | PageActionTypes
|
||||
): FeedState {
|
||||
switch (action.type) {
|
||||
case INIT_SOURCES:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Success: return {
|
||||
...state,
|
||||
[ALL]: new RSSFeed(ALL, action.sources.map(s => s.sid))
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
case ADD_SOURCE:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Success: return {
|
||||
...state,
|
||||
[ALL]: new RSSFeed(ALL, [...state[ALL].sids, action.source.sid])
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
case FETCH_ITEMS:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Success: {
|
||||
let nextState = { ...state }
|
||||
for (let k of Object.keys(state)) {
|
||||
if (state[k].loaded) {
|
||||
let iids = action.items.filter(i => state[k].sids.includes(i.source)).map(i => i.id)
|
||||
if (iids.length > 0) {
|
||||
nextState[k] = {
|
||||
...nextState[k],
|
||||
iids: [...iids, ...nextState[k].iids]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nextState
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
case INIT_FEED:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Success: return {
|
||||
...state,
|
||||
[action.feed.id]: {
|
||||
...action.feed,
|
||||
loaded: true,
|
||||
allLoaded: action.items.length < LOAD_QUANTITY,
|
||||
iids: action.items.map(i => i.id)
|
||||
}
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
case LOAD_MORE:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Request: return {
|
||||
...state,
|
||||
[action.feed.id] : {
|
||||
...action.feed,
|
||||
loading: true
|
||||
}
|
||||
}
|
||||
case ActionStatus.Success: return {
|
||||
...state,
|
||||
[action.feed.id] : {
|
||||
...action.feed,
|
||||
loading: false,
|
||||
allLoaded: action.items.length < LOAD_QUANTITY,
|
||||
iids: [...action.feed.iids, ...action.items.map(i => i.id)]
|
||||
}
|
||||
}
|
||||
case ActionStatus.Failure: return {
|
||||
...state,
|
||||
[action.feed.id] : {
|
||||
...action.feed,
|
||||
loading: false
|
||||
}
|
||||
}
|
||||
}
|
||||
case SELECT_PAGE:
|
||||
switch (action.pageType) {
|
||||
case PageType.Sources: return {
|
||||
...state,
|
||||
[SOURCE]: new RSSFeed(SOURCE, action.sids)
|
||||
}
|
||||
case PageType.AllArticles: return action.init ? {
|
||||
...state,
|
||||
[ALL]: {
|
||||
...state[ALL],
|
||||
loaded: false
|
||||
}
|
||||
} : state
|
||||
default: return state
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
}
|
|
@ -0,0 +1,208 @@
|
|||
import * as db from "../db"
|
||||
import { rssParser, rssProxyParser, domParser, htmlDecode, ActionStatus, AppThunk } from "../utils"
|
||||
import { RSSSource } from "./source"
|
||||
import { FeedActionTypes, INIT_FEED, LOAD_MORE } from "./feed"
|
||||
import Parser = require("rss-parser")
|
||||
|
||||
export class RSSItem {
|
||||
id: number
|
||||
source: number
|
||||
title: string
|
||||
link: string
|
||||
date: Date
|
||||
fetchedDate: Date
|
||||
thumb?: string
|
||||
content: string
|
||||
snippet: string
|
||||
creator: string
|
||||
categories: string[]
|
||||
hasRead: boolean
|
||||
|
||||
constructor (item: Parser.Item, source: RSSSource) {
|
||||
this.source = source.sid
|
||||
this.title = item.title
|
||||
this.link = item.link
|
||||
this.date = new Date(item.isoDate)
|
||||
this.fetchedDate = new Date()
|
||||
if (item.thumb) this.thumb = item.thumb
|
||||
else if (item.image) this.thumb = item.image
|
||||
else {
|
||||
let dom = domParser.parseFromString(item.content, "text/html")
|
||||
let img = dom.querySelector("img")
|
||||
if (img && img.src) this.thumb = img.src
|
||||
}
|
||||
if (item.fullContent) {
|
||||
this.content = item.fullContent
|
||||
this.snippet = htmlDecode(item.fullContent)
|
||||
} else {
|
||||
this.content = item.content
|
||||
this.snippet = htmlDecode(item.contentSnippet)
|
||||
}
|
||||
this.creator = item.creator
|
||||
this.categories = item.categories
|
||||
this.hasRead = false
|
||||
}
|
||||
}
|
||||
|
||||
export type ItemState = {
|
||||
[id: number]: RSSItem
|
||||
}
|
||||
|
||||
export const FETCH_ITEMS = 'FETCH_ITEMS'
|
||||
export const MARK_READ = "MARK_READ"
|
||||
export const MARK_UNREAD = "MARK_UNREAD"
|
||||
|
||||
interface FetchItemsAction {
|
||||
type: typeof FETCH_ITEMS
|
||||
status: ActionStatus
|
||||
items?: RSSItem[]
|
||||
errSource?: RSSSource
|
||||
err?
|
||||
}
|
||||
|
||||
interface MarkReadAction {
|
||||
type: typeof MARK_READ
|
||||
item: RSSItem
|
||||
}
|
||||
|
||||
interface MarkUnreadAction {
|
||||
type: typeof MARK_UNREAD
|
||||
item: RSSItem
|
||||
}
|
||||
|
||||
export type ItemActionTypes = FetchItemsAction | MarkReadAction | MarkUnreadAction
|
||||
|
||||
export function fetchItemsRequest(): ItemActionTypes {
|
||||
return {
|
||||
type: FETCH_ITEMS,
|
||||
status: ActionStatus.Request
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchItemsSuccess(items: RSSItem[]): ItemActionTypes {
|
||||
return {
|
||||
type: FETCH_ITEMS,
|
||||
status: ActionStatus.Success,
|
||||
items: items
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchItemsFailure(source: RSSSource, err): ItemActionTypes {
|
||||
return {
|
||||
type: FETCH_ITEMS,
|
||||
status: ActionStatus.Failure,
|
||||
errSource: source,
|
||||
err: err
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchItems(): AppThunk<Promise<void>> {
|
||||
return (dispatch, getState) => {
|
||||
let p = new Array<Promise<RSSItem[]>>()
|
||||
if (!getState().app.fetchingItems) {
|
||||
for (let source of <RSSSource[]>Object.values(getState().sources)) {
|
||||
p.push(RSSSource.fetchItems(source, rssParser, db.idb))
|
||||
}
|
||||
dispatch(fetchItemsRequest())
|
||||
return Promise.allSettled(p).then(results => new Promise<void>((resolve, reject) => {
|
||||
db.idb.count({}, (err, count) => {
|
||||
if (err) {
|
||||
console.log(err)
|
||||
reject(err)
|
||||
}
|
||||
let items = new Array<RSSItem>()
|
||||
results.map((r, i) => {
|
||||
if (r.status === "fulfilled") items.push(...r.value)
|
||||
else {
|
||||
console.log(r.reason)
|
||||
dispatch(fetchItemsFailure(getState().sources[i], r.reason))
|
||||
}
|
||||
})
|
||||
items.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||
for (let i of items) i.id = count++
|
||||
db.idb.insert(items, (err) => {
|
||||
if (err) {
|
||||
console.log(err)
|
||||
reject(err)
|
||||
} else {
|
||||
dispatch(fetchItemsSuccess(items.reverse()))
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
return new Promise((resolve) => { resolve() })
|
||||
}
|
||||
}
|
||||
|
||||
export const markReadDone = (item: RSSItem): ItemActionTypes => ({
|
||||
type: MARK_READ,
|
||||
item: item
|
||||
})
|
||||
|
||||
export const markUnreadDone = (item: RSSItem): ItemActionTypes => ({
|
||||
type: MARK_UNREAD,
|
||||
item: item
|
||||
})
|
||||
|
||||
export function markRead(item: RSSItem): AppThunk {
|
||||
return (dispatch) => {
|
||||
db.idb.update({ id: item.id }, { $set: { hasRead: true } })
|
||||
dispatch(markReadDone(item))
|
||||
}
|
||||
}
|
||||
|
||||
export function markUnread(item: RSSItem): AppThunk {
|
||||
return (dispatch) => {
|
||||
db.idb.update({ id: item.id }, { $set: { hasRead: false } })
|
||||
dispatch(markUnreadDone(item))
|
||||
}
|
||||
}
|
||||
|
||||
export function itemReducer(
|
||||
state: ItemState = {},
|
||||
action: ItemActionTypes | FeedActionTypes
|
||||
): ItemState {
|
||||
switch (action.type) {
|
||||
case FETCH_ITEMS:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Success: {
|
||||
let newMap = {}
|
||||
for (let i of action.items) {
|
||||
newMap[i.id] = i
|
||||
}
|
||||
return {...newMap, ...state}
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
case MARK_READ: return {
|
||||
...state,
|
||||
[action.item.id] : {
|
||||
...action.item,
|
||||
hasRead: true
|
||||
}
|
||||
}
|
||||
case MARK_UNREAD: return {
|
||||
...state,
|
||||
[action.item.id] : {
|
||||
...action.item,
|
||||
hasRead: false
|
||||
}
|
||||
}
|
||||
case LOAD_MORE:
|
||||
case INIT_FEED: {
|
||||
switch (action.status) {
|
||||
case ActionStatus.Success: {
|
||||
let nextState = { ...state }
|
||||
for (let i of action.items) {
|
||||
nextState[i.id] = i
|
||||
}
|
||||
return nextState
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
import { RSSSource, SourceActionTypes, INIT_SOURCES, ADD_SOURCE } from "./source"
|
||||
import { ALL, SOURCE } from "./feed"
|
||||
import { ActionStatus } from "../utils"
|
||||
|
||||
const GROUPS_STORE_KEY = "sourceGroups"
|
||||
|
||||
export class SourceGroup {
|
||||
isMultiple: boolean
|
||||
sids: number[]
|
||||
name?: string
|
||||
|
||||
constructor(sources: RSSSource[], name: string = "订阅源组") {
|
||||
if (sources.length == 1) {
|
||||
this.isMultiple = false
|
||||
} else {
|
||||
this.isMultiple = true
|
||||
this.name = name
|
||||
}
|
||||
this.sids = sources.map(s => s.sid)
|
||||
}
|
||||
|
||||
static save(groups: SourceGroup[]) {
|
||||
localStorage.setItem(GROUPS_STORE_KEY, JSON.stringify(groups))
|
||||
}
|
||||
|
||||
static load(): SourceGroup[] {
|
||||
return <SourceGroup[]>JSON.parse(localStorage.getItem(GROUPS_STORE_KEY))
|
||||
}
|
||||
}
|
||||
|
||||
export const SELECT_PAGE = "SELECT_PAGE"
|
||||
|
||||
export enum PageType {
|
||||
AllArticles, Sources, Page
|
||||
}
|
||||
|
||||
interface SelectPageAction {
|
||||
type: typeof SELECT_PAGE
|
||||
pageType: PageType
|
||||
init: boolean
|
||||
sids?: number[]
|
||||
menuKey?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type PageActionTypes = SelectPageAction
|
||||
|
||||
export function selectAllArticles(init = false): SelectPageAction {
|
||||
return {
|
||||
type: SELECT_PAGE,
|
||||
pageType: PageType.AllArticles,
|
||||
init: init
|
||||
}
|
||||
}
|
||||
|
||||
export function selectSources(sids: number[], menuKey: string, title: string) {
|
||||
return {
|
||||
type: SELECT_PAGE,
|
||||
pageType: PageType.Sources,
|
||||
sids: sids,
|
||||
menuKey: menuKey,
|
||||
title: title,
|
||||
init: true
|
||||
}
|
||||
}
|
||||
|
||||
export class PageState {
|
||||
feedId = ALL
|
||||
sourceGroups = new Array<SourceGroup>()
|
||||
}
|
||||
|
||||
|
||||
export function pageReducer(
|
||||
state = new PageState(),
|
||||
action: PageActionTypes | SourceActionTypes
|
||||
): PageState {
|
||||
switch(action.type) {
|
||||
case INIT_SOURCES:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Success: return {
|
||||
...state,
|
||||
sourceGroups: [new SourceGroup(action.sources, "中文")]
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
case ADD_SOURCE:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Success: return {
|
||||
...state,
|
||||
sourceGroups: [
|
||||
...state.sourceGroups,
|
||||
new SourceGroup([action.source])
|
||||
]
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
case SELECT_PAGE:
|
||||
switch (action.pageType) {
|
||||
case PageType.AllArticles: return {
|
||||
...state,
|
||||
feedId: ALL
|
||||
}
|
||||
case PageType.Sources: return {
|
||||
...state,
|
||||
feedId: SOURCE
|
||||
}
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
}
|
|
@ -0,0 +1,223 @@
|
|||
import Parser = require("rss-parser")
|
||||
import * as db from "../db"
|
||||
import { rssParser, faviconPromise, ActionStatus, AppThunk } from "../utils"
|
||||
import { RSSItem } from "./item"
|
||||
|
||||
export class RSSSource {
|
||||
sid: number
|
||||
url: string
|
||||
iconurl: string
|
||||
name: string
|
||||
description: string
|
||||
useProxy: boolean
|
||||
|
||||
constructor(url: string, useProxy=false) {
|
||||
this.url = url
|
||||
this.useProxy = useProxy
|
||||
}
|
||||
|
||||
async fetchMetaData(parser: Parser) {
|
||||
let feed = await parser.parseURL(this.url)
|
||||
this.name = feed.title
|
||||
this.description = feed.description
|
||||
let domain = this.url.split("/").slice(0, 3).join("/")
|
||||
this.iconurl = await faviconPromise(domain)
|
||||
if (this.iconurl === null) {
|
||||
let f = domain + "/favicon.ico"
|
||||
let result = await fetch(f)
|
||||
if (result.status == 200) this.iconurl = f
|
||||
}
|
||||
}
|
||||
|
||||
private static checkItem(source:RSSSource, item: Parser.Item, db: Nedb<RSSItem>): Promise<RSSItem> {
|
||||
return new Promise<RSSItem>((resolve, reject) => {
|
||||
let i = new RSSItem(item, source)
|
||||
db.findOne({
|
||||
source: i.source,
|
||||
title: i.title,
|
||||
date: i.date
|
||||
},
|
||||
(err, doc) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else if (doc === null) {
|
||||
resolve(i)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
static fetchItems(source:RSSSource, parser: Parser, db: Nedb<RSSItem>): Promise<RSSItem[]> {
|
||||
return new Promise<RSSItem[]>((resolve, reject) => {
|
||||
parser.parseURL(source.url)
|
||||
.then(feed => {
|
||||
let p = new Array<Promise<RSSItem>>()
|
||||
for (let item of feed.items) {
|
||||
p.push(this.checkItem(source, item, db))
|
||||
}
|
||||
Promise.all(p).then(values => {
|
||||
resolve(values.filter(v => v != null))
|
||||
}).catch(e => { reject(e) })
|
||||
})
|
||||
.catch(e => { reject(e) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type SourceState = {
|
||||
[sid: number]: RSSSource
|
||||
}
|
||||
|
||||
export const INIT_SOURCES = 'INIT_SOURCES'
|
||||
export const ADD_SOURCE = 'ADD_SOURCE'
|
||||
export const UPDATE_SOURCE = 'UPDATE_SOURCE'
|
||||
|
||||
interface InitSourcesAction {
|
||||
type: typeof INIT_SOURCES
|
||||
status: ActionStatus
|
||||
sources?: RSSSource[]
|
||||
err?
|
||||
}
|
||||
|
||||
interface AddSourceAction {
|
||||
type: typeof ADD_SOURCE
|
||||
status: ActionStatus
|
||||
source?: RSSSource
|
||||
err?
|
||||
}
|
||||
|
||||
interface UpdateSourceAction {
|
||||
type: typeof UPDATE_SOURCE
|
||||
status: ActionStatus
|
||||
source?: RSSSource
|
||||
err?
|
||||
}
|
||||
|
||||
export type SourceActionTypes = InitSourcesAction | AddSourceAction | UpdateSourceAction
|
||||
|
||||
export function initSourcesRequest(): SourceActionTypes {
|
||||
return {
|
||||
type: INIT_SOURCES,
|
||||
status: ActionStatus.Request
|
||||
}
|
||||
}
|
||||
|
||||
export function initSourcesSuccess(sources: RSSSource[]): SourceActionTypes {
|
||||
return {
|
||||
type: INIT_SOURCES,
|
||||
status: ActionStatus.Success,
|
||||
sources: sources
|
||||
}
|
||||
}
|
||||
|
||||
export function initSourcesFailure(err): SourceActionTypes {
|
||||
return {
|
||||
type: INIT_SOURCES,
|
||||
status: ActionStatus.Failure,
|
||||
err: err
|
||||
}
|
||||
}
|
||||
|
||||
export function initSources(): AppThunk<Promise<void>> {
|
||||
return (dispatch) => {
|
||||
dispatch(initSourcesRequest())
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
db.sdb.find({}).sort({ sid: 1 }).exec((err, docs) => {
|
||||
if (err) {
|
||||
dispatch(initSourcesFailure(err))
|
||||
reject(err)
|
||||
} else {
|
||||
dispatch(initSourcesSuccess(docs))
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function addSourceRequest(): SourceActionTypes {
|
||||
return {
|
||||
type: ADD_SOURCE,
|
||||
status: ActionStatus.Request
|
||||
}
|
||||
}
|
||||
|
||||
export function addSourceSuccess(source: RSSSource): SourceActionTypes {
|
||||
return {
|
||||
type: ADD_SOURCE,
|
||||
status: ActionStatus.Success,
|
||||
source: source
|
||||
}
|
||||
}
|
||||
|
||||
export function addSourceFailure(err): SourceActionTypes {
|
||||
return {
|
||||
type: ADD_SOURCE,
|
||||
status: ActionStatus.Failure,
|
||||
err: err
|
||||
}
|
||||
}
|
||||
|
||||
export function addSource(url: string): AppThunk<Promise<void>> {
|
||||
return (dispatch, getState) => {
|
||||
if (getState().app.sourceInit) {
|
||||
dispatch(addSourceRequest())
|
||||
let source = new RSSSource(url)
|
||||
return source.fetchMetaData(rssParser)
|
||||
.then(() => {
|
||||
let sids = Object.values(getState().sources).map(s=>s.sid)
|
||||
source.sid = Math.max(...sids, -1) + 1
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
db.sdb.insert(source, (err) => {
|
||||
if (err) {
|
||||
console.log(err)
|
||||
dispatch(addSourceFailure(err))
|
||||
reject(err)
|
||||
} else {
|
||||
dispatch(addSourceSuccess(source))
|
||||
/* dispatch(fetchItems()).then(() => {
|
||||
dispatch(initFeeds())
|
||||
}) */
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch(e => {
|
||||
console.log(e)
|
||||
dispatch(addSourceFailure(e))
|
||||
})
|
||||
}
|
||||
return new Promise((_, reject) => { reject("Need to init sources before adding.") })
|
||||
}
|
||||
}
|
||||
|
||||
export function sourceReducer(
|
||||
state: SourceState = {},
|
||||
action: SourceActionTypes
|
||||
): SourceState {
|
||||
switch (action.type) {
|
||||
case INIT_SOURCES:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Success: {
|
||||
let newState: SourceState = {}
|
||||
for (let source of action.sources) {
|
||||
newState[source.sid] = source
|
||||
}
|
||||
return newState
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
case ADD_SOURCE:
|
||||
switch (action.status) {
|
||||
case ActionStatus.Success: return {
|
||||
...state,
|
||||
[action.source.sid]: action.source
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { combineReducers } from "redux"
|
||||
|
||||
import { sourceReducer } from "./models/source"
|
||||
import { itemReducer } from "./models/item"
|
||||
import { feedReducer } from "./models/feed"
|
||||
import { appReducer } from "./models/app"
|
||||
import { pageReducer } from "./models/page"
|
||||
|
||||
export const rootReducer = combineReducers({
|
||||
sources: sourceReducer,
|
||||
items: itemReducer,
|
||||
feeds: feedReducer,
|
||||
page: pageReducer,
|
||||
app: appReducer
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof rootReducer>
|
|
@ -0,0 +1,60 @@
|
|||
import { shell } from "electron"
|
||||
import { ThunkAction } from "redux-thunk"
|
||||
import { AnyAction } from "redux"
|
||||
import { RootState } from "./reducer"
|
||||
|
||||
export enum ActionStatus {
|
||||
Request, Success, Failure
|
||||
}
|
||||
|
||||
export type AppThunk<ReturnType = void> = ThunkAction<
|
||||
ReturnType,
|
||||
RootState,
|
||||
unknown,
|
||||
AnyAction
|
||||
>
|
||||
|
||||
import Parser = require("rss-parser")
|
||||
const customFields = {
|
||||
item: ["thumb", "image", ["content:encoded", "fullContent"]] as Parser.CustomFieldItem[]
|
||||
}
|
||||
export const rssParser = new Parser({
|
||||
customFields: customFields
|
||||
})
|
||||
import { HttpsProxyAgent } from "https-proxy-agent"
|
||||
import url = require("url")
|
||||
let agent = new HttpsProxyAgent(url.parse("http://127.0.0.1:1080"))
|
||||
export const rssProxyParser = new Parser({
|
||||
customFields: customFields,
|
||||
requestOptions: {
|
||||
agent: agent
|
||||
}
|
||||
})
|
||||
|
||||
export const domParser = new DOMParser()
|
||||
|
||||
const favicon = require("favicon")
|
||||
export function faviconPromise(url: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
favicon(url, (err, icon: string) => {
|
||||
if (err || !icon) reject(err)
|
||||
else {
|
||||
let parts = icon.split("//")
|
||||
resolve(parts[0] + "//" + parts[parts.length - 1])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function htmlDecode(input: string) {
|
||||
var doc = domParser.parseFromString(input, "text/html");
|
||||
return doc.documentElement.textContent;
|
||||
}
|
||||
|
||||
export function openExternal(url: string) {
|
||||
if (url.startsWith("https://") || url.startsWith("http://"))
|
||||
shell.openExternal(url)
|
||||
}
|
||||
|
||||
export const urlTest = (s: string) =>
|
||||
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi.test(s)
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"target": "ES2019",
|
||||
"module": "CommonJS"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
mode: 'development',
|
||||
entry: './src/electron.ts',
|
||||
target: 'electron-main',
|
||||
module: {
|
||||
rules: [{
|
||||
test: /\.ts$/,
|
||||
include: /src/,
|
||||
use: [{ loader: 'ts-loader' }]
|
||||
}]
|
||||
},
|
||||
output: {
|
||||
path: __dirname + '/dist',
|
||||
filename: 'electron.js'
|
||||
}
|
||||
},
|
||||
{
|
||||
mode: 'development',
|
||||
entry: './src/index.tsx',
|
||||
target: 'electron-renderer',
|
||||
devtool: 'source-map',
|
||||
module: {
|
||||
rules: [{
|
||||
test: /\.ts(x?)$/,
|
||||
include: /src/,
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js']
|
||||
},
|
||||
use: [{ loader: 'ts-loader' }]
|
||||
}]
|
||||
},
|
||||
output: {
|
||||
path: __dirname + '/dist',
|
||||
filename: 'index.js'
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: './src/index.html'
|
||||
})
|
||||
]
|
||||
}
|
||||
];
|
Loading…
Reference in New Issue