Compare commits

..

9 Commits

11 changed files with 169 additions and 10 deletions

View File

@ -23,6 +23,9 @@ func Refresh() error {
go RefreshFeed(feed.FeedURL) go RefreshFeed(feed.FeedURL)
} }
fmt.Println("Reaping old items...")
feedStore.DeleteOldReadItems()
fmt.Printf("Going to sleep for %d minutes\n", interval) fmt.Printf("Going to sleep for %d minutes\n", interval)
time.Sleep(time.Duration(interval) * time.Minute) time.Sleep(time.Duration(interval) * time.Minute)
} }

View File

@ -1,6 +1,8 @@
package feeds package feeds
import ( import (
"time"
"github.com/spf13/viper" "github.com/spf13/viper"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
@ -55,6 +57,37 @@ func (fs *FeedStore) GetUnread() *[]ItemWithFeed {
return items return items
} }
func (fs *FeedStore) GetSaved() *[]ItemWithFeed {
items := &[]ItemWithFeed{}
fs.getDB().Table("items").
Where("save = ?", true).
Select("items.*, feeds.title as feed_title, feeds.homepage_url as feed_homepage_url").
Order("items.created desc, items.title").
Joins("left join feeds on feeds.id = items.feed_id").
Find(items)
return items
}
func (fs *FeedStore) DeleteOldReadItems() {
t := time.Now()
threshold := t.Add(-time.Hour * 24 * 7)
fs.getDB().Table("items").
Where("save = ? and read = ? and created < ?", false, true, threshold).
Delete(Item{})
}
func (fs *FeedStore) GetAll() *[]ItemWithFeed {
items := &[]ItemWithFeed{}
fs.getDB().Table("items").
Select("items.*, feeds.title as feed_title, feeds.homepage_url as feed_homepage_url").
Order("items.created desc, items.title").
Joins("left join feeds on feeds.id = items.feed_id").
Find(items)
return items
}
func (fs *FeedStore) SaveFeed(feed Feed) { func (fs *FeedStore) SaveFeed(feed Feed) {
fs.getDB().Omit("Items").Clauses(clause.OnConflict{ fs.getDB().Omit("Items").Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}}, Columns: []clause.Column{{Name: "id"}},
@ -77,3 +110,12 @@ func (fs *FeedStore) MarkAsRead(itemID string) {
fs.getDB().Save(*item) fs.getDB().Save(*item)
} }
func (fs *FeedStore) ToggleSaved(itemID string) {
item := &Item{}
fs.getDB().Where("id = ?", itemID).First(item)
item.Save = !item.Save
fs.getDB().Save(*item)
}

View File

@ -46,6 +46,7 @@ type Item struct {
FeedID string FeedID string
Read bool Read bool
Save bool Save bool
DeletedAt gorm.DeletedAt
} }
type ItemWithFeed struct { type ItemWithFeed struct {

View File

@ -33,6 +33,14 @@ func (a *API) GetUnread(c *fiber.Ctx) error {
return c.JSON(a.FeedStore.GetUnread()) return c.JSON(a.FeedStore.GetUnread())
} }
func (a *API) GetSaved(c *fiber.Ctx) error {
return c.JSON(a.FeedStore.GetSaved())
}
func (a *API) GetAll(c *fiber.Ctx) error {
return c.JSON(a.FeedStore.GetAll())
}
func (a *API) PostRead(c *fiber.Ctx) error { func (a *API) PostRead(c *fiber.Ctx) error {
a.FeedStore.MarkAsRead(c.Params("id")) a.FeedStore.MarkAsRead(c.Params("id"))
return nil return nil
@ -56,3 +64,8 @@ func (a *API) RefreshAll(c *fiber.Ctx) error {
return c.JSON(a.FeedStore.GetUnread()) return c.JSON(a.FeedStore.GetUnread())
} }
func (a *API) SaveItem(c *fiber.Ctx) error {
a.FeedStore.ToggleSaved(c.Params("id"))
return nil
}

View File

@ -51,7 +51,10 @@ func Start(port string) error {
app.Post("/api/feeds", api.PostFeed) app.Post("/api/feeds", api.PostFeed)
app.Get("/api/feed/:id", api.GetFeed) app.Get("/api/feed/:id", api.GetFeed)
app.Get("/api/item/:id", api.GetItem) app.Get("/api/item/:id", api.GetItem)
app.Post("/api/item/:id/save", api.SaveItem)
app.Get("/api/unread", api.GetUnread) app.Get("/api/unread", api.GetUnread)
app.Get("/api/saved", api.GetSaved)
app.Get("/api/all", api.GetAll)
app.Post("/api/read/:id", api.PostRead) app.Post("/api/read/:id", api.PostRead)
app.Post("/api/read", api.PostReadAll) app.Post("/api/read", api.PostReadAll)
app.Get("/api/refresh", api.RefreshAll) app.Get("/api/refresh", api.RefreshAll)

View File

@ -4,6 +4,17 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gopherss</title> <title>Gopherss</title>
<meta name="apple-mobile-web-app-capable" content="yes">
<!-- <link rel="apple-touch-icon" href="static/icon.png"> -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/manifest.json">
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
</script>
<script src="/static/feed-item.js" defer></script> <script src="/static/feed-item.js" defer></script>
<script src="https://unpkg.com/vue@2.5.17/dist/vue.min.js"></script> <script src="https://unpkg.com/vue@2.5.17/dist/vue.min.js"></script>
@ -26,6 +37,9 @@
<div id="app"> <div id="app">
<div class="menu"> <div class="menu">
<button title="Show Read" v-on:click="toggleShowRead()">
<svg width="30" height="30" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 9a4 4 0 110 8 4 4 0 010-8zm0-3.5a10 10 0 019.7 7.6.8.8 0 01-1.5.3 8.5 8.5 0 00-16.4 0 .8.8 0 01-1.5-.3A10 10 0 0112 5.5z" fill="#212121" fill-rule="nonzero"/></svg>
</button>
<button title="Toggle dark mode" v-on:click="toggleDarkMode()"> <button title="Toggle dark mode" v-on:click="toggleDarkMode()">
<svg width="30" height="30" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M12 22a10 10 0 100-20 10 10 0 000 20zm0-2V4a8 8 0 110 16z" fill="#212121" fill-rule="nonzero"/></svg> <svg width="30" height="30" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M12 22a10 10 0 100-20 10 10 0 000 20zm0-2V4a8 8 0 110 16z" fill="#212121" fill-rule="nonzero"/></svg>
</button> </button>
@ -43,7 +57,12 @@
</div> </div>
<div v-for="feed in feeds" :class="{strong: unreadCounts[feed.ID], 'alert': true, 'alert-success': selectedFeed == feed.FeedURL }" :data-feed="feed.FeedURL" v-on:click="loadFeed(feed.ID)"> <div v-for="feed in feeds" :class="{strong: unreadCounts[feed.ID], 'alert': true, 'alert-success': selectedFeed == feed.FeedURL }" :data-feed="feed.FeedURL" v-on:click="loadFeed(feed.ID)">
{{feed.Title}} ({{unreadCounts[feed.ID]}}) {{feed.Title}} ({{unreadCounts[feed.ID] || '0'}})
</div>
<div :class="{ strong: items.length, alert: true, 'alert-success': selectedFeed == 'SAVED'}" v-on:click="loadFeed('SAVED')">
<svg viewBox="0 0 24 24" width="18" height="18" xmlns="http://www.w3.org/2000/svg"><path d="M12.8 5.6l-.8.8-.8-.8a5.4 5.4 0 00-7.6 7.6l7.9 7.9c.3.3.7.3 1 0l8-8a5.4 5.4 0 10-7.7-7.5z" style="fill: #ff2e88" fill-rule="nonzero"/></svg>
Saved ({{saved}})
</div> </div>
<div class="menu"> <div class="menu">
@ -86,6 +105,12 @@
</div> </div>
<div class="card item-content" :data-id="item.ID" v-if="item.ID == selectedItem"> <div class="card item-content" :data-id="item.ID" v-if="item.ID == selectedItem">
<div class="card-content"> <div class="card-content">
<div class="menu">
<button title="Save" v-on:click="saveItem(item)" :disabled="isBusy">
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg"><path d="M12.8 5.6l-.8.8-.8-.8a5.4 5.4 0 00-7.6 7.6l7.9 7.9c.3.3.7.3 1 0l8-8a5.4 5.4 0 10-7.7-7.5z" :style="{'fill': item.Save ? '#ff2e88' : '#eee' }" fill-rule="nonzero"/></svg>
</button>
</div>
<feed-item :item-id="item.ID" :class="{ dark: isDark }"></feed-item> <feed-item :item-id="item.ID" :class="{ dark: isDark }"></feed-item>
</div> </div>
</div> </div>
@ -106,6 +131,7 @@
data: { data: {
feeds: [], feeds: [],
items: [], items: [],
savedItems: [],
selectedFeed: '', selectedFeed: '',
selectedItem: undefined, selectedItem: undefined,
showAddModal: false, showAddModal: false,
@ -113,18 +139,24 @@
opml: '', opml: '',
isBusy: false, isBusy: false,
isDark: false, isDark: false,
showRead: false,
}, },
computed: { computed: {
shownItems() { shownItems() {
if (this.selectedFeed === '') { if (this.selectedFeed === '') {
return this.items; return this.items.filter(item => item.ID == this.selectedItem || !item.Read || item.Read === this.showRead);
} else if (this.selectedFeed === 'SAVED') {
return this.savedItems;
} else { } else {
return this.items.filter(item => item.FeedID === this.selectedFeed); return this.items.filter(item => item.ID == this.selectedItem || item.FeedID === this.selectedFeed && (!item.Read || item.Read === this.showRead));
} }
}, },
unread() { unread() {
return this.items.filter(item => !item.Read).length; return this.items.filter(item => !item.Read).length;
}, },
saved() {
return this.savedItems.length;
},
unreadCounts() { unreadCounts() {
return this.items.filter(item => !item.Read).reduce((acc, item) => { return this.items.filter(item => !item.Read).reduce((acc, item) => {
if (!acc[item.FeedID]) acc[item.FeedID] = 0; if (!acc[item.FeedID]) acc[item.FeedID] = 0;
@ -152,13 +184,25 @@
this.selectedItem = undefined; this.selectedItem = undefined;
} else { } else {
this.selectedItem = item.ID; this.selectedItem = item.ID;
// document.querySelector(`feed-item[data-id='${item.ID}']`).content = item.Content || item.Description;
document.getElementById(this.selectedItem).scrollIntoView(); document.getElementById(this.selectedItem).scrollIntoView();
item.Read = true; item.Read = true;
fetch(`/api/read/${item.ID}`, {method: "POST"}) fetch(`/api/read/${item.ID}`, {method: "POST"})
} }
}, },
saveItem(item) {
this.setBusy(true);
fetch(`/api/item/${item.ID}/save`, {method: "POST"})
.then(() => {
item.Save = !item.Save;
if (item.Save) {
this.savedItems.push(item);
} else {
this.savedItems = this.savedItems.filter(i => item.ID != i.ID);
}
this.setBusy(false);
})
},
nextItem() { nextItem() {
let currentItem = -1; let currentItem = -1;
if (this.selectedItem != undefined) { if (this.selectedItem != undefined) {
@ -190,14 +234,13 @@
}, },
markAllRead() { markAllRead() {
let ids = this.shownItems.filter(item => !item.Read).map(item => item.ID); let ids = this.shownItems.filter(item => !item.Read).map(item => item.ID);
if (confirm(`Are you sure you want to mark ${ids.length} items as read?`)) { if (ids.length > 0 && confirm(`Are you sure you want to mark ${ids.length} items as read?`)) {
this.setBusy(true); this.setBusy(true);
this.shownItems.filter(item => !item.Read).forEach(item => item.Read = true)
fetch( fetch(
`/api/read`, `/api/read`,
{method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(ids)} {method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(ids)}
) )
.then(res => res.json())
.then(items => this.items = items)
.then(() => { .then(() => {
this.setBusy(false); this.setBusy(false);
}) })
@ -261,13 +304,29 @@
console.error(err); console.error(err);
this.setBusy(false); this.setBusy(false);
}); });
},
toggleShowRead() {
this.showRead = !this.showRead;
if (this.showRead && !this.items.some(item => item.Read)) {
this.setBusy(true);
fetch('/api/all').then(res => res.json()).then(items => this.items = items)
.then(() => {
this.setBusy(false);
})
.catch(err => {
console.error(err);
this.setBusy(false);
});
}
} }
}, },
created() { created() {
this.setBusy(true); this.setBusy(true);
Promise.all([ Promise.all([
fetch(`/api/feeds`).then(res => res.json()).then(feeds => this.feeds = feeds), fetch(`/api/feeds`).then(res => res.json()).then(feeds => this.feeds = feeds),
fetch(`/api/unread`).then(res => res.json()).then(items => this.items = items) fetch(`/api/unread`).then(res => res.json()).then(items => this.items = items),
fetch(`/api/saved`).then(res => res.json()).then(items => this.savedItems = items)
]) ])
.then(() => { .then(() => {
this.setBusy(false); this.setBusy(false);

13
views/manifest.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "Gopherss",
"short_name": "Gopherss",
"description": "RSS reader built in Go",
"theme_color": "#282626",
"background_color": "#282626",
"display": "standalone",
"scope": "/",
"start_url": "/",
"icons": [
],
"splash_pages": null
}

View File

@ -21,20 +21,22 @@ class FeedItem extends HTMLElement {
width: 100% !important; width: 100% !important;
overflow: scroll !important; overflow: scroll !important;
overflow-x: auto !important; overflow-x: auto !important;
font-size: 18px;
} }
* { * {
max-width: 100% !important; max-width: 100% !important;
height: auto !important; height: auto !important;
float: none !important;
} }
table { table {
width: 100% !important; width: 100% !important;
} }
img { img {
margin: auto auto !important; margin: auto auto !important;
} }
p { p {
font-family: charter, Georgia, "Times New Roman", Times, serif; font-family: charter, Georgia, "Times New Roman", Times, serif;
font-size: 21px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
letter-spacing: -0.063px; letter-spacing: -0.063px;
@ -47,6 +49,9 @@ class FeedItem extends HTMLElement {
:host(.dark) a { :host(.dark) a {
color: #ccc; color: #ccc;
} }
a:hover, :host(.dark) a:hover {
color: #ff2e88;
}
</style> </style>
`; `;

View File

@ -1,3 +1,6 @@
body {
padding-bottom: 20px;
}
.hide { .hide {
display: none; display: none;

File diff suppressed because one or more lines are too long

1
views/sw.js Normal file
View File

@ -0,0 +1 @@
importScripts('/static/sw-toolbox.js');