Skip to content

Commit 81c10f4

Browse files
committed
App v2 initial commit. #318
1 parent a7bce25 commit 81c10f4

File tree

10 files changed

+557
-0
lines changed

10 files changed

+557
-0
lines changed

pyproject.toml

+2
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ app = [
7777
"humanize>=4,!=4.7.*",
7878
# for config
7979
"PyYAML",
80+
# for v2
81+
"WTForms",
8082
]
8183

8284
# UNSTABLE PLUGINS

src/reader/_app/__init__.py

+5
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def get_reader():
105105
def stream_template(template_name_or_list, **kwargs):
106106
template = current_app.jinja_env.get_template(template_name_or_list)
107107
stream = template.stream(**kwargs)
108+
# TODO: increase to at least 1-2k, like this we have 50% overhead
108109
stream.enable_buffering(50)
109110
return Response(stream_with_context(stream))
110111

@@ -836,6 +837,10 @@ def create_app(config):
836837

837838
app.register_blueprint(blueprint)
838839

840+
from . import v2
841+
842+
app.register_blueprint(v2.blueprint, url_prefix='/v2')
843+
839844
# NOTE: this is part of the app extension API
840845
app.reader_additional_enclosure_links = []
841846
app.reader_additional_links = []

src/reader/_app/templates/layout.html

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<a href="{{ url_for('reader.feeds') }}">feeds</a>
2222
<a href="{{ url_for('reader.tags') }}">tags</a>
2323
<a href="{{ url_for('reader.metadata') }}">metadata</a>
24+
<a href="{{ url_for('v2.entries') }}">v2</a>
2425

