Updated to use Vue for frontend
This commit is contained in:
parent
3e8c5dbd6b
commit
25a389d60d
@ -28,8 +28,9 @@ func Refresh() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RefreshFeed(url string) error {
|
func RefreshFeed(url string) Feed {
|
||||||
fmt.Printf("Refreshing %s\n", url)
|
fmt.Printf("Refreshing %s\n", url)
|
||||||
|
var feed Feed
|
||||||
f, err := fp.ParseURL(url)
|
f, err := fp.ParseURL(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Failed to refresh %s\n", url)
|
fmt.Printf("Failed to refresh %s\n", url)
|
||||||
@ -39,7 +40,7 @@ func RefreshFeed(url string) error {
|
|||||||
imageURL = f.Image.URL
|
imageURL = f.Image.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
feed := Feed{
|
feed = Feed{
|
||||||
ID: strings.ReplaceAll(base64.StdEncoding.EncodeToString([]byte(url)), "/", ""),
|
ID: strings.ReplaceAll(base64.StdEncoding.EncodeToString([]byte(url)), "/", ""),
|
||||||
Title: f.Title,
|
Title: f.Title,
|
||||||
Description: f.Description,
|
Description: f.Description,
|
||||||
@ -73,5 +74,5 @@ func RefreshFeed(url string) error {
|
|||||||
fmt.Printf("Finished refreshing '%s'\n", feed.Title)
|
fmt.Printf("Finished refreshing '%s'\n", feed.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return feed
|
||||||
}
|
}
|
||||||
|
@ -32,7 +32,7 @@ func (fs *FeedStore) GetItem(id string) *Item {
|
|||||||
|
|
||||||
func (fs *FeedStore) GetFeeds() *[]Feed {
|
func (fs *FeedStore) GetFeeds() *[]Feed {
|
||||||
feeds := &[]Feed{}
|
feeds := &[]Feed{}
|
||||||
fs.getDB().Preload("Items").Order("title asc").Find(feeds)
|
fs.getDB().Order("title asc").Find(feeds)
|
||||||
return feeds
|
return feeds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,6 +17,14 @@ func (a *API) GetFeed(c *fiber.Ctx) error {
|
|||||||
return c.JSON(a.FeedStore.GetFeed(c.Params("id")))
|
return c.JSON(a.FeedStore.GetFeed(c.Params("id")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *API) PostFeed(c *fiber.Ctx) error {
|
||||||
|
url := ""
|
||||||
|
if err := c.BodyParser(&url); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.JSON(feeds.RefreshFeed(url))
|
||||||
|
}
|
||||||
|
|
||||||
func (a *API) GetItem(c *fiber.Ctx) error {
|
func (a *API) GetItem(c *fiber.Ctx) error {
|
||||||
return c.JSON(a.FeedStore.GetItem(c.Params("id")))
|
return c.JSON(a.FeedStore.GetItem(c.Params("id")))
|
||||||
}
|
}
|
||||||
@ -29,3 +37,22 @@ func (a *API) PostRead(c *fiber.Ctx) error {
|
|||||||
a.FeedStore.MarkAsRead(c.Params("id"))
|
a.FeedStore.MarkAsRead(c.Params("id"))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *API) PostReadAll(c *fiber.Ctx) error {
|
||||||
|
ids := &[]string{}
|
||||||
|
if err := c.BodyParser(ids); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range *ids {
|
||||||
|
a.FeedStore.MarkAsRead(id)
|
||||||
|
}
|
||||||
|
return c.JSON(a.FeedStore.GetUnread())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) RefreshAll(c *fiber.Ctx) error {
|
||||||
|
for _, feed := range *a.FeedStore.GetFeeds() {
|
||||||
|
feeds.RefreshFeed(feed.FeedURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(a.FeedStore.GetUnread())
|
||||||
|
}
|
||||||
|
@ -43,38 +43,32 @@ func Start(port string) error {
|
|||||||
Views: engine,
|
Views: engine,
|
||||||
})
|
})
|
||||||
|
|
||||||
app.Static("/", "./views/static")
|
app.Static("/", "./views")
|
||||||
app.Get("/", GetFeeds)
|
|
||||||
app.Post("/opml", PostOPML)
|
app.Post("/opml", PostOPML)
|
||||||
|
|
||||||
// API
|
// API
|
||||||
app.Get("/api/feeds", api.GetFeeds)
|
app.Get("/api/feeds", api.GetFeeds)
|
||||||
// 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/feed/:id/unread", api.GetFeedUnread)
|
|
||||||
app.Get("/api/item/:id", api.GetItem)
|
app.Get("/api/item/:id", api.GetItem)
|
||||||
app.Get("/api/unread", api.GetUnread)
|
app.Get("/api/unread", api.GetUnread)
|
||||||
app.Post("/api/read/:id", api.PostRead)
|
app.Post("/api/read/:id", api.PostRead)
|
||||||
|
app.Post("/api/read", api.PostReadAll)
|
||||||
|
app.Get("/api/refresh", api.RefreshAll)
|
||||||
|
|
||||||
return app.Listen(fmt.Sprintf(":%s", port))
|
return app.Listen(fmt.Sprintf(":%s", port))
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetFeeds(c *fiber.Ctx) error {
|
|
||||||
return c.Render("index", fiber.Map{
|
|
||||||
"Feeds": api.FeedStore.GetFeeds(),
|
|
||||||
"Unread": api.FeedStore.GetUnread(),
|
|
||||||
}, "layouts/main")
|
|
||||||
}
|
|
||||||
|
|
||||||
func PostOPML(c *fiber.Ctx) error {
|
func PostOPML(c *fiber.Ctx) error {
|
||||||
opml := &feeds.Opml{}
|
opml := &feeds.Opml{}
|
||||||
if err := c.BodyParser(opml); err != nil {
|
if err := c.BodyParser(opml); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
f := []feeds.Feed{}
|
||||||
for _, outline := range opml.Outlines {
|
for _, outline := range opml.Outlines {
|
||||||
feeds.RefreshFeed(outline.XmlUrl)
|
f = append(f, feeds.RefreshFeed(outline.XmlUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return c.JSON(f)
|
||||||
}
|
}
|
||||||
|
353
views/index.html
353
views/index.html
@ -1,108 +1,301 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Gopherss</title>
|
||||||
|
<script src="/static/feed-item.js" defer></script>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/vue@2.5.17/dist/vue.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.2/axios.min.js" integrity="sha256-T/f7Sju1ZfNNfBh7skWn0idlCBcI3RwdLSS4/I7NQKQ=" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/hack@0.8.1/dist/hack.css">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/hack@0.8.1/dist/dark.css">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/hack@0.8.1/dist/dark-grey.css">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="hack">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">
|
||||||
|
Gopherss
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<div class="menu">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
<button title="Mark all as read" v-on:click="markAllRead()" :disabled="isBusy">
|
||||||
|
<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="M18.8 3C20 3 21 4 21 5.3v13.4c0 1.3-1 2.3-2.2 2.3H5.3C4 21 3 20 3 18.7V5.3C3 4 4 3 5.3 3zm-3.6 5.7l-4.4 4.5-1.5-1.5a.7.7 0 00-1 1l2 2c.2.4.7.4 1 0l5-5a.8.8 0 00-1-1z" fill="#212121" fill-rule="nonzero"/></svg>
|
||||||
|
</button>
|
||||||
|
<button title="Refresh all feeds" v-on:click="refresh()" :disabled="isBusy">
|
||||||
|
<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="M20 7.7c.3 0 .6.1.8.4a7 7 0 01-5.5 11l-.3-.1H9.4l1.3 1.3a1 1 0 01-1.3 1.5l-.1-.1-3-3a1 1 0 010-1.3v-.1l3-3a1 1 0 011.3-.1h.1v.2c.4.3.4.8 0 1.2v.1L9.4 17H15a5 5 0 004.2-7.8 1 1 0 01.8-1.5zm-5.4-5.5h.1l3 3c.4.5.4 1 .1 1.4v.1l-3 3a1 1 0 01-1.6-1.3l.1-.1L14.6 7H9a5 5 0 00-4.3 7.5l.1.2.2.6a1 1 0 01-1.8.6A7 7 0 018.8 5h5.8l-1.3-1.3a1 1 0 010-1.3v-.1a1 1 0 011.3 0z" fill="#212121" fill-rule="nonzero"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="app">
|
|
||||||
<div class="feeds">
|
<div class="feeds">
|
||||||
<div class="alert alert-success">All <span class="{{if (gt (len .Unread) 0)}}strong{{end}}">({{len .Unread}})</span></div>
|
<div :class="{ alert: true, 'alert-success': selectedFeed == ''}" v-on:click="loadFeed('')">
|
||||||
{{range .Feeds}}
|
All
|
||||||
<div class="alert" data-feed="{{.HomepageURL}}">{{.Title}} <span class="{{if (gt .UnreadCount 0)}}strong{{end}}">({{.UnreadCount}})</span></div>
|
<span :class="{ strong: items.length }">({{items.length}})</span>
|
||||||
{{end}}
|
</div>
|
||||||
|
|
||||||
|
<div v-for="feed in feeds" :class="{'alert': true, 'alert-success': selectedFeed == feed.FeedURL }" :data-feed="feed.FeedURL" v-on:click="loadFeed(feed.ID)">
|
||||||
|
{{feed.Title}}
|
||||||
|
<span :class="{ strong: unreadCount(feed) }">({{unreadCount(feed)}})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu">
|
||||||
|
<button title="Add New Site" v-on:click="showAddModal = true" :disabled="isBusy">
|
||||||
|
<svg width="30" height="30" viewbox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17.8 3C19.5 3 21 4.5 21 6.3V12a6.5 6.5 0 00-8.2 1H8.6a.8.8 0 000 1.5h3.1A6.5 6.5 0 0012 21H6.3A3.3 3.3 0 013 17.7V9.4A2.2 2.2 0 103.2 5C3.7 3.9 5 3 6.2 3h11.6zm-2.5 6.5H8.7a.8.8 0 000 1.5h6.6a.8.8 0 000-1.5z" fill="#212121"/><path d="M3.8 6a1.3 1.3 0 110 2.5 1.3 1.3 0 010-2.5z" fill="#212121"/><path d="M23 17.5a5.5 5.5 0 10-11 0 5.5 5.5 0 0011 0zm-5 .5v2.5a.5.5 0 11-1 0V18h-2.5a.5.5 0 010-1H17v-2.5a.5.5 0 111 0V17h2.5a.5.5 0 010 1H18z" fill="#212121"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="showAddModal" class="card">
|
||||||
|
<header class="card-header">Add New Site</header>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="inner">
|
||||||
|
<fieldset class="form-group">
|
||||||
|
<label for="url">URL:</label>
|
||||||
|
<input id="url" type="text" placeholder="" class="form-control" v-model="newSiteURL">
|
||||||
|
<div class="help-block">Enter the direct URL to the feed</div>
|
||||||
|
</fieldset>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-primary btn-block" v-on:click="addSite(newSiteURL)" :disabled="isBusy">Add</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="form-group">
|
||||||
|
<label for="opml">Import OPML:</label>
|
||||||
|
<input id="opml" name="opml" type="file" class="form-control" v-on:change="loadOPML">
|
||||||
|
</fieldset>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-primary btn-block" v-on:click="importOPML()" :disabled="isBusy">Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="items">
|
<div class="items">
|
||||||
{{range .Unread}}
|
<div v-for="item in shownItems" :id="item.ID">
|
||||||
<div class="alert alert-info item-heading" data-feed="{{.FeedHomepageURL}}" data-id="{{.ID}}" id="{{.ID}}">
|
<div :class="{'alert': true, 'alert-info': item.Read == false, 'item-heading': true}" :data-feed="item.FeedHomepageURL" v-on:click="loadItem(item)">
|
||||||
<span class="feed-title">{{.FeedTitle}}</span>
|
<span class="feed-title">{{item.FeedTitle}}</span>
|
||||||
<span class="date" title="{{.Created}}">{{humanDate .Created}}</span>
|
<span class="date" :title="item.Created">{{item.Created}}</span>
|
||||||
<h3 class="item-title"><a href="{{.URL}}">{{.Title}}</a></h3>
|
<h3 class="item-title"><a :href="item.URL">{{item.Title}}</a></h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card item-content hide" data-id="{{.ID}}">
|
<div class="card item-content" :data-id="item.ID" v-show="item.ID == selectedItem">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="loading"></div>
|
<div class="loading"></div>
|
||||||
<feed-item item-id="{{.ID}}">
|
<feed-item :item-id="item.ID"></feed-item>
|
||||||
</feed-item>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="shownItems.length == 0">
|
||||||
|
<div class="no-items alert">
|
||||||
|
No Items to Show
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
const vm = new Vue({
|
const vm = new Vue({
|
||||||
el: '#app',
|
el: '#app',
|
||||||
data: {
|
data: {
|
||||||
feeds: [],
|
feeds: [],
|
||||||
items: [],
|
items: [],
|
||||||
|
selectedFeed: '',
|
||||||
|
selectedItem: undefined,
|
||||||
|
showAddModal: false,
|
||||||
|
newSiteURL: '',
|
||||||
|
opml: '',
|
||||||
|
isBusy: false,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
shownItems() {
|
||||||
|
if (this.selectedFeed === '') {
|
||||||
|
return this.items;
|
||||||
|
} else {
|
||||||
|
return this.items.filter(item => item.FeedID === this.selectedFeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
setBusy(isBusy) {
|
||||||
|
this.isBusy = isBusy;
|
||||||
|
document.body.style.cursor = isBusy ? "wait" : "";
|
||||||
},
|
},
|
||||||
async created() {
|
toggleDarkMode() {
|
||||||
this.feeds = (await fetch(`/api/feeds`)).json();
|
document.body.classList.toggle('dark');
|
||||||
this.items = (await fetch(`/api/unread`)).json();
|
document.body.classList.toggle('dark-grey');
|
||||||
}
|
},
|
||||||
});
|
unreadCount(feed) {
|
||||||
</script>
|
return this.items.filter(item => item.FeedID == feed.ID).length;
|
||||||
|
},
|
||||||
|
loadFeed(feed) {
|
||||||
<script>
|
this.selectedItem = undefined;
|
||||||
function reset() {
|
this.selectedFeed = feed;
|
||||||
[...document.querySelectorAll('.items [data-feed]')].forEach(item => {
|
},
|
||||||
item.style.display = 'none';
|
loadItem(item) {
|
||||||
});
|
if (this.selectedItem === item.ID) {
|
||||||
[...document.querySelectorAll('.feeds .alert')].forEach(el => {
|
this.selectedItem = undefined;
|
||||||
el.classList.remove('alert-success');
|
|
||||||
});
|
|
||||||
[...document.querySelectorAll('.items .item-content')].forEach(el => {
|
|
||||||
el.classList.add('hide');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function markAsRead(id) {
|
|
||||||
fetch(`/api/read/${id}`, {method: "POST"})
|
|
||||||
.then(res => {
|
|
||||||
if (res.status < 400) {
|
|
||||||
document.querySelector('.items .alert[data-id="'+id+'"]').classList.remove('alert-info');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => console.log)
|
|
||||||
}
|
|
||||||
|
|
||||||
[...document.querySelectorAll('.feeds .alert')].forEach(feed => {
|
|
||||||
feed.addEventListener('click', () => {
|
|
||||||
reset();
|
|
||||||
if (!feed.dataset.feed) {
|
|
||||||
[...document.querySelectorAll('.items [data-feed]')].forEach(item => {
|
|
||||||
item.style.display = 'block';
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
if (document.querySelector('.items [data-feed="'+feed.dataset.feed+'"]')) {
|
this.selectedItem = item.ID;
|
||||||
[...document.querySelectorAll('.items [data-feed="'+feed.dataset.feed+'"]')].forEach(item => {
|
let feedItem = document.querySelector('feed-item[item-id="'+item.ID+'"]');
|
||||||
item.style.display = 'block';
|
feedItem.load().then(() => feedItem.parentElement.querySelector('.loading').remove());
|
||||||
});
|
document.getElementById(this.selectedItem).scrollIntoView();
|
||||||
|
item.Read = true;
|
||||||
|
fetch(`/api/read/${item.ID}`, {method: "POST"})
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
nextItem() {
|
||||||
|
let currentItem = -1;
|
||||||
|
if (this.selectedItem != undefined) {
|
||||||
|
currentItem = this.shownItems.findIndex(item => item.ID == this.selectedItem);
|
||||||
}
|
}
|
||||||
feed.classList.add('alert-success');
|
this.loadItem(this.shownItems[currentItem+1]);
|
||||||
});
|
},
|
||||||
});
|
prevItem() {
|
||||||
|
let currentItem = this.shownItems.length;
|
||||||
[...document.querySelectorAll('.items [data-feed]')].forEach(item => {
|
if (this.selectedItem != undefined) {
|
||||||
item.addEventListener('click', () => {
|
currentItem = this.shownItems.findIndex(item => item.ID == this.selectedItem);
|
||||||
window.location.hash = item.dataset.id;
|
}
|
||||||
|
this.loadItem(this.shownItems[currentItem-1]);
|
||||||
[...document.querySelectorAll('.items .item-content:not([data-id="'+item.dataset.id+'"])')].forEach(el => {
|
},
|
||||||
el.classList.add('hide');
|
refresh() {
|
||||||
});
|
this.setBusy(true);
|
||||||
|
fetch(`/api/refresh`)
|
||||||
document.querySelector('.items .item-content[data-id="'+item.dataset.id+'"]').classList.toggle('hide');
|
.then(res => res.json())
|
||||||
document.querySelector('.items .item-content[data-id="'+item.dataset.id+'"] feed-item').load()
|
.then(items => {
|
||||||
.then(() => {
|
this.items = items;
|
||||||
document.querySelector('.items .item-content[data-id="'+item.dataset.id+'"] .loading').remove();
|
|
||||||
markAsRead(item.dataset.id);
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.setBusy(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
this.setBusy(false);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
markAllRead() {
|
||||||
|
this.setBusy(true);
|
||||||
|
let ids = this.shownItems.filter(item => !item.Read).map(item => item.ID);
|
||||||
|
fetch(
|
||||||
|
`/api/read`,
|
||||||
|
{method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(ids)}
|
||||||
|
)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(items => this.items = items)
|
||||||
|
.then(() => {
|
||||||
|
this.setBusy(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
this.setBusy(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
addSite(url) {
|
||||||
|
this.setBusy(true);
|
||||||
|
fetch(
|
||||||
|
`/api/feeds`,
|
||||||
|
{method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(url)}
|
||||||
|
)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(feed => {
|
||||||
|
if (!this.feeds.some(f => f.ID === feed.ID)) {
|
||||||
|
this.items.push(...feed.Items);
|
||||||
|
this.feeds.push(feed);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.setBusy(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
this.setBusy(false);
|
||||||
|
});
|
||||||
|
this.showAddModal = false;
|
||||||
|
},
|
||||||
|
loadOPML(event) {
|
||||||
|
const fReader = new FileReader();
|
||||||
|
fReader.onload = () => {
|
||||||
|
this.opml = atob(fReader.result.replace("data:text/xml;base64,", ""));
|
||||||
|
}
|
||||||
|
fReader.readAsDataURL(event.target.files[0]);
|
||||||
|
},
|
||||||
|
importOPML() {
|
||||||
|
this.setBusy(true);
|
||||||
|
fetch("/opml", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/xml",
|
||||||
|
},
|
||||||
|
body: this.opml
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(feeds => {
|
||||||
|
for(let feed of feeds) {
|
||||||
|
if (!this.feeds.some(f => f.ID === feed.ID)) {
|
||||||
|
this.items.push(...feed.Items);
|
||||||
|
this.feeds.push(feed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.setBusy(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
this.setBusy(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.setBusy(true);
|
||||||
|
Promise.all([
|
||||||
|
fetch(`/api/feeds`).then(res => res.json()).then(feeds => this.feeds = feeds),
|
||||||
|
fetch(`/api/unread`).then(res => res.json()).then(items => this.items = items)
|
||||||
|
])
|
||||||
|
.then(() => {
|
||||||
|
this.setBusy(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
this.setBusy(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this._keyListener = function(e) {
|
||||||
|
switch(e.key) {
|
||||||
|
case "j":
|
||||||
|
return this.nextItem();
|
||||||
|
case "k":
|
||||||
|
return this.prevItem();
|
||||||
|
case "m":
|
||||||
|
return this.markAllRead()
|
||||||
|
case "o":
|
||||||
|
// TODO: Open item in new tab
|
||||||
|
return
|
||||||
|
case "a":
|
||||||
|
this.showAddModal = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('url').focus();
|
||||||
|
}, 200)
|
||||||
|
return;
|
||||||
|
case "r":
|
||||||
|
return this.refresh()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', this._keyListener.bind(this));
|
||||||
</script>
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
document.removeEventListener('keydown', this._keyListener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
@ -1,103 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Gopherss</title>
|
|
||||||
<script src="/feed-item.js" defer></script>
|
|
||||||
|
|
||||||
<script src="https://unpkg.com/vue@2.5.17/dist/vue.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.2/axios.min.js" integrity="sha256-T/f7Sju1ZfNNfBh7skWn0idlCBcI3RwdLSS4/I7NQKQ=" crossorigin="anonymous"></script>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/hack@0.8.1/dist/hack.css">
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/hack@0.8.1/dist/dark.css">
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/hack@0.8.1/dist/dark-grey.css">
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.strong {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
margin: 0 auto;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: min(50%, 100em);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-heading .item-title {
|
|
||||||
|
|
||||||
margin-bottom: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-heading {
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-heading .date {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-heading:after {
|
|
||||||
content: '';
|
|
||||||
clear: both;
|
|
||||||
display: table;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
padding: 3px;
|
|
||||||
}
|
|
||||||
.item:nth-of-type(even) {
|
|
||||||
background: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-content {
|
|
||||||
margin-bottom: 1.75rem;
|
|
||||||
padding: 15px;
|
|
||||||
margin-top: -2px;
|
|
||||||
border-top: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feeds {
|
|
||||||
position: absolute;
|
|
||||||
left: max(-25vw, -500px);
|
|
||||||
width: min(25vw, 500px);
|
|
||||||
margin: 0 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feeds .alert {
|
|
||||||
margin-bottom: 1px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert:hover:not(.alert-success) {
|
|
||||||
border-color: #ff2e88;
|
|
||||||
color: #00bcd4;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 700px) {
|
|
||||||
.feeds{ display: none !important; }
|
|
||||||
.container {
|
|
||||||
max-width: 100em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="hack">
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="title">
|
|
||||||
Gopherss
|
|
||||||
</h1>
|
|
||||||
{{embed}}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -9,11 +9,17 @@ class FeedItem extends HTMLElement {
|
|||||||
const template = document.createElement('template');
|
const template = document.createElement('template');
|
||||||
template.innerHTML = `
|
template.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
table {
|
:root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow: scroll;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
* {
|
* {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
img {
|
img {
|
||||||
margin: auto auto;
|
margin: auto auto;
|
||||||
|
116
views/static/style.css
Normal file
116
views/static/style.css
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
|
||||||
|
.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.strong {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .strong {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
margin: 0 auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: min(50%, 100em);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-heading .item-title {
|
||||||
|
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-heading {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-heading .date {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-heading:after {
|
||||||
|
content: '';
|
||||||
|
clear: both;
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
.item:nth-of-type(even) {
|
||||||
|
background: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-content {
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: -2px;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.feeds {
|
||||||
|
position: absolute;
|
||||||
|
left: max(-25vw, -500px);
|
||||||
|
width: min(25vw, 500px);
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feeds .alert {
|
||||||
|
margin-bottom: 1px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert:hover:not(.alert-success):not(.no-items) {
|
||||||
|
border-color: #ff2e88;
|
||||||
|
color: #00bcd4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button:not(:disabled):hover svg path {
|
||||||
|
fill: #ff2e88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark button svg path {
|
||||||
|
fill: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
z-index: 999;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(600px, 80vw);
|
||||||
|
background: white;
|
||||||
|
box-shadow: 1px 2px 3px #333
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 700px) {
|
||||||
|
.feeds{ display: none !important; }
|
||||||
|
.container {
|
||||||
|
max-width: 100em;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user