gopherss/views/index.html

302 lines
12 KiB
HTML

<!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 class="feeds">
<div :class="{ alert: true, 'alert-success': selectedFeed == ''}" v-on:click="loadFeed('')">
All
<span :class="{ strong: items.length }">({{items.length}})</span>
</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 class="items">
<div v-for="item in shownItems" :id="item.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">{{item.FeedTitle}}</span>
<span class="date" :title="item.Created">{{item.Created}}</span>
<h3 class="item-title"><a :href="item.URL">{{item.Title}}</a></h3>
</div>
<div class="card item-content" :data-id="item.ID" v-show="item.ID == selectedItem">
<div class="card-content">
<div class="loading"></div>
<feed-item :item-id="item.ID"></feed-item>
</div>
</div>
</div>
<div v-show="shownItems.length == 0">
<div class="no-items alert">
No Items to Show
</div>
</div>
</div>
</div>
<script>
const vm = new Vue({
el: '#app',
data: {
feeds: [],
items: [],
selectedFeed: '',
selectedItem: undefined,
showAddModal: false,
newSiteURL: '',
opml: '',
isBusy: false,
},
computed: {
shownItems() {
if (this.selectedFeed === '') {
return this.items;
} else {
return this.items.filter(item => item.FeedID === this.selectedFeed);
}
}
},
methods: {
setBusy(isBusy) {
this.isBusy = isBusy;
document.body.style.cursor = isBusy ? "wait" : "";
},
toggleDarkMode() {
document.body.classList.toggle('dark');
document.body.classList.toggle('dark-grey');
},
unreadCount(feed) {
return this.items.filter(item => item.FeedID == feed.ID).length;
},
loadFeed(feed) {
this.selectedItem = undefined;
this.selectedFeed = feed;
},
loadItem(item) {
if (this.selectedItem === item.ID) {
this.selectedItem = undefined;
} else {
this.selectedItem = item.ID;
let feedItem = document.querySelector('feed-item[item-id="'+item.ID+'"]');
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);
}
this.loadItem(this.shownItems[currentItem+1]);
},
prevItem() {
let currentItem = this.shownItems.length;
if (this.selectedItem != undefined) {
currentItem = this.shownItems.findIndex(item => item.ID == this.selectedItem);
}
this.loadItem(this.shownItems[currentItem-1]);
},
refresh() {
this.setBusy(true);
fetch(`/api/refresh`)
.then(res => res.json())
.then(items => {
this.items = items;
})
.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));
},
beforeDestroy() {
document.removeEventListener('keydown', this._keyListener);
}
});
</script>
</div>
</body>
</html>