move packages to src

This commit is contained in:
Nazar Kanaev 2021-02-26 13:39:30 +00:00
parent d825ce9bdf
commit 3fac9bb1bd
71 changed files with 0 additions and 0 deletions

45
src/assets/assets.go Normal file
View file

@ -0,0 +1,45 @@
package assets
import (
"embed"
"html/template"
"io"
"io/ioutil"
"io/fs"
"os"
)
type assetsfs struct {
embedded *embed.FS
templates map[string]*template.Template
}
var FS assetsfs
func (afs assetsfs) Open(name string) (fs.File, error) {
if afs.embedded != nil {
return afs.embedded.Open(name)
}
return os.DirFS("assets").Open(name)
}
func Render(path string, writer io.Writer, data interface{}) {
var tmpl *template.Template
tmpl, found := FS.templates[path]
if !found {
tmpl = template.Must(template.New(path).Delims("{%", "%}").Funcs(template.FuncMap{
"inline": func(svg string) template.HTML {
svgfile, _ := FS.Open("graphicarts/" + svg)
content, _ := ioutil.ReadAll(svgfile)
svgfile.Close()
return template.HTML(content)
},
}).ParseFS(FS, path))
FS.templates[path] = tmpl
}
tmpl.Execute(writer, data)
}
func init() {
FS.templates = make(map[string]*template.Template)
}

15
src/assets/assetsfs.go Normal file
View file