2526
{{ macros.text_input_button_get(
2627
'reader.preview', 'add feed', 'url', 'url',

src/reader/_app/v2/__init__.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from functools import partial
2+
3+
from flask import Blueprint
4+
from flask import request
5+
6+
from .. import get_reader
7+
from .. import stream_template
8+
from .forms import ENTRY_FILTER_PRESETS
9+
from .forms import EntryFilter
10+
11+
12+
blueprint = Blueprint(
13+
'v2', __name__, template_folder='templates', static_folder='static'
14+
)
15+
16+
17+
@blueprint.route('/')
18+
def entries():
19+
reader = get_reader()
20+
21+
# TODO: search
22+
# TODO: if search/tags is active, search/tags box should not be hidden
23+
# TODO: highlight active filter preset + uncollapse more
24+
# TODO: feed filter
25+
# TODO: pagination
26+
# TODO: read time
27+
# TODO: mark as ...
28+
# TODO: enclosures
29+
30+
form = EntryFilter(request.args)
31+
kwargs = dict(form.data)
32+
del kwargs['search']
33+
34+
get_entries = reader.get_entries
35+
36+
if form.validate():
37+
entries = get_entries(**kwargs, limit=64)
38+
else:
39+
entries = []
40+
41+
return stream_template(
42+
'v2/entries.html', presets=ENTRY_FILTER_PRESETS, form=form, entries=entries
43+
)

src/reader/_app/v2/forms.py

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import yaml
2+
from wtforms import Form
3+
from wtforms import RadioField
4+
from wtforms import SearchField
5+
from wtforms import StringField
6+
7+
from reader._types import tag_filter_argument
8+
9+
10+
class TagFilterField(StringField):
11+
12+
def process_formdata(self, valuelist):
13+
if not valuelist:
14+
return
15+
value = valuelist[0]
16+
if '[' not in value:
17+
value = f'[{value}]'
18+
try:
19+
data = yaml.safe_load(value)
20+
except yaml.error.MarkedYAMLError as e:
21+
raise ValueError(f"invalid YAML: {e.problem or e.context}") from e
22+
tag_filter_argument(data)
23+
self.data = data
24+
25+
def _value(self):
26+
if self.raw_data:
27+
return self.raw_data[0]
28+
if not self.data:
29+
return ''
30+
return yaml.safe_dump(self.data, default_flow_style=True).rstrip()
31+
32+
33+
class ToFormdataMixin:
34+
def to_formdata(self):
35+
rv = {}
36+
37+
for field in self:
38+
try:
39+
value = field._value()
40+
except AttributeError:
41+
values = [option._value() for option in field if option.checked]
42+
if values:
43+
value, *rest = values
44+
if rest:
45+
raise NotImplementedError(
46+
"multiple choices not supported"
47+
) from None
48+
else:
49+
value = field.default
50+
51+
if value and value != field.default:
52+
rv[field.name] = value
53+
54+
return rv
55+
56+
57+
def radio_field(*args, choices, **kwargs):
58+
"""Like RadioField, but choices is a list of (value, value_str),
59+
(value, value_str, label), or (value, value_str, label, render_kw) tuples.
60+
61+
"""
62+
return RadioField(
63+
*args,
64+
choices=[c[1] if len(c) == 2 else c[1:] for c in choices],
65+
coerce={c[1]: c[0] for c in choices}.get,
66+
**kwargs,
67+
)
68+
69+
70+
BOOL_CHOICES = [(True, 'yes'), (False, 'no'), (None, 'all')]
71+
TRISTATE_CHOICES = [('notfalse', 'maybe')] + BOOL_CHOICES
72+
ENTRY_SORT_CHOICES = ['recent', 'random']
73+
74+
75+
class EntryFilter(ToFormdataMixin, Form):
76+
search = SearchField("search", name='q')
77+
feed_tags = TagFilterField("tags", name='tags')
78+
read = radio_field("read", choices=BOOL_CHOICES, default='no')
79+
important = radio_field("important", choices=TRISTATE_CHOICES, default='maybe')
80+
has_enclosures = radio_field(
81+
"enclosures", name='enclosures', choices=BOOL_CHOICES, default='all'
82+
)
83+
sort = RadioField("sort", choices=ENTRY_SORT_CHOICES, default='recent')
84+
85+
86+
class SearchEntryFilter(EntryFilter):
87+
sort = RadioField(
88+
"sort", choices=ENTRY_SORT_CHOICES + ['relevant'], default='relevant'
89+
)
90+
91+
92+
ENTRY_FILTER_PRESETS = {
93+
'unread': {},
94+
'important': {'read': 'all', 'important': 'yes'},
95+
'podcast': {'enclosures': 'yes'},
96+
'random': {'sort': 'random'},
97+
}
98+
99+
100+
if __name__ == '__main__':
101+
from werkzeug.datastructures import MultiDict
102+
103+
args = MultiDict(dict(tags='1'))
104+
for FormCls in EntryFilter, SearchEntryFilter:
105+
form = FormCls(args)
106+
for field in form:
107+
print(field())
108+
print()
109+
print(form.data)
110+
print(form.to_formdata())
111+
print()
112+
113+
print(form.feed_tags.__dict__)
114+
import IPython
115+
116+
IPython.embed()

src/reader/_app/v2/static/style.css

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
2+
/* knock back heading sizes by 2 steps */
3+
.h1, h1 { font-size: calc(1.3rem + .6vw); }
4+
.h2, h2 { font-size: calc(1.275rem + .3vw); }
5+
/* TODO: rest of them */
6+
@media (min-width: 1200px) {
7+
.h1, h1 { font-size: 1.75rem; }
8+
.h2, h2 { font-size: 1.5rem; }
9+
/* TODO: rest of them */
10+
}
11+
12+
.nav.controls {
13+
--bs-nav-link-padding-x: 0;
14+
--bs-nav-link-padding-y: 0;
15+
gap: 1rem;
16+
}
17+
.nav.controls .nav-link.active {
18+
color: var(--bs-navbar-active-color);
19+
}

src/reader/_app/v2/static/theme.js

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*!
2+
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
3+
* Copyright 2011-2024 The Bootstrap Authors
4+
* Licensed under the Creative Commons Attribution 3.0 Unported License.
5+
*/
6+
7+
/*
8+
* Modified to use the Bootstrap Icons font, instead of SVG sprites.
9+
*/
10+
11+
(() => {
12+
'use strict'
13+
14+
const getStoredTheme = () => localStorage.getItem('theme')
15+
const setStoredTheme = theme => localStorage.setItem('theme', theme)
16+
17+
const getPreferredTheme = () => {
18+
const storedTheme = getStoredTheme()
19+
if (storedTheme) {
20+
return storedTheme
21+
}
22+
23+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
24+
}
25+
26+
const setTheme = theme => {
27+
if (theme === 'auto') {
28+
document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'))
29+
} else {
30+
document.documentElement.setAttribute('data-bs-theme', theme)
31+
}
32+
}
33+
34+
const getIconCls = btn => {
35+
return btn.querySelector('.bi').classList.values().find(x => x.startsWith('bi-'))
36+
}
37+
38+
setTheme(getPreferredTheme())
39+
40+
const showActiveTheme = (theme, focus = false) => {
41+
const themeSwitcher = document.querySelector('#theme')
42+
43+
if (!themeSwitcher) {
44+
return
45+
}
46+
47+
const themeSwitcherText = document.querySelector('#theme-text')
48+
const activeThemeIcon = document.querySelector('.theme-icon-active')
49+
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
50+
const clsOfActiveBtn = btnToActive.querySelector('.bi').classList.values().find(x => x.startsWith('bi-'))
51+
52+
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
53+
element.classList.remove('active')
54+
element.setAttribute('aria-pressed', 'false')
55+
})
56+
57+
btnToActive.classList.add('active')
58+
btnToActive.setAttribute('aria-pressed', 'true')
59+
activeThemeIcon.classList.remove(
60+
activeThemeIcon.classList.values().find(x => x.startsWith('bi-'))
61+
)
62+
activeThemeIcon.classList.add(clsOfActiveBtn)
63+
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`
64+
themeSwitcher.setAttribute('aria-label', themeSwitcherLabel)
65+
66+
if (focus) {
67+
themeSwitcher.focus()
68+
}
69+
}
70+
71+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
72+
const storedTheme = getStoredTheme()
73+
if (storedTheme !== 'light' && storedTheme !== 'dark') {
74+
setTheme(getPreferredTheme())
75+
}
76+
})
77+
78+
window.addEventListener('DOMContentLoaded', () => {
79+
showActiveTheme(getPreferredTheme())
80+
81+
document.querySelectorAll('[data-bs-theme-value]')
82+
.forEach(toggle => {
83+
toggle.addEventListener('click', () => {
84+
const theme = toggle.getAttribute('data-bs-theme-value')
85+
setStoredTheme(theme)
86+
setTheme(theme)
87+
showActiveTheme(theme, true)
88+
})
89+
})
90+
})
91+
})()

0 commit comments

Comments
 (0)