Skip to content

Commit

Permalink
App v2 initial commit. #318
Browse files Browse the repository at this point in the history
  • Loading branch information
lemon24 committed Dec 31, 2024
1 parent a7bce25 commit 81c10f4
Show file tree
Hide file tree
Showing 10 changed files with 557 additions and 0 deletions.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ app = [
"humanize>=4,!=4.7.*",
# for config
"PyYAML",
# for v2
"WTForms",
]

# UNSTABLE PLUGINS
Expand Down
5 changes: 5 additions & 0 deletions src/reader/_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def get_reader():
def stream_template(template_name_or_list, **kwargs):
template = current_app.jinja_env.get_template(template_name_or_list)
stream = template.stream(**kwargs)
# TODO: increase to at least 1-2k, like this we have 50% overhead
stream.enable_buffering(50)
return Response(stream_with_context(stream))

Expand Down Expand Up @@ -836,6 +837,10 @@ def create_app(config):

app.register_blueprint(blueprint)

from . import v2

app.register_blueprint(v2.blueprint, url_prefix='/v2')

# NOTE: this is part of the app extension API
app.reader_additional_enclosure_links = []
app.reader_additional_links = []
Expand Down
1 change: 1 addition & 0 deletions src/reader/_app/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<a href="{{ url_for('reader.feeds') }}">feeds</a>
<a href="{{ url_for('reader.tags') }}">tags</a>
<a href="{{ url_for('reader.metadata') }}">metadata</a>
<a href="{{ url_for('v2.entries') }}">v2</a>

{{ macros.text_input_button_get(
'reader.preview', 'add feed', 'url', 'url',
Expand Down
43 changes: 43 additions & 0 deletions src/reader/_app/v2/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from functools import partial

from flask import Blueprint
from flask import request

from .. import get_reader
from .. import stream_template
from .forms import ENTRY_FILTER_PRESETS
from .forms import EntryFilter


blueprint = Blueprint(
'v2', __name__, template_folder='templates', static_folder='static'
)


@blueprint.route('/')
def entries():
reader = get_reader()

# TODO: search
# TODO: if search/tags is active, search/tags box should not be hidden
# TODO: highlight active filter preset + uncollapse more
# TODO: feed filter
# TODO: pagination
# TODO: read time
# TODO: mark as ...
# TODO: enclosures

form = EntryFilter(request.args)
kwargs = dict(form.data)
del kwargs['search']

get_entries = reader.get_entries

if form.validate():
entries = get_entries(**kwargs, limit=64)
else:
entries = []

return stream_template(
'v2/entries.html', presets=ENTRY_FILTER_PRESETS, form=form, entries=entries
)
116 changes: 116 additions & 0 deletions src/reader/_app/v2/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import yaml
from wtforms import Form
from wtforms import RadioField
from wtforms import SearchField
from wtforms import StringField

from reader._types import tag_filter_argument


class TagFilterField(StringField):

def process_formdata(self, valuelist):
if not valuelist:
return
value = valuelist[0]
if '[' not in value:
value = f'[{value}]'
try:
data = yaml.safe_load(value)
except yaml.error.MarkedYAMLError as e:
raise ValueError(f"invalid YAML: {e.problem or e.context}") from e
tag_filter_argument(data)
self.data = data

def _value(self):
if self.raw_data:
return self.raw_data[0]
if not self.data:
return ''
return yaml.safe_dump(self.data, default_flow_style=True).rstrip()


class ToFormdataMixin:
def to_formdata(self):
rv = {}

for field in self:
try:
value = field._value()
except AttributeError:
values = [option._value() for option in field if option.checked]
if values:
value, *rest = values
if rest:
raise NotImplementedError(
"multiple choices not supported"
) from None
else:
value = field.default

if value and value != field.default:
rv[field.name] = value

return rv


def radio_field(*args, choices, **kwargs):
"""Like RadioField, but choices is a list of (value, value_str),
(value, value_str, label), or (value, value_str, label, render_kw) tuples.
"""
return RadioField(
*args,
choices=[c[1] if len(c) == 2 else c[1:] for c in choices],
coerce={c[1]: c[0] for c in choices}.get,
**kwargs,
)


BOOL_CHOICES = [(True, 'yes'), (False, 'no'), (None, 'all')]
TRISTATE_CHOICES = [('notfalse', 'maybe')] + BOOL_CHOICES
ENTRY_SORT_CHOICES = ['recent', 'random']


class EntryFilter(ToFormdataMixin, Form):
search = SearchField("search", name='q')
feed_tags = TagFilterField("tags", name='tags')
read = radio_field("read", choices=BOOL_CHOICES, default='no')
important = radio_field("important", choices=TRISTATE_CHOICES, default='maybe')
has_enclosures = radio_field(
"enclosures", name='enclosures', choices=BOOL_CHOICES, default='all'
)
sort = RadioField("sort", choices=ENTRY_SORT_CHOICES, default='recent')


class SearchEntryFilter(EntryFilter):
sort = RadioField(
"sort", choices=ENTRY_SORT_CHOICES + ['relevant'], default='relevant'
)


ENTRY_FILTER_PRESETS = {
'unread': {},
'important': {'read': 'all', 'important': 'yes'},
'podcast': {'enclosures': 'yes'},
'random': {'sort': 'random'},
}


if __name__ == '__main__':
from werkzeug.datastructures import MultiDict

args = MultiDict(dict(tags='1'))
for FormCls in EntryFilter, SearchEntryFilter:
form = FormCls(args)
for field in form:
print(field())
print()
print(form.data)
print(form.to_formdata())
print()

print(form.feed_tags.__dict__)
import IPython

IPython.embed()
19 changes: 19 additions & 0 deletions src/reader/_app/v2/static/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@

/* knock back heading sizes by 2 steps */
.h1, h1 { font-size: calc(1.3rem + .6vw); }
.h2, h2 { font-size: calc(1.275rem + .3vw); }
/* TODO: rest of them */
@media (min-width: 1200px) {
.h1, h1 { font-size: 1.75rem; }
.h2, h2 { font-size: 1.5rem; }
/* TODO: rest of them */
}

.nav.controls {
--bs-nav-link-padding-x: 0;
--bs-nav-link-padding-y: 0;
gap: 1rem;
}
.nav.controls .nav-link.active {
color: var(--bs-navbar-active-color);
}
91 changes: 91 additions & 0 deletions src/reader/_app/v2/static/theme.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/

/*
* Modified to use the Bootstrap Icons font, instead of SVG sprites.
*/

(() => {
'use strict'

const getStoredTheme = () => localStorage.getItem('theme')
const setStoredTheme = theme => localStorage.setItem('theme', theme)

const getPreferredTheme = () => {
const storedTheme = getStoredTheme()
if (storedTheme) {
return storedTheme
}

return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}

const setTheme = theme => {
if (theme === 'auto') {
document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'))
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}

const getIconCls = btn => {
return btn.querySelector('.bi').classList.values().find(x => x.startsWith('bi-'))
}

setTheme(getPreferredTheme())

const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector('#theme')

if (!themeSwitcher) {
return
}

const themeSwitcherText = document.querySelector('#theme-text')
const activeThemeIcon = document.querySelector('.theme-icon-active')
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
const clsOfActiveBtn = btnToActive.querySelector('.bi').classList.values().find(x => x.startsWith('bi-'))

document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active')
element.setAttribute('aria-pressed', 'false')
})

btnToActive.classList.add('active')
btnToActive.setAttribute('aria-pressed', 'true')
activeThemeIcon.classList.remove(
activeThemeIcon.classList.values().find(x => x.startsWith('bi-'))
)
activeThemeIcon.classList.add(clsOfActiveBtn)
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`
themeSwitcher.setAttribute('aria-label', themeSwitcherLabel)

if (focus) {
themeSwitcher.focus()
}
}

window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') {
setTheme(getPreferredTheme())
}
})

window.addEventListener('DOMContentLoaded', () => {
showActiveTheme(getPreferredTheme())

document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()
Loading

0 comments on commit 81c10f4

Please sign in to comment.