@ -0,0 +1,15 @@
// +build release
package assets
import "embed"
//go:embed *.html
//go:embed graphicarts
//go:embed javascripts
//go:embed stylesheets
var embedded embed.FS
func init() {
FS.embedded = &embedded
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-circle"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-anchor"><circle cx="12" cy="5" r="3"></circle><line x1="12" y1="22" x2="12" y2="8"></line><path d="M5 12H2a10 10 0 0 0 20 0h-3"></path></svg>

After

Width:  |  Height:  |  Size: 345 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-bar-chart-2"><line x1="4" y1="6" x2="14" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="8" y2="18"></line></svg>

After

Width:  |  Height:  |  Size: 353 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-book-open"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path></svg>

After

Width:  |  Height:  |  Size: 339 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check"><polyline points="20 6 9 17 4 12"></polyline></svg>

After

Width:  |  Height:  |  Size: 262 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>

After

Width:  |  Height:  |  Size: 269 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-left"><polyline points="15 18 9 12 15 6"></polyline></svg>

After

Width:  |  Height:  |  Size: 270 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg>

After

Width:  |  Height:  |  Size: 270 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-circle"><circle cx="12" cy="12" r="10"></circle></svg>

After

Width:  |  Height:  |  Size: 267 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-circle"><circle cx="12" cy="12" r="10"></circle></svg>

After

Width:  |  Height:  |  Size: 258 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>

After

Width:  |  Height:  |  Size: 370 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>

After

Width:  |  Height:  |  Size: 365 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>

After

Width:  |  Height:  |  Size: 388 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-folder"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-layers"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>

After

Width:  |  Height:  |  Size: 365 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-list"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>

After

Width:  |  Height:  |  Size: 482 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-out"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>

After

Width:  |  Height:  |  Size: 367 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-more-horizontal"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg>

After

Width:  |  Height:  |  Size: 343 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-more-vertical"><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>

After

Width:  |  Height:  |  Size: 341 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>

After

Width:  |  Height:  |  Size: 304 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-rotate-cw"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>

After

Width:  |  Height:  |  Size: 321 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-rss"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>

After

Width:  |  Height:  |  Size: 330 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-search"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>

After

Width:  |  Height:  |  Size: 308 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-settings"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>

After

Width:  |  Height:  |  Size: 1,011 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-sliders"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>

After

Width:  |  Height:  |  Size: 611 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>

After

Width:  |  Height:  |  Size: 348 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>

After

Width:  |  Height:  |  Size: 339 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash-2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>

After

Width:  |  Height:  |  Size: 448 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-upload"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>

After

Width:  |  Height:  |  Size: 365 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>

After

Width:  |  Height:  |  Size: 299 B

396
src/assets/index.html Normal file
View file

@ -0,0 +1,396 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon shortcut" href="./static/graphicarts/anchor.png">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
</head>
<body class="theme-light">
<div id="app" class="d-flex" :class="{'feed-selected': feedSelected !== null, 'item-selected': itemSelected !== null}" v-cloak>
<!-- feed list -->
<div id="col-feed-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: feedListWidth+'px'}">
<drag :width="feedListWidth" @resize="resizeFeedList"></drag>
<div class="p-2 toolbar d-flex align-items-center">
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
<div class="flex-grow-1"></div>
<button class="toolbar-item"
:class="{active: filterSelected == 'unread'}"
v-b-tooltip.hover.bottom="'Unread'"
@click="filterSelected = 'unread'">
<span class="icon">{% inline "circle-full.svg" %}</span>
</button>
<button class="toolbar-item"
:class="{active: filterSelected == 'starred'}"
v-b-tooltip.hover.bottom="'Starred'"
@click="filterSelected = 'starred'">
<span class="icon">{% inline "star-full.svg" %}</span>
</button>
<button class="toolbar-item"
:class="{active: filterSelected == ''}"
v-b-tooltip.hover.bottom="'All'"
@click="filterSelected = ''">
<span class="icon">{% inline "assorted.svg" %}</span>
</button>
<div class="flex-grow-1"></div>
<b-dropdown
right no-caret lazy variant="link"
class="settings-dropdown"
toggle-class="toolbar-item px-2"
ref="menuDropdown">
<template v-slot:button-content class="toolbar-item">
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<b-dropdown-item-button @click="showSettings('create')">
<span class="icon mr-1">{% inline "plus.svg" %}</span>
New Feed
</b-dropdown-item-button>
<b-dropdown-item-button @click.stop="showSettings('manage')">
<span class="icon mr-1">{% inline "list.svg" %}</span>
Manage Feeds
</b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item-button @click.stop="fetchAllFeeds()">
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
Refresh Feeds
</b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-header>Refresh</b-dropdown-header>
<b-dropdown-item-button @click.stop="refreshRate = min" v-for="min in [0, 60]">
<span class="icon mr-1" :class="{invisible: refreshRate != min}">{% inline "check.svg" %}</span>
<span v-if="min == 0">Manually</span>
<span v-if="min == 60">Every hour</span>
</b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-header>Sort by</b-dropdown-header>
<b-dropdown-item-button @click.stop="itemSortNewestFirst=true">
<span class="icon mr-1" :class="{invisible: !itemSortNewestFirst}">{% inline "check.svg" %}</span>
Newest First
</b-dropdown-item-button>
<b-dropdown-item-button @click="itemSortNewestFirst=false">
<span class="icon mr-1" :class="{invisible: itemSortNewestFirst}">{% inline "check.svg" %}</span>
Oldest First
</b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-header>Subscriptions</b-dropdown-header>
<b-dropdown-form id="opml-import-form" enctype="multipart/form-data">
<input type="file"
id="opml-import"
@change="importOPML"
name="opml"
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import">
<span class="icon mr-1">{% inline "download.svg" %}</span>
Import
</label>
</b-dropdown-form>
<b-dropdown-item href="./opml/export">
<span class="icon mr-1">{% inline "upload.svg" %}</span>
Export
</b-dropdown-item>
<b-dropdown-divider v-if="authenticated"></b-dropdown-divider>
<b-dropdown-item-button v-if="authenticated" @click="logout()">
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
Log out
</b-dropdown-item-button>
</b-dropdown>
</div>
<div class="p-2 overflow-auto border-top flex-grow-1">
<label class="selectgroup">
<input type="radio" name="feed" value="" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
<span class="icon mr-2">{% inline "layers.svg" %}</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">All Unread</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">All Starred</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">All Feeds</span>
<span class="counter text-right">{{ filteredTotalStats }}</span>
</div>
</label>
<div v-for="folder in foldersWithFeeds">
<label class="selectgroup mt-1"
:class="{'d-none': filterSelected
&& !filteredFolderStats[folder.id]
&& (!itemSelected || feedsById[itemSelectedDetails.feed_id].folder_id != folder.id)}">
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
<span class="icon mr-2"
:class="{expanded: folder.is_expanded}"
@click.prevent="toggleFolderExpanded(folder)">
{% inline "chevron-right.svg" %}
</span>
<span class="flex-fill text-left text-truncate">{{ folder.title }}</span>
<span class="counter text-right">{{ filteredFolderStats[folder.id] || '' }}</span>
</div>
</label>
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
<label class="selectgroup"
:class="{'d-none': filterSelected
&& !filteredFeedStats[feed.id]
&& (!itemSelected || itemSelectedDetails.feed_id != feed.id)}"
v-for="feed in folder.feeds">
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
<span class="icon mr-2" v-else><img v-lazy="'./api/feeds/'+feed.id+'/icon'" alt=""></span>
<span class="flex-fill text-left text-truncate">{{ feed.title }}</span>
<span class="counter text-right">{{ filteredFeedStats[feed.id] || '' }}</span>
</div>
</label>
</div>
</div>
</div>
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
<span class="icon loading mx-2"></span>
<span class="text-truncate cursor-default noselect">Refreshing ({{ loading.feeds }} left)</span>
</div>
</div>
<!-- item list -->
<div id="col-item-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: itemListWidth+'px'}">
<drag :width="itemListWidth" @resize="resizeItemList"></drag>
<div class="px-2 toolbar d-flex align-items-center">
<button class="toolbar-item mr-2 d-block d-md-none"
@click="feedSelected = null"
v-b-tooltip.hover.bottom="'Show Feeds'">
<span class="icon">{% inline "chevron-left.svg" %}</span>
</button>
<div class="input-icon flex-grow-1">
<span class="icon">{% inline "search.svg" %}</span>
<input class="d-block toolbar-search" type="" v-model="itemSearch">
</div>
<button class="toolbar-item ml-2"
@click="markItemsRead()"
v-if="filterSelected == 'unread'"
v-b-tooltip.hover.bottom="'Mark All Read'">
<span class="icon">{% inline "check.svg" %}</span>
</button>
</div>
<div class="p-2 overflow-auto border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
<label v-for="item in items" :key="item.id"
class="selectgroup">
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
<div class="selectgroup-label d-flex flex-column">
<div style="line-height: 1; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
<transition name="indicator">
<span class="icon icon-small mr-1" v-if="item.status=='unread'">{% inline "circle-full.svg" %}</span>
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
</transition>
<small class="flex-fill text-truncate mr-1">
{{ feedsById[item.feed_id].title }}
</small>
<small class="flex-shrink-0"><relative-time :val="item.date"/></small>
</div>
<div>{{ item.title || 'untitled' }}</div>
</div>
</label>
<button class="btn btn-link btn-block loading my-3" v-if="itemsPage.cur < itemsPage.num"></button>
</div>
</div>
<!-- item show -->
<div id="col-item" class="vh-100 d-flex flex-column w-100" style="min-width: 0;">
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelected">
<button class="toolbar-item"
@click="toggleItemStarred(itemSelectedDetails)"
v-b-tooltip.hover.bottom="'Mark Starred'">
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
</button>
<button class="toolbar-item"
:disabled="itemSelectedDetails.status=='starred'"
v-b-tooltip.hover.bottom="'Mark Unread'"
@click="toggleItemRead(itemSelectedDetails)">
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
</button>
<a class="toolbar-item" id="content-appearance" v-b-tooltip.hover.bottom="'Appearance'" tabindex="0">
<span class="icon">{% inline "sliders.svg" %}</span>
</a>
<button class="toolbar-item"
:class="{active: itemSelectedReadability}"
@click="getReadable(itemSelectedDetails)"
v-b-tooltip.hover.bottom="'Read Here'">
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
</button>
<a class="toolbar-item" :href="itemSelectedDetails.link" target="_blank" v-b-tooltip.hover.bottom="'Open Link'">
<span class="icon">{% inline "external-link.svg" %}</span>
</a>
<b-popover target="content-appearance" triggers="focus" placement="bottom">
<div class="p-1" style="width: 200px;">
<div class="d-flex">
<label class="themepicker">
<input type="radio" name="settingsTheme" value="light" v-model="theme.name">
<div class="themepicker-label appearance-option"></div>
</label>
<label class="themepicker">
<input type="radio" name="settingsTheme" value="sepia" v-model="theme.name">
<div class="themepicker-label appearance-option"></div>
</label>
<label class="themepicker">
<input type="radio" name="settingsTheme" value="night" v-model="theme.name">
<div class="themepicker-label appearance-option"></div>
</label>
</div>
<div class="mt-2">
<label class="selectgroup">
<input type="radio" name="font" value="" v-model="theme.font">
<div class="selectgroup-label appearance-option">
System Default
</div>
</label>
<label class="selectgroup" v-for="f in fonts" :key="f">
<input type="radio" name="font" :value="f" v-model="theme.font">
<div class="selectgroup-label appearance-option":style="{'font-family': f}">
{{ f }}
</div>
</label>
</div>
<div class="btn-group d-flex mt-2">
<button class="btn btn-outline appearance-option"
style="font-size: 0.8rem" @click="incrFont(-1)">A</button>
<button class="btn btn-outline appearance-option"
style="font-size: 1.2rem" @click="incrFont(1)">A</button>
</div>
</div>
</b-popover>
<div class="flex-grow-1"></div>
<button class="toolbar-item" @click="itemSelected=null" v-b-tooltip.hover.bottom="'Close Article'">
<span class="icon">{% inline "x.svg" %}</span>
</button>
</div>
<div v-if="itemSelected"
ref="content"
class="content px-4 pt-3 pb-5 border-top overflow-auto"
:style="{'font-family': theme.font, 'font-size': theme.size + 'rem'}">
<h1><b>{{ itemSelectedDetails.title }}</b></h1>
<div class="text-muted">
<div>{{ feedsById[itemSelectedDetails.feed_id].title }}</div>
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
</div>
<hr>
<div v-html="itemSelectedContent"></div>
</div>
</div>
<b-modal id="settings-modal" hide-header hide-footer lazy>
<button class="btn btn-link outline-none float-right p-2 mr-n2 mt-n2" style="line-height: 1" @click="$bvModal.hide('settings-modal')">
<span class="icon">{% inline "x.svg" %}</span>
</button>
<div v-if="settings=='create'">
<p class="cursor-default"><b>New Feed</b></p>
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
<label for="feed-url">URL</label>
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0">
<label for="feed-folder" class="mt-3 d-block">
Folder
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
</label>
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
<option value="">---</option>
<option :value="folder.id" v-for="folder in folders">{{ folder.title }}</option>
</select>
<div class="mt-4" v-if="feedNewChoice.length">
<p class="mb-2">
Multiple feeds found. Choose one below:
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">cancel</a>
</p>
<label class="selectgroup" v-for="choice in feedNewChoice">
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
<div class="selectgroup-label">
<div class="text-truncate">{{ choice.title }}</div>
<div class="text-truncate" :class="{light: choice.title}">{{ choice.url }}</div>
</div>
</label>
</div>
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">Add</button>
</form>
</div>
<div v-else-if="settings=='manage'">
<p class="cursor-default"><b>Manage Feeds</b></p>
<div v-for="folder in foldersWithFeeds" class="mt-4" :key="folder.id">
<div class="list-row d-flex align-items-center">
<div class="w-100 text-truncate" v-if="folder.id">
<span class="icon mr-2">{% inline "folder.svg" %}</span>
{{ folder.title }}
</div>
<div class="flex-shrink-0" v-if="folder.id">
<b-dropdown right no-caret lazy variant="link" class="settings-dropdown" toggle-class="text-decoration-none">
<template v-slot:button-content>
<span class="icon">{% inline "more-vertical.svg" %}</span>
</template>
<b-dropdown-header>{{ folder.title }}</b-dropdown-header>
<b-dropdown-item @click.prevent="renameFolder(folder)">Rename</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item class="dropdown-danger"
@click.prevent="deleteFolder(folder)">
Delete
</b-dropdown-item>
</b-dropdown>
</div>
</div>
<div v-for="feed in folder.feeds" class="list-row d-flex align-items-center" :key="feed.id">
<div class="w-100 text-truncate">
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
<span class="icon mr-2" v-else><img v-lazy="'./api/feeds/'+feed.id+'/icon'" alt=""></span>
{{ feed.title }}
</div>
<span class="icon flex-shrink-0 mx-2"
v-b-tooltip.hover.top="feed_errors[feed.id]"
v-if="feed_errors[feed.id]">
{% inline "alert-circle.svg" %}
</span>
<div class="flex-shrink-0">
<b-dropdown right no-caret lazy variant="link" class="settings-dropdown" toggle-class="text-decoration-none">
<template v-slot:button-content>
<span class="icon">{% inline "more-vertical.svg" %}</span>
</template>
<b-dropdown-header>{{ feed.title }}</b-dropdown-header>
<b-dropdown-item :href="feed.link" target="_blank" v-if="feed.link">Visit Website</b-dropdown-item>
<b-dropdown-divider v-if="feed.link"></b-dropdown-divider>
<b-dropdown-item @click.prevent="renameFeed(feed)">Rename</b-dropdown-item>
<b-dropdown-divider v-if="folders.length"></b-dropdown-divider>
<b-dropdown-header v-if="folders.length">Move to...</b-dropdown-header>
<b-dropdown-item @click="moveFeed(feed, null)" v-if="feed.folder_id">
---
</b-dropdown-item>
<b-dropdown-item-button
v-if="folder.id != feed.folder_id"
v-for="folder in folders"
@click="moveFeed(feed, folder)">
<span class="icon mr-1">{% inline "folder.svg" %}</span>
{{ folder.title }}
</b-dropdown-item-button>
<b-dropdown-item-button @click="moveFeedToNewFolder(feed)">
<span class="text-muted icon mr-1">{% inline "plus.svg" %}</span>
<span class="text-muted">New Folder</span>
</b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item class="dropdown-danger"
@click.prevent="deleteFeed(feed)">
Delete
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- polyfill -->
<script src="./static/javascripts/fetch.umd.js"></script>
<script src="./static/javascripts/url-polyfill.min.js"></script>
<!-- external -->
<script src="./static/javascripts/vue.min.js"></script>
<script src="./static/javascripts/vue-lazyload.js"></script>
<script src="./static/javascripts/popper.min.js"></script>
<script src="./static/javascripts/bootstrap-vue.min.js"></script>
<script src="./static/javascripts/Readability.min.js"></script>
<script src="./static/javascripts/purify.min.js"></script>
<!-- internal -->
<script src="./static/javascripts/api.js"></script>
<script src="./static/javascripts/app.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,110 @@
"use strict";
(function() {
var xfetch = function(resource, init) {
init = init || {}
if (['post', 'put', 'delete'].indexOf(init.method) !== -1) {
init['headers'] = init['headers'] || {}
init['headers']['x-requested-by'] = 'yarr'
}
return fetch(resource, init)
}
var api = function(method, endpoint, data) {
var headers = {'Content-Type': 'application/json'}
return xfetch(endpoint, {
method: method,
headers: headers,
body: JSON.stringify(data),
})
}
var json = function(res) {
return res.json()
}
var param = function(query) {
if (!query) return ''
return '?' + Object.keys(query).map(function(key) {
return encodeURIComponent(key) + '=' + encodeURIComponent(query[key])
}).join('&')
}
window.api = {
feeds: {
list: function() {
return api('get', './api/feeds').then(json)
},
create: function(data) {
return api('post', './api/feeds', data).then(json)
},
update: function(id, data) {
return api('put', './api/feeds/' + id, data)
},
delete: function(id) {
return api('delete', './api/feeds/' + id)
},
list_items: function(id) {
return api('get', './api/feeds/' + id + '/items').then(json)
},
refresh: function() {
return api('post', './api/feeds/refresh')
},
list_errors: function() {
return api('get', './api/feeds/errors').then(json)
},
},
folders: {
list: function() {
return api('get', './api/folders').then(json)
},
create: function(data) {
return api('post', './api/folders', data).then(json)
},
update: function(id, data) {
return api('put', './api/folders/' + id, data)
},
delete: function(id) {
return api('delete', './api/folders/' + id)
},
list_items: function(id) {
return api('get', './api/folders/' + id + '/items').then(json)
}
},
items: {
list: function(query) {
return api('get', './api/items' + param(query)).then(json)
},
update: function(id, data) {
return api('put', './api/items/' + id, data)
},
mark_read: function(query) {
return api('put', './api/items' + param(query))
},
},
settings: {
get: function() {
return api('get', './api/settings').then(json)
},
update: function(data) {
return api('put', './api/settings', data)
},
},
status: function() {
return api('get', './api/status').then(json)
},
upload_opml: function(form) {
return xfetch('./opml/import', {
method: 'post',
body: new FormData(form),
})
},
logout: function() {
return api('post', './logout')
},
crawl: function(url) {
return xfetch('./page?url=' + url).then(function(res) {
return res.text()
})
}
}
})()

View file

@ -0,0 +1,608 @@
'use strict';
var TITLE = document.title
function authenticated() {
return /auth=.+/g.test(document.cookie)
}
var FONTS = [
"Arial",
"Courier New",
"Georgia",
"Times New Roman",
"Verdana",
]
var debounce = function(callback, wait) {
var timeout
return function() {
var ctx = this, args = arguments
clearTimeout(timeout)
timeout = setTimeout(function() {
callback.apply(ctx, args)
}, wait)
}
}
var sanitize = function(content, base) {
// WILD: `item.link` may be a relative link (or some nonsense)
try { new URL(base) } catch(err) { base = null }
var sanitizer = new DOMPurify
sanitizer.addHook('afterSanitizeAttributes', function(node) {
// set all elements owning target to target=_blank
if ('target' in node)
node.setAttribute('target', '_blank')
// set non-HTML/MathML links to xlink:show=new
if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href')))
node.setAttribute('xlink:show', 'new')
// set absolute urls
if (base && node.attributes.href && node.attributes.href.value)
node.href = new URL(node.attributes.href.value, base).toString()
if (base && node.attributes.src && node.attributes.src.value)
node.src = new URL(node.attributes.src.value, base).toString()
})
return sanitizer.sanitize(content, {FORBID_TAGS: ['style'], FORBID_ATTR: ['style', 'class']})
}
Vue.use(VueLazyload)
Vue.directive('scroll', {
inserted: function(el, binding) {
el.addEventListener('scroll', debounce(function(event) {
binding.value(event, el)
}, 200))
},
})
Vue.component('drag', {
props: ['width'],
template: '<div class="drag"></div>',
mounted: function() {
var self = this
var startX = undefined
var initW = undefined
var onMouseMove = function(e) {
var offset = e.clientX - startX
var newWidth = initW + offset
self.$emit('resize', newWidth)
}
var onMouseUp = function(e) {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
this.$el.addEventListener('mousedown', function(e) {
startX = e.clientX
initW = self.width
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
})
},
})
function dateRepr(d) {
var sec = (new Date().getTime() - d.getTime()) / 1000
var neg = sec < 0
var out = ''
sec = Math.abs(sec)
if (sec < 2700) // less than 45 minutes
out = Math.round(sec / 60) + 'm'
else if (sec < 86400) // less than 24 hours
out = Math.round(sec / 3600) + 'h'
else if (sec < 604800) // less than a week
out = Math.round(sec / 86400) + 'd'
else
out = d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"})
if (neg) return '-' + out
return out
}
Vue.component('relative-time', {
props: ['val'],
data: function() {
var d = new Date(this.val)
return {
'date': d,
'formatted': dateRepr(d),
'interval': null,
}
},
template: '<time :datetime="val">{{ formatted }}</time>',
mounted: function() {
this.interval = setInterval(function() {
this.formatted = dateRepr(this.date)
}.bind(this), 600000) // every 10 minutes
},
destroyed: function() {
clearInterval(this.interval)
},
})
var vm = new Vue({
created: function() {
this.refreshFeeds()
this.refreshStats()
},
mounted: function() {
this.$root.$on('bv::modal::hidden', function(bvEvent, modalId) {
if (vm.settings == 'create') {
vm.feedNewChoice = []
vm.feedNewChoiceSelected = ''
}
})
},
data: function() {
return {
'filterSelected': undefined,
'folders': [],
'feeds': [],
'feedSelected': undefined,
'feedListWidth': undefined,
'feedNewChoice': [],
'feedNewChoiceSelected': '',
'items': [],
'itemsPage': {
'cur': 1,
'num': 1,
},
'itemSelected': null,
'itemSelectedDetails': {},
'itemSelectedReadability': '',
'itemSearch': '',
'itemSortNewestFirst': undefined,
'itemListWidth': undefined,
'filteredFeedStats': {},
'filteredFolderStats': {},
'filteredTotalStats': null,
'settings': 'create',
'loading': {
'feeds': 0,
'newfeed': false,
'items': false,
'readability': false,
},
'fonts': FONTS,
'feedStats': {},
'theme': {
'name': 'light',
'font': '',
'size': 1,
},
'refreshRate': undefined,
'authenticated': authenticated(),
'feed_errors': {},
}
},
computed: {
foldersWithFeeds: function() {
var feedsByFolders = this.feeds.reduce(function(folders, feed) {
if (!folders[feed.folder_id])
folders[feed.folder_id] = [feed]
else
folders[feed.folder_id].push(feed)
return folders
}, {})
var folders = this.folders.slice().map(function(folder) {
folder.feeds = feedsByFolders[folder.id]
return folder
})
folders.push({id: null, feeds: feedsByFolders[null]})
return folders
},
feedsById: function() {
return this.feeds.reduce(function(acc, feed) { acc[feed.id] = feed; return acc }, {})
},
itemsById: function() {
return this.items.reduce(function(acc, item) { acc[item.id] = item; return acc }, {})
},
itemSelectedContent: function() {
if (!this.itemSelected) return ''
if (this.itemSelectedReadability)
return this.itemSelectedReadability
var content = ''
if (this.itemSelectedDetails.content)
content = this.itemSelectedDetails.content
else if (this.itemSelectedDetails.description)
content = this.itemSelectedDetails.description
return sanitize(content, this.itemSelectedDetails.link)
},
},
watch: {
'theme': {
deep: true,
handler: function(theme) {
document.body.classList.value = 'theme-' + theme.name
api.settings.update({
theme_name: theme.name,
theme_font: theme.font,
theme_size: theme.size,
})
},
},
'feedStats': {
deep: true,
handler: debounce(function() {
var title = TITLE
var unreadCount = Object.values(this.feedStats).reduce(function(acc, stat) {
return acc + stat.unread
}, 0)
if (unreadCount) {
title += ' ('+unreadCount+')'
}
document.title = title
this.computeStats()
}, 500),
},
'filterSelected': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this))
this.itemSelected = null
this.computeStats()
},
'feedSelected': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this))
this.itemSelected = null
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
},
'itemSelected': function(newVal, oldVal) {
this.itemSelectedReadability = ''
if (newVal === null) {
this.itemSelectedDetails = null
return
}
if (this.$refs.content) this.$refs.content.scrollTop = 0
this.itemSelectedDetails = this.itemsById[newVal]
if (this.itemSelectedDetails.status == 'unread') {
this.itemSelectedDetails.status = 'read'
this.feedStats[this.itemSelectedDetails.feed_id].unread -= 1
api.items.update(this.itemSelectedDetails.id, {status: this.itemSelectedDetails.status})
}
},
'itemSearch': debounce(function(newVal) {
this.refreshItems()
}, 500),
'itemSortNewestFirst': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({sort_newest_first: newVal}).then(this.refreshItems.bind(this))
},
'feedListWidth': debounce(function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({feed_list_width: newVal})
}, 1000),
'itemListWidth': debounce(function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({item_list_width: newVal})
}, 1000),
'refreshRate': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({refresh_rate: newVal})
},
},
methods: {
refreshStats: function(loopMode) {
api.status().then(function(data) {
if (loopMode && !vm.itemSelected) vm.refreshItems()
vm.loading.feeds = data.running
if (data.running) {
setTimeout(vm.refreshStats.bind(vm, true), 500)
}
vm.feedStats = data.stats.reduce(function(acc, stat) {
acc[stat.feed_id] = stat
return acc
}, {})
})
},
getItemsQuery: function() {
var query = {}
if (this.feedSelected) {
var parts = this.feedSelected.split(':', 2)
var type = parts[0]
var guid = parts[1]
if (type == 'feed') {
query.feed_id = guid
} else if (type == 'folder') {
query.folder_id = guid
}
}
if (this.filterSelected) {
query.status = this.filterSelected
}
if (this.itemSearch) {
query.search = this.itemSearch
}
if (!this.itemSortNewestFirst) {
query.oldest_first = true
}
return query
},
refreshFeeds: function() {
return Promise
.all([api.folders.list(), api.feeds.list()])
.then(function(values) {
vm.folders = values[0]
vm.feeds = values[1]
})
},
refreshItems: function() {
if (this.feedSelected === null) {
vm.items = []
vm.itemsPage = {'cur': 1, 'num': 1}
return
}
var query = this.getItemsQuery()
this.loading.items = true
return api.items.list(query).then(function(data) {
vm.items = data.list
vm.itemsPage = data.page
vm.loading.items = false
})
},
loadMoreItems: function(event, el) {
if (this.itemsPage.cur >= this.itemsPage.num) return
if (this.loading.items) return
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < 50
if (closeToBottom) {
this.loading.moreitems = true
var query = this.getItemsQuery()
query.page = this.itemsPage.cur + 1
api.items.list(query).then(function(data) {
vm.items = vm.items.concat(data.list)
vm.itemsPage = data.page
vm.loading.items = false
})
}
},
markItemsRead: function() {
var query = this.getItemsQuery()
api.items.mark_read(query).then(function() {
vm.items = []
vm.itemsPage = {'cur': 1, 'num': 1}
vm.itemSelected = null
vm.refreshStats()
})
},
toggleFolderExpanded: function(folder) {
folder.is_expanded = !folder.is_expanded
api.folders.update(folder.id, {is_expanded: folder.is_expanded})
},
formatDate: function(datestr) {
var options = {
year: "numeric", month: "long", day: "numeric",
hour: '2-digit', minute: '2-digit',
}
return new Date(datestr).toLocaleDateString(undefined, options)
},
moveFeed: function(feed, folder) {
var folder_id = folder ? folder.id : null
api.feeds.update(feed.id, {folder_id: folder_id}).then(function() {
feed.folder_id = folder_id
vm.refreshStats()
})
},
moveFeedToNewFolder: function(feed) {
var title = prompt('Enter folder name:')
if (!title) return
api.folders.create({'title': title}).then(function(folder) {
api.feeds.update(feed.id, {folder_id: folder.id}).then(function() {
vm.refreshFeeds().then(function() {
vm.refreshStats()
})
})
})
},
createNewFeedFolder: function() {
var title = prompt('Enter folder name:')
if (!title) return
api.folders.create({'title': title}).then(function(result) {
vm.refreshFeeds().then(function() {
vm.$nextTick(function() {
if (vm.$refs.newFeedFolder) {
vm.$refs.newFeedFolder.value = result.id
}
})
})
})
},
renameFolder: function(folder) {
var newTitle = prompt('Enter new title', folder.title)
if (newTitle) {
api.folders.update(folder.id, {title: newTitle}).then(function() {
folder.title = newTitle
})
}
},
deleteFolder: function(folder) {
if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
api.folders.delete(folder.id).then(function() {
if (vm.feedSelected === 'folder:'+folder.id) {
vm.items = []
vm.feedSelected = ''
}
vm.refreshStats()
vm.refreshFeeds()
})
}
},
renameFeed: function(feed) {
var newTitle = prompt('Enter new title', feed.title)
if (newTitle) {
api.feeds.update(feed.id, {title: newTitle}).then(function() {
feed.title = newTitle
})
}
},
deleteFeed: function(feed) {
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
api.feeds.delete(feed.id).then(function() {
// unselect feed to prevent reading properties of null in template
var isSelected = !vm.feedSelected
|| (vm.feedSelected === 'feed:'+feed.id
|| (feed.folder_id && vm.feedSelected === 'folder:'+feed.folder_id));
if (isSelected) vm.feedSelected = null
vm.refreshStats()
vm.refreshFeeds()
})
}
},
createFeed: function(event) {
var form = event.target
var data = {
url: form.querySelector('input[name=url]').value,
folder_id: parseInt(form.querySelector('select[name=folder_id]').value) || null,
}
if (this.feedNewChoiceSelected) {
data.url = this.feedNewChoiceSelected
}
this.loading.newfeed = true
api.feeds.create(data).then(function(result) {
if (result.status === 'success') {
vm.refreshFeeds()
vm.refreshStats()
vm.$bvModal.hide('settings-modal')
} else if (result.status === 'multiple') {
vm.feedNewChoice = result.choice
vm.feedNewChoiceSelected = result.choice[0].url
} else {
alert('No feeds found at the given url.')
}
vm.loading.newfeed = false
})
},
toggleItemStarred: function(item) {
if (item.status == 'starred') {
item.status = 'read'
this.feedStats[item.feed_id].starred -= 1
} else if (item.status != 'starred') {
item.status = 'starred'
this.feedStats[item.feed_id].starred += 1
}
api.items.update(item.id, {status: item.status})
},
toggleItemRead: function(item) {
if (item.status == 'unread') {
item.status = 'read'
this.feedStats[item.feed_id].unread -= 1
} else if (item.status == 'read') {
item.status = 'unread'
this.feedStats[item.feed_id].unread += 1
}
api.items.update(item.id, {status: item.status})
},
importOPML: function(event) {
var input = event.target
var form = document.querySelector('#opml-import-form')
this.$refs.menuDropdown.hide()
api.upload_opml(form).then(function() {
input.value = ''
vm.refreshFeeds()
vm.refreshStats()
})
},
logout: function() {
api.logout().then(function() {
document.location.reload()
})
},
getReadable: function(item) {
if (this.itemSelectedReadability) {
this.itemSelectedReadability = null
return
}
if (item.link) {
this.loading.readability = true
api.crawl(item.link).then(function(body) {
vm.loading.readability = false
if (!body.length) return
var bodyClean = sanitize(body, item.link)
var doc = new DOMParser().parseFromString(bodyClean, 'text/html')
var parsed = new Readability(doc).parse()
if (parsed && parsed.content) {
vm.itemSelectedReadability = parsed.content
}
})
}
},
showSettings: function(settings) {
this.settings = settings
this.$bvModal.show('settings-modal')
if (settings === 'manage') {
api.feeds.list_errors().then(function(errors) {
vm.feed_errors = errors
})
}
},
resizeFeedList: function(width) {
this.feedListWidth = Math.min(Math.max(200, width), 700)
},
resizeItemList: function(width) {
this.itemListWidth = Math.min(Math.max(200, width), 700)
},
resetFeedChoice: function() {
this.feedNewChoice = []
this.feedNewChoiceSelected = ''
},
incrFont: function(x) {
this.theme.size = +(this.theme.size + (0.1 * x)).toFixed(1)
},
fetchAllFeeds: function() {
api.feeds.refresh().then(this.refreshStats.bind(this))
},
computeStats: function() {
var filter = this.filterSelected
if (!filter) {
this.filteredFeedStats = {}
this.filteredFolderStats = {}
this.filteredTotalStats = null
return
}
var statsFeeds = {}, statsFolders = {}, statsTotal = 0
for (var i = 0; i < this.feeds.length; i++) {
var feed = this.feeds[i]
if (!this.feedStats[feed.id]) continue
var n = vm.feedStats[feed.id][filter] || 0
if (!statsFolders[feed.folder_id]) statsFolders[feed.folder_id] = 0
statsFeeds[feed.id] = n
statsFolders[feed.folder_id] += n
statsTotal += n
}
this.filteredFeedStats = statsFeeds
this.filteredFolderStats = statsFolders
this.filteredTotalStats = statsTotal
},
}
})
api.settings.get().then(function(data) {
vm.feedSelected = data.feed
vm.filterSelected = data.filter
vm.itemSortNewestFirst = data.sort_newest_first
vm.feedListWidth = data.feed_list_width || 300
vm.itemListWidth = data.item_list_width || 300
vm.theme.name = data.theme_name
vm.theme.font = data.theme_font
vm.theme.size = data.theme_size
vm.refreshRate = data.refresh_rate
vm.refreshItems()
vm.$mount('#app')
})

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,609 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(factory((global.WHATWGFetch = {})));
}(this, (function (exports) { 'use strict';
var global = (typeof self !== 'undefined' && self) || (typeof global !== 'undefined' && global);
var support = {
searchParams: 'URLSearchParams' in global,
iterable: 'Symbol' in global && 'iterator' in Symbol,
blob:
'FileReader' in global &&
'Blob' in global &&
(function() {
try {
new Blob();
return true
} catch (e) {
return false
}
})(),
formData: 'FormData' in global,
arrayBuffer: 'ArrayBuffer' in global
};
function isDataView(obj) {
return obj && DataView.prototype.isPrototypeOf(obj)
}
if (support.arrayBuffer) {
var viewClasses = [
'[object Int8Array]',
'[object Uint8Array]',
'[object Uint8ClampedArray]',
'[object Int16Array]',
'[object Uint16Array]',
'[object Int32Array]',
'[object Uint32Array]',
'[object Float32Array]',
'[object Float64Array]'
];
var isArrayBufferView =
ArrayBuffer.isView ||
function(obj) {
return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1
};
}
function normalizeName(name) {
if (typeof name !== 'string') {
name = String(name);
}
if (/[^a-z0-9\-#$%&'*+.^_`|~!]/i.test(name) || name === '') {
throw new TypeError('Invalid character in header field name')
}
return name.toLowerCase()
}
function normalizeValue(value) {
if (typeof value !== 'string') {
value = String(value);
}
return value
}
// Build a destructive iterator for the value list
function iteratorFor(items) {
var iterator = {
next: function() {
var value = items.shift();
return {done: value === undefined, value: value}
}
};
if (support.iterable) {
iterator[Symbol.iterator] = function() {
return iterator
};
}
return iterator
}
function Headers(headers) {
this.map = {};
if (headers instanceof Headers) {
headers.forEach(function(value, name) {
this.append(name, value);
}, this);
} else if (Array.isArray(headers)) {
headers.forEach(function(header) {
this.append(header[0], header[1]);
}, this);
} else if (headers) {
Object.getOwnPropertyNames(headers).forEach(function(name) {
this.append(name, headers[name]);
}, this);
}
}
Headers.prototype.append = function(name, value) {
name = normalizeName(name);
value = normalizeValue(value);
var oldValue = this.map[name];
this.map[name] = oldValue ? oldValue + ', ' + value : value;
};
Headers.prototype['delete'] = function(name) {
delete this.map[normalizeName(name)];
};
Headers.prototype.get = function(name) {
name = normalizeName(name);
return this.has(name) ? this.map[name] : null
};
Headers.prototype.has = function(name) {
return this.map.hasOwnProperty(normalizeName(name))
};
Headers.prototype.set = function(name, value) {
this.map[normalizeName(name)] = normalizeValue(value);
};
Headers.prototype.forEach = function(callback, thisArg) {
for (var name in this.map) {
if (this.map.hasOwnProperty(name)) {
callback.call(thisArg, this.map[name], name, this);
}
}
};
Headers.prototype.keys = function() {
var items = [];
this.forEach(function(value, name) {
items.push(name);
});
return iteratorFor(items)
};
Headers.prototype.values = function() {
var items = [];
this.forEach(function(value) {
items.push(value);
});
return iteratorFor(items)
};
Headers.prototype.entries = function() {
var items = [];
this.forEach(function(value, name) {
items.push([name, value]);
});
return iteratorFor(items)
};
if (support.iterable) {
Headers.prototype[Symbol.iterator] = Headers.prototype.entries;
}
function consumed(body) {
if (body.bodyUsed) {
return Promise.reject(new TypeError('Already read'))
}
body.bodyUsed = true;
}
function fileReaderReady(reader) {
return new Promise(function(resolve, reject) {
reader.onload = function() {
resolve(reader.result);
};
reader.onerror = function() {
reject(reader.error);
};
})
}
function readBlobAsArrayBuffer(blob) {
var reader = new FileReader();
var promise = fileReaderReady(reader);
reader.readAsArrayBuffer(blob);
return promise
}
function readBlobAsText(blob) {
var reader = new FileReader();
var promise = fileReaderReady(reader);
reader.readAsText(blob);
return promise
}
function readArrayBufferAsText(buf) {
var view = new Uint8Array(buf);
var chars = new Array(view.length);
for (var i = 0; i < view.length; i++) {
chars[i] = String.fromCharCode(view[i]);
}
return chars.join('')
}
function bufferClone(buf) {
if (buf.slice) {
return buf.slice(0)
} else {
var view = new Uint8Array(buf.byteLength);
view.set(new Uint8Array(buf));
return view.buffer
}
}
function Body() {
this.bodyUsed = false;
this._initBody = function(body) {
/*
fetch-mock wraps the Response object in an ES6 Proxy to
provide useful test harness features such as flush. However, on
ES5 browsers without fetch or Proxy support pollyfills must be used;
the proxy-pollyfill is unable to proxy an attribute unless it exists
on the object before the Proxy is created. This change ensures
Response.bodyUsed exists on the instance, while maintaining the
semantic of setting Request.bodyUsed in the constructor before
_initBody is called.
*/
this.bodyUsed = this.bodyUsed;
this._bodyInit = body;
if (!body) {
this._bodyText = '';
} else if (typeof body === 'string') {
this._bodyText = body;
} else if (support.blob && Blob.prototype.isPrototypeOf(body)) {
this._bodyBlob = body;
} else if (support.formData && FormData.prototype.isPrototypeOf(body)) {
this._bodyFormData = body;
} else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
this._bodyText = body.toString();
} else if (support.arrayBuffer && support.blob && isDataView(body)) {
this._bodyArrayBuffer = bufferClone(body.buffer);
// IE 10-11 can't handle a DataView body.
this._bodyInit = new Blob([this._bodyArrayBuffer]);
} else if (support.arrayBuffer && (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body))) {
this._bodyArrayBuffer = bufferClone(body);
} else {
this._bodyText = body = Object.prototype.toString.call(body);
}
if (!this.headers.get('content-type')) {
if (typeof body === 'string') {
this.headers.set('content-type', 'text/plain;charset=UTF-8');
} else if (this._bodyBlob && this._bodyBlob.type) {
this.headers.set('content-type', this._bodyBlob.type);
} else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
}
}
};
if (support.blob) {
this.blob = function() {
var rejected = consumed(this);
if (rejected) {
return rejected
}
if (this._bodyBlob) {
return Promise.resolve(this._bodyBlob)
} else if (this._bodyArrayBuffer) {
return Promise.resolve(new Blob([this._bodyArrayBuffer]))
} else if (this._bodyFormData) {
throw new Error('could not read FormData body as blob')
} else {
return Promise.resolve(new Blob([this._bodyText]))
}
};
this.arrayBuffer = function() {
if (this._bodyArrayBuffer) {
var isConsumed = consumed(this);
if (isConsumed) {
return isConsumed
}
if (ArrayBuffer.isView(this._bodyArrayBuffer)) {
return Promise.resolve(
this._bodyArrayBuffer.buffer.slice(
this._bodyArrayBuffer.byteOffset,
this._bodyArrayBuffer.byteOffset + this._bodyArrayBuffer.byteLength
)
)
} else {
return Promise.resolve(this._bodyArrayBuffer)
}
} else {
return this.blob().then(readBlobAsArrayBuffer)
}
};
}
this.text = function() {
var rejected = consumed(this);
if (rejected) {
return rejected
}
if (this._bodyBlob) {
return readBlobAsText(this._bodyBlob)
} else if (this._bodyArrayBuffer) {
return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer))
} else if (this._bodyFormData) {
throw new Error('could not read FormData body as text')
} else {
return Promise.resolve(this._bodyText)
}
};
if (support.formData) {
this.formData = function() {
return this.text().then(decode)
};
}
this.json = function() {
return this.text().then(JSON.parse)
};
return this
}
// HTTP methods whose capitalization should be normalized
var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'];
function normalizeMethod(method) {
var upcased = method.toUpperCase();
return methods.indexOf(upcased) > -1 ? upcased : method
}
function Request(input, options) {
if (!(this instanceof Request)) {
throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.')
}
options = options || {};
var body = options.body;
if (input instanceof Request) {
if (input.bodyUsed) {
throw new TypeError('Already read')
}
this.url = input.url;
this.credentials = input.credentials;
if (!options.headers) {
this.headers = new Headers(input.headers);
}
this.method = input.method;
this.mode = input.mode;
this.signal = input.signal;
if (!body && input._bodyInit != null) {
body = input._bodyInit;
input.bodyUsed = true;
}
} else {
this.url = String(input);
}
this.credentials = options.credentials || this.credentials || 'same-origin';
if (options.headers || !this.headers) {
this.headers = new Headers(options.headers);
}
this.method = normalizeMethod(options.method || this.method || 'GET');
this.mode = options.mode || this.mode || null;
this.signal = options.signal || this.signal;
this.referrer = null;
if ((this.method === 'GET' || this.method === 'HEAD') && body) {
throw new TypeError('Body not allowed for GET or HEAD requests')
}
this._initBody(body);
if (this.method === 'GET' || this.method === 'HEAD') {
if (options.cache === 'no-store' || options.cache === 'no-cache') {
// Search for a '_' parameter in the query string
var reParamSearch = /([?&])_=[^&]*/;
if (reParamSearch.test(this.url)) {
// If it already exists then set the value with the current time
this.url = this.url.replace(reParamSearch, '$1_=' + new Date().getTime());
} else {
// Otherwise add a new '_' parameter to the end with the current time
var reQueryString = /\?/;
this.url += (reQueryString.test(this.url) ? '&' : '?') + '_=' + new Date().getTime();
}
}
}
}
Request.prototype.clone = function() {
return new Request(this, {body: this._bodyInit})
};
function decode(body) {
var form = new FormData();
body
.trim()
.split('&')
.forEach(function(bytes) {
if (bytes) {
var split = bytes.split('=');
var name = split.shift().replace(/\+/g, ' ');
var value = split.join('=').replace(/\+/g, ' ');
form.append(decodeURIComponent(name), decodeURIComponent(value));
}
});
return form
}
function parseHeaders(rawHeaders) {
var headers = new Headers();
// Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space
// https://tools.ietf.org/html/rfc7230#section-3.2
var preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' ');
preProcessedHeaders.split(/\r?\n/).forEach(function(line) {
var parts = line.split(':');
var key = parts.shift().trim();
if (key) {
var value = parts.join(':').trim();
headers.append(key, value);
}
});
return headers
}
Body.call(Request.prototype);
function Response(bodyInit, options) {
if (!(this instanceof Response)) {
throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.')
}
if (!options) {
options = {};
}
this.type = 'default';
this.status = options.status === undefined ? 200 : options.status;
this.ok = this.status >= 200 && this.status < 300;
this.statusText = 'statusText' in options ? options.statusText : '';
this.headers = new Headers(options.headers);
this.url = options.url || '';
this._initBody(bodyInit);
}
Body.call(Response.prototype);
Response.prototype.clone = function() {
return new Response(this._bodyInit, {
status: this.status,
statusText: this.statusText,
headers: new Headers(this.headers),
url: this.url
})
};
Response.error = function() {
var response = new Response(null, {status: 0, statusText: ''});
response.type = 'error';
return response
};
var redirectStatuses = [301, 302, 303, 307, 308];
Response.redirect = function(url, status) {
if (redirectStatuses.indexOf(status) === -1) {
throw new RangeError('Invalid status code')
}
return new Response(null, {status: status, headers: {location: url}})
};
exports.DOMException = global.DOMException;
try {
new exports.DOMException();
} catch (err) {
exports.DOMException = function(message, name) {
this.message = message;
this.name = name;
var error = Error(message);
this.stack = error.stack;
};
exports.DOMException.prototype = Object.create(Error.prototype);
exports.DOMException.prototype.constructor = exports.DOMException;
}
function fetch(input, init) {
return new Promise(function(resolve, reject) {
var request = new Request(input, init);
if (request.signal && request.signal.aborted) {
return reject(new exports.DOMException('Aborted', 'AbortError'))
}
var xhr = new XMLHttpRequest();
function abortXhr() {
xhr.abort();
}
xhr.onload = function() {
var options = {
status: xhr.status,
statusText: xhr.statusText,
headers: parseHeaders(xhr.getAllResponseHeaders() || '')
};
options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL');
var body = 'response' in xhr ? xhr.response : xhr.responseText;
setTimeout(function() {
resolve(new Response(body, options));
}, 0);
};
xhr.onerror = function() {
setTimeout(function() {
reject(new TypeError('Network request failed'));
}, 0);
};
xhr.ontimeout = function() {
setTimeout(function() {
reject(new TypeError('Network request failed'));
}, 0);
};
xhr.onabort = function() {
setTimeout(function() {
reject(new exports.DOMException('Aborted', 'AbortError'));
}, 0);
};
function fixUrl(url) {
try {
return url === '' && global.location.href ? global.location.href : url
} catch (e) {
return url
}
}
xhr.open(request.method, fixUrl(request.url), true);
if (request.credentials === 'include') {
xhr.withCredentials = true;
} else if (request.credentials === 'omit') {
xhr.withCredentials = false;
}
if ('responseType' in xhr) {
if (support.blob) {
xhr.responseType = 'blob';
} else if (
support.arrayBuffer &&
request.headers.get('Content-Type') &&
request.headers.get('Content-Type').indexOf('application/octet-stream') !== -1
) {
xhr.responseType = 'arraybuffer';
}
}
if (init && typeof init.headers === 'object' && !(init.headers instanceof Headers)) {
Object.getOwnPropertyNames(init.headers).forEach(function(name) {
xhr.setRequestHeader(name, normalizeValue(init.headers[name]));
});
} else {
request.headers.forEach(function(value, name) {
xhr.setRequestHeader(name, value);
});
}
if (request.signal) {
request.signal.addEventListener('abort', abortXhr);
xhr.onreadystatechange = function() {
// DONE (success or failure)
if (xhr.readyState === 4) {
request.signal.removeEventListener('abort', abortXhr);
}
};
}
xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit);
})
}
fetch.polyfill = true;
if (!global.fetch) {
global.fetch = fetch;
global.Headers = Headers;
global.Request = Request;
global.Response = Response;
}
exports.Headers = Headers;
exports.Request = Request;
exports.Response = Response;
exports.fetch = fetch;
Object.defineProperty(exports, '__esModule', { value: true });
})));

6
src/assets/javascripts/popper.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
src/assets/javascripts/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

6
src/assets/javascripts/vue.min.js vendored Normal file

File diff suppressed because one or more lines are too long

38
src/assets/login.html Normal file
View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon shortcut" href="./static/graphicarts/anchor.png">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style>
form {
max-width: 300px;
margin: 0 auto;
padding: 1rem;
}
form img {
width: 4rem;
height: 4rem;
display: block;
margin: 3rem auto;
}
</style>
</head>
<body>
<form action="" method="post">
<img src="./static/graphicarts/anchor.svg" alt="">
<div class="form-group">
<label for="username">Username</label>
<input name="username" class="form-control" id="username" autocomplete="off">
</div>
<div class="form-group">
<label for="password">Password</label>
<input name="password" class="form-control" id="password" type="password">
</div>
<button class="btn btn-block btn-default" type="submit">Login</button>
</form>
</body>
</html>

View file

@ -0,0 +1,605 @@
[v-cloak] {
display: none !important;
}
body {
font-size: 15px !important;
}
/* bootstrap customizations */
.btn-link {
color: inherit;
}
.form-control {
box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.07);
}
select.form-control {
-webkit-appearance: none;
}
select.form-control:not([multiple]):not([size]) {
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
background: #fff url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center/.6rem .6rem;
padding-right: 1.2rem;
cursor: pointer;
}
.form-control:focus, .btn:focus {
box-shadow: none !important;
}
.dropdown-header {
cursor: default;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-toggle-no-caret:after {
display: none;
}
#settings-modal {
color: #212529 !important;
}
.settings-dropdown .dropdown-toggle {
padding-left: 0;
padding-right: 0;
}
.dropdown-menu {
padding: 0;
box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.07);
overflow: hidden;
}
.dropdown-item, .dropdown-header {
padding: .375rem 1rem;
}
.dropdown-divider {
margin: 0;
}
.settings-dropdown .dropdown-menu:focus {
outline: none;
}
.settings-dropdown .dropdown-item:focus {
outline: none;
}
.settings-dropdown.large .dropdown-item {
padding: .5rem 1rem;
}
.dropdown-danger .dropdown-item {
color: #dc3545!important;
}
.modal-backdrop {
background-color: rgba(0, 0, 0, 0.7);
}
.modal.fade .modal-dialog {
transition: none !important;
transform: none !important;
}
.b-dropdown-form:focus {
outline: none;
}
.popover:focus {
outline: none;
}
.b-tooltip {
opacity: 1;
font-size: .7rem;
}
.b-tooltip:focus {
outline: none;
}
/* custom elements */
.icon {
height: 1rem;
width: 1rem;
display: inline-block;
line-height: 1;
}
.icon > svg , .icon > img {
width: 1rem;
height: 1rem;
vertical-align: top;
}
.icon-small {
width: .6rem;
height: .6rem;
display: inline-block;
}
.icon-small > svg , .icon-small > img {
width: .6rem;
height: .6rem;
}
.feed-icon {
width: 16px;
height: 16px;
min-width: 16px;
margin-left: -18px !important;
}
.counter {
padding-left: .5rem;
opacity: .6;
}
.light {
opacity: .6;
}
.selectgroup {
position: relative;
display: block;
margin: 0;
}
.selectgroup *, .noselect, .dropdown-menu * {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.selectgroup input {
opacity: 0;
position: absolute;
z-index: -1;
top: 0; left: 0;
}
.selectgroup + .selectgroup {
margin-top: .25rem;
}
.selectgroup-label {
padding: .375rem .5rem;
border-radius: 4px;
overflow-wrap: break-word;
}
.selectgroup-label:hover {
cursor: pointer;
}
.list-row:hover,
.toolbar-item:hover,
.toolbar-search:hover,
.selectgroup-label:hover,
.dropdown-item:hover {
background-color: #f0f0f0;
}
.expanded {
transform: rotate(90deg);
}
@keyframes stroke {
from { stroke-dashoffset: 120; }
to { stroke-dashoffset: 0; }
}
@keyframes rotate {
from { transform: rotate(0); }
to { transform: rotate(360deg); }
}
.loading {
color: transparent!important;
min-height: .8rem;
pointer-events: none;
position: relative;
}
.loading::after {
animation: rotate .5s infinite linear;
border: .1rem solid #6c757d;
border-radius: 50%;
border-right-color: transparent;
border-top-color: transparent;
content: "";
display: block;
height: 1rem;
width: 1rem;
left: 50%;
margin-left: -.5rem;
margin-top: -.5rem;
position: absolute;
top: 50%;
z-index: 1;
}
.icon-loading > svg {
animation: stroke 2s infinite normal;
stroke-dasharray: 60;
}
.btn-default {
border: 1px solid #ced4da;
border-radius: .25rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
background: linear-gradient(#fff, #f5f7f9);
}
.btn-default:active {
background: #f5f7f9;
box-shadow: none;
}
.btn-outline {
border: 1px solid #ced4da;
border-radius: .25rem;
}
.btn-outline:hover {
background-color: #f8f9fa;
}
.list-row {
padding-left: .5rem;
padding-right: .5rem;
margin-left: -.5rem;
margin-right: -.5rem;
border-radius: 3px;
user-select: none;
cursor: default;
}
.toolbar {
min-height: 2rem !important;
max-height: 2rem !important;
}
.toolbar-item {
display: inline-block;
background-color: transparent;
text-decoration: none;
user-select: none;
border: 1px solid transparent;
padding: .25rem .5rem;
font-size: 1rem;
border-radius: .25rem;
line-height: 1;
color: inherit;
text-align: center;
vertical-align: middle;
border-radius: 3px;
cursor: pointer;
color: inherit;
}
.toolbar-item:focus {
outline: none;
}
.toolbar-item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.cursor-pointer {
cursor: pointer;
}
.cursor-default {
cursor: default;
}
.drag {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
z-index: 900;
cursor: col-resize;
}
.input-icon {
position: relative;
}
.input-icon .icon {
position: absolute;
display: flex;
align-items: center;
height: 100%;
width: 2rem;
justify-content: center;
pointer-events: none;
}
.input-icon input {
padding-left: 2rem !important;
width: 100%;
}
.toolbar-search {
border: none;
border-radius: 3px;
padding: .25rem .5rem;
line-height: 1;
}
.toolbar-search:hover, .toolbar-search:focus {
background-color: #f3f3f3;
outline: none;
}
.themepicker {
position: relative;
background: none;
border: none;
width: 100%;
margin-bottom: 0;
}
.themepicker input {
opacity: 0;
position: absolute;
z-index: -1;
top: 0; left: 0;
}
.themepicker-label {
height: 1.75rem;
border-radius: 4px;
cursor: pointer;
}
.themepicker input[value=light] + .themepicker-label {
box-shadow: inset 0 0 0px 1px #dee2e6;
background: #fff;
}
.themepicker + .themepicker {
margin-left: .5rem;
}
.themepicker-label:hover {
box-shadow: inset 0 0 0 2px rgb(1, 123, 254, .6) !important;
}
.themepicker input:checked + .themepicker-label {
box-shadow: inset 0 0 0px 2px #017bfe !important;
}
.appearance-option {
height: 2rem;
padding-top: 0 !important;
padding-bottom: 0 !important;
line-height: 2rem;
}
#opml-import-form input[type="file"]::-webkit-file-upload-button {
position: absolute;
top: -999px;
left: -999px;
}
/* content */
.content {
overflow-wrap: break-word;
line-height: 1.5;
}
.content img, .content video {
max-width: 100%;
height: auto;
}
.content pre {
overflow-x: auto;
color: inherit;
border: 1px solid #dee2e6;
border-radius: 3px;
margin-left: -0.5rem;
margin-right: -0.5rem;
padding: 0.5rem;
}
.content a {
color: inherit;
text-decoration: underline;
}
.content blockquote {
border-left: 3px solid #22262a;
padding-left: 1rem;
}
.content h1 {
font-size: 1.8rem;
}
.content h2 {
font-size: 1.5rem;
}
.content h3 {
font-size: 1.17rem;
}
.content h4,
.content h5,
.content h6 {
font-size: 1rem;
}
/* theme: light */
a,
.btn-link:hover,
.toolbar-item.active {
color: #0080d4;
}
.dropdown-item.active,
.dropdown-item:active,
.selectgroup input:checked + .selectgroup-label {
color: #fff;
background-color: #0080d4 !important;
}
.btn-default:focus,
.form-control:focus {
border-color: #0080d4;
}
/* theme: sepia */
.themepicker input[value=sepia] + .themepicker-label,
.theme-sepia,
.theme-sepia .toolbar-search {
background-color: #f4f0e5;
}
.theme-sepia .content hr,
.theme-sepia .content pre,
.theme-sepia .border-right,
.theme-sepia .border-top {
border-color: #e0d6ba !important;
}
.theme-sepia .selectgroup-label:not(.appearance-option):hover,
.theme-sepia .toolbar-item:hover,
.theme-sepia .toolbar-search:hover,
.theme-sepia .toolbar-search:focus {
background-color: #e0d6ba;
}
/* theme: night */
.themepicker input[value=night] + .themepicker-label,
.theme-night,
.theme-night .toolbar-search {
color: #d1d1d1;
background-color: #0e0e0e;
}
.theme-night .content hr,
.theme-night .content pre,
.theme-night .border-right,
.theme-night .border-top {
border-color: #1a1a1a !important;
}
.theme-night .selectgroup-label:not(.appearance-option):hover,
.theme-night .toolbar-item:hover,
.theme-night .toolbar-search:hover,
.theme-night .toolbar-search:focus {
background-color: #1a1a1a;
}
/* animation */
.indicator-enter-active, .indicator-leave-active {
transition: all .3s;
}
.indicator-enter, .indicator-leave-to {
width: 0;
opacity: 0;
margin: 0 !important;
}
/* responsive layout
tablet:
none selected: show feed list & item list
feed selected: show feed list & item list
item selected: show item
mobile:
none selected: show feed list
feed selected: show item list
item selected: show item
*/
@media (min-width: 768px) and (max-width: 991.98px) {
#app #col-feed-list {
width: 35% !important;
}
#app #col-item-list {
width: 65% !important;
border-right-width: 0 !important;
}
#app #col-item {
display: none !important;
}
#app.item-selected #col-feed-list {
display: none !important;
}
#app.item-selected #col-item-list {
display: none !important;
}
#app.item-selected #col-item {
display: flex !important;
}
}
@media (max-width: 767.98px) {
#app #col-feed-list {
width: 100% !important;
border-right-width: 0 !important;
}
#app #col-item-list {
width: 100% !important;
display: none !important;
border-right-width: 0 !important;
}
#app #col-item {
width: 100% !important;
display: none !important;
}
#app.feed-selected #col-feed-list {
display: none !important;
}
#app.feed-selected #col-item-list {
display: flex !important;
}
#app.item-selected #col-feed-list {
display: none !important;
}
#app.item-selected #col-item-list {
display: none !important;
}
#app.item-selected #col-item {
display: flex !important;
}
}
/* styles for both mobile & tablet layout */
@media (max-width: 991.98px) {
.drag {
cursor: default;
}
.toolbar {
min-height: 3rem !important;
max-height: 3rem !important;
}
.toolbar-item,
.toolbar-search {
padding: .5rem;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long