454 lines
21 KiB
HTML
454 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Gopherss</title>
|
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22></text><text text-anchor=%22end%22 y=%221.9em%22 x=%221.9em%22 font-size=%2250%22>10</text></svg>">
|
|
|
|
<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/favicon.js"></script>
|
|
|
|
<script src="https://unpkg.com/vue@2.5.17/dist/vue.min.js"></script>
|
|
<script src="https://unpkg.com/dayjs@1.8.21/dayjs.min.js"></script>
|
|
<script src="https://unpkg.com/dayjs@1.9.5/plugin/relativeTime.js"></script>
|
|
<script>dayjs.extend(window.dayjs_plugin_relativeTime)</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 href="https://fonts.googleapis.com/css2?family=Roboto:wght@500&display=swap" rel="stylesheet">
|
|
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
</head>
|
|
<body class="hack">
|
|
<div id="app" class="container">
|
|
<header>
|
|
<button class="feed-toggle" v-on:click="toggleFeeds">
|
|
<svg width="24" height="24" viewbox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M24 16a3.5 3.5 0 110-7 3.5 3.5 0 010 7z" fill="#212121"/><path d="M24 27.5a3.5 3.5 0 110-7 3.5 3.5 0 010 7z" fill="#212121"/><path d="M20.5 35.5a3.5 3.5 0 107 0 3.5 3.5 0 00-7 0z" fill="#212121"/></svg>
|
|
</button>
|
|
<h1 class="title">
|
|
Gopherss
|
|
</h1>
|
|
</header>
|
|
|
|
<div>
|
|
<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" :style="{'fill': showRead ? '#ff2e88' : '' }" fill-rule="nonzero"/></svg>
|
|
</button>
|
|
<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="{ strong: items.length, alert: true, 'alert-success': selectedFeed == ''}" v-on:click="loadFeed('')">
|
|
All ({{unread}})
|
|
</div>
|
|
|
|
<div v-for="feed in sortedFeeds" :class="{strong: unreadCounts[feed.ID], 'alert': true, 'alert-success': selectedFeed == feed.ID }" :data-feed="feed.FeedURL" v-on:click="loadFeed(feed.ID)">
|
|
<img :src="feedIcon(feed)" style="height: 16px; width: 16px;" onerror="this.style.visibility = 'hidden'" /> {{feed.Title}} ({{unreadCounts[feed.ID] || '0'}})
|
|
<div style="float:right">
|
|
<button title="Delete Feed" v-on:click="deleteFeed(feed)" :disabled="isBusy">
|
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" width="16px" viewBox="0 0 473 473" enable-background="new 0 0 473 473"><g><path d="M317.7 214.4l5.6-86.4h21V38h-98.4V0H132.7v38H34.3v90h21l20 305h140.5a129.6 129.6 0 00223-89.4c0-68.6-53.7-124.8-121.1-129.2zM162.7 30h53.2v8h-53.2v-8zM64.3 68h250v30h-250V68zm39 335l-18-275h208l-5.8 88a129.6 129.6 0 00-93.2 187h-91zm206 40a99.5 99.5 0 010-198.9 99.5 99.5 0 010 198.9z"/><path d="M342.2 289.4l-33 33-32.9-33-21.2 21.2 33 33-33 33 21.2 21.1 33-33 33 33 21.2-21.2-33-33 33-32.9z"/></g><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/></svg>
|
|
</button>
|
|
</div>
|
|
</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 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">
|
|
</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 && !item.PendingRead, '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">{{ dayjs(item.Created).fromNow() }}</span>
|
|
<h3 class="item-title">{{item.Title}} <a :href="item.URL">↗</a></h3>
|
|
</div>
|
|
<div class="card item-content" :data-id="item.ID" v-if="item.ID == selectedItem">
|
|
<div class="card-content">
|
|
<div class="menu">
|
|
<button title="Show IFrame" v-on:click="showIframe(item)" :disabled="isBusy">
|
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"width="24" height="24" viewBox="0 0 426 426" enable-background="new 0 0 426.001 426.001"><g><path d="M406.8 54.2H19.2A19.2 19.2 0 000 73.4v279.2c0 10.6 8.6 19.2 19.2 19.2h387.6c10.6 0 19.2-8.6 19.2-19.2V73.4c0-10.6-8.6-19.2-19.2-19.2zM368.4 82a17.8 17.8 0 110 35.7 17.8 17.8 0 010-35.7zm-48 0a17.8 17.8 0 110 35.7 17.8 17.8 0 010-35.7zm-48 0a17.8 17.8 0 110 35.7 17.8 17.8 0 010-35.7zm115.2 251.5H38.4V141.6h349.2v191.8z"/></g><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/></svg>
|
|
</button>
|
|
<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' : '' }" fill-rule="nonzero"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<feed-item :item-id="item.ID" :class="{ dark: isDark }"></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: [],
|
|
savedItems: [],
|
|
selectedFeed: '',
|
|
selectedItem: undefined,
|
|
showAddModal: false,
|
|
newSiteURL: '',
|
|
opml: '',
|
|
isBusy: false,
|
|
isDark: false,
|
|
showRead: false,
|
|
},
|
|
computed: {
|
|
shownItems() {
|
|
if (this.selectedFeed === '') {
|
|
return this.items.filter(item => item.ID == this.selectedItem || !item.Read || item.Read === this.showRead);
|
|
} else if (this.selectedFeed === 'SAVED') {
|
|
return this.savedItems;
|
|
} else {
|
|
return this.items.filter(item => item.ID == this.selectedItem || item.FeedID === this.selectedFeed && (!item.Read || item.Read === this.showRead));
|
|
}
|
|
},
|
|
sortedFeeds() {
|
|
return this.feeds.sort((a, b) => a.Title.toLowerCase() > b.Title.toLowerCase());
|
|
},
|
|
unread() {
|
|
return this.items.filter(item => !item.Read && !item.PendingRead).length;
|
|
},
|
|
saved() {
|
|
return this.savedItems.length;
|
|
},
|
|
unreadCounts() {
|
|
return this.items.filter(item => !item.Read && !item.PendingRead).reduce((acc, item) => {
|
|
if (!acc[item.FeedID]) acc[item.FeedID] = 0;
|
|
acc[item.FeedID]++;
|
|
return acc;
|
|
}, {})
|
|
}
|
|
},
|
|
methods: {
|
|
setPageTitle() {
|
|
document.title = `Gopherss (${this.unread})`;
|
|
setFavicon(this.unread);
|
|
},
|
|
setBusy(isBusy) {
|
|
this.isBusy = isBusy;
|
|
document.body.style.cursor = isBusy ? "wait" : "";
|
|
this.setPageTitle();
|
|
},
|
|
toggleDarkMode() {
|
|
this.isDark = !this.isDark;
|
|
document.body.classList.toggle('dark');
|
|
document.body.classList.toggle('dark-grey');
|
|
},
|
|
loadFeed(feed) {
|
|
this.selectedItem = undefined;
|
|
this.items.forEach(item => item.Read = item.Read || item.PendingRead);
|
|
this.selectedFeed = feed;
|
|
window.location.hash = feed;
|
|
},
|
|
loadItem(item) {
|
|
this.setBusy(true);
|
|
if (this.selectedItem === item.ID) {
|
|
this.selectedItem = undefined;
|
|
} else {
|
|
this.selectedItem = item.ID;
|
|
document.getElementById(this.selectedItem).scrollIntoView();
|
|
item.PendingRead = true;
|
|
fetch(`/api/read/${item.ID}`, {method: "POST"})
|
|
}
|
|
this.setBusy(false);
|
|
},
|
|
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);
|
|
})
|
|
},
|
|
showIframe(item) {
|
|
document.querySelector(`feed-item[item-id='${item.ID}'`).showIframe();
|
|
},
|
|
deleteFeed(feed) {
|
|
if (confirm(`Are you sure you want to remove '${feed.Title}'`)) {
|
|
this.setBusy(true);
|
|
fetch(`/api/feed/${feed.ID}`, {method: "DELETE"})
|
|
.then(() => {
|
|
this.feeds = this.feeds.filter(f => f.ID != feed.ID);
|
|
this.items = this.items.filter(i => i.FeedID != feed.ID);
|
|
this.setBusy(false);
|
|
});
|
|
}
|
|
},
|
|
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() {
|
|
let ids = this.shownItems.filter(item => !item.Read).map(item => item.ID);
|
|
if (ids.length > 0 && confirm(`Are you sure you want to mark ${ids.length} items as read?`)) {
|
|
this.setBusy(true);
|
|
this.shownItems.filter(item => !item.Read).forEach(item => item.Read = true)
|
|
fetch(
|
|
`/api/read`,
|
|
{method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(ids)}
|
|
)
|
|
.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);
|
|
this.newSiteURL = '';
|
|
})
|
|
.catch(err => {
|
|
console.error(err);
|
|
this.setBusy(false);
|
|
this.newSiteURL = '';
|
|
});
|
|
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);
|
|
});
|
|
},
|
|
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);
|
|
});
|
|
}
|
|
},
|
|
feedIcon(feed) {
|
|
if (feed.ImageURL) {
|
|
return feed.ImageURL;
|
|
}
|
|
|
|
return "https://s2.googleusercontent.com/s2/favicons?domain_url=" + (feed.HomepageURL || feed.FeedURL);
|
|
},
|
|
toggleFeeds() {
|
|
document.querySelector('.feeds').classList.toggle('show-mobile');
|
|
}
|
|
},
|
|
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.map(item => {item.PendingRead = false; return item;})),
|
|
fetch(`/api/saved`).then(res => res.json()).then(items => this.savedItems = items)
|
|
])
|
|
.then(() => {
|
|
this.setBusy(false);
|
|
if (window.location.hash.length > 1) {
|
|
this.loadFeed(window.location.hash.substr(1));
|
|
}
|
|
})
|
|
.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()
|
|
}
|
|
};
|
|
|
|
// Fetch updates every 5 minutes
|
|
setInterval(() => {
|
|
fetch(`/api/unread`)
|
|
.then(res => res.json())
|
|
.then(items => {
|
|
if (!this.showRead) {
|
|
if (!items.some(i => i.ID == this.selectedItem)) {
|
|
items.unshift(this.items.find(i => i.ID == this.selectedItem));
|
|
}
|
|
|
|
this.items = items;
|
|
} else {
|
|
for (let item of items) {
|
|
if (!this.items.some(i => i.ID == item.ID)) {
|
|
this.items.unshift(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.setPageTitle();
|
|
})
|
|
}, 5 * 60 * 1000);
|
|
|
|
document.addEventListener('keydown', this._keyListener.bind(this));
|
|
|
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
this.toggleDarkMode();
|
|
}
|
|
},
|
|
beforeDestroy() {
|
|
document.removeEventListener('keydown', this._keyListener);
|
|
}
|
|
});
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>
|