From 81c10f4f25316042c2e0520a5a0afd6df4da6f53 Mon Sep 17 00:00:00 2001 From: lemon24 Date: Tue, 31 Dec 2024 18:19:59 +0200 Subject: [PATCH] App v2 initial commit. #318 --- pyproject.toml | 2 + src/reader/_app/__init__.py | 5 + src/reader/_app/templates/layout.html | 1 + src/reader/_app/v2/__init__.py | 43 ++++++ src/reader/_app/v2/forms.py | 116 +++++++++++++++++ src/reader/_app/v2/static/style.css | 19 +++ src/reader/_app/v2/static/theme.js | 91 +++++++++++++ src/reader/_app/v2/templates/v2/entries.html | 130 +++++++++++++++++++ src/reader/_app/v2/templates/v2/layout.html | 114 ++++++++++++++++ src/reader/_app/v2/templates/v2/macros.html | 36 +++++ 10 files changed, 557 insertions(+) create mode 100644 src/reader/_app/v2/__init__.py create mode 100644 src/reader/_app/v2/forms.py create mode 100644 src/reader/_app/v2/static/style.css create mode 100644 src/reader/_app/v2/static/theme.js create mode 100644 src/reader/_app/v2/templates/v2/entries.html create mode 100644 src/reader/_app/v2/templates/v2/layout.html create mode 100644 src/reader/_app/v2/templates/v2/macros.html diff --git a/pyproject.toml b/pyproject.toml index cf3eafb0..bed423d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,8 @@ app = [ "humanize>=4,!=4.7.*", # for config "PyYAML", + # for v2 + "WTForms", ] # UNSTABLE PLUGINS diff --git a/src/reader/_app/__init__.py b/src/reader/_app/__init__.py index 622b066c..710ad854 100644 --- a/src/reader/_app/__init__.py +++ b/src/reader/_app/__init__.py @@ -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)) @@ -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 = [] diff --git a/src/reader/_app/templates/layout.html b/src/reader/_app/templates/layout.html index f3a5b2db..1bc05068 100644 --- a/src/reader/_app/templates/layout.html +++ b/src/reader/_app/templates/layout.html @@ -21,6 +21,7 @@ feeds tags metadata +v2 {{ macros.text_input_button_get( 'reader.preview', 'add feed', 'url', 'url', diff --git a/src/reader/_app/v2/__init__.py b/src/reader/_app/v2/__init__.py new file mode 100644 index 00000000..7cf43216 --- /dev/null +++ b/src/reader/_app/v2/__init__.py @@ -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 + ) diff --git a/src/reader/_app/v2/forms.py b/src/reader/_app/v2/forms.py new file mode 100644 index 00000000..3f47a4fc --- /dev/null +++ b/src/reader/_app/v2/forms.py @@ -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() diff --git a/src/reader/_app/v2/static/style.css b/src/reader/_app/v2/static/style.css new file mode 100644 index 00000000..23a07f88 --- /dev/null +++ b/src/reader/_app/v2/static/style.css @@ -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); +} diff --git a/src/reader/_app/v2/static/theme.js b/src/reader/_app/v2/static/theme.js new file mode 100644 index 00000000..c08cb34b --- /dev/null +++ b/src/reader/_app/v2/static/theme.js @@ -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) + }) + }) + }) +})() diff --git a/src/reader/_app/v2/templates/v2/entries.html b/src/reader/_app/v2/templates/v2/entries.html new file mode 100644 index 00000000..e1fe5902 --- /dev/null +++ b/src/reader/_app/v2/templates/v2/entries.html @@ -0,0 +1,130 @@ +{% extends "v2/layout.html" %} + +{% import "v2/macros.html" as macros %} + + +{% block page_title %}Entries{% endblock %} + +{% block main_title %}Entries{% endblock %} + +{% block body %} + + + + + +
+ +
+ + +{% for entry in entries %} + + +
+ {# TODO: aria stuff #} + + + +
+ + + + +
+ + {% if entry.link %} + + + + {% endif %} + +
+ +

{{ entry.get_content(prefer_summary=True).value | striptags | truncate }}

+ + +{% else %} +

no entries found

+{% endfor %} + + + + + +{% endblock %} diff --git a/src/reader/_app/v2/templates/v2/layout.html b/src/reader/_app/v2/templates/v2/layout.html new file mode 100644 index 00000000..f641e6be --- /dev/null +++ b/src/reader/_app/v2/templates/v2/layout.html @@ -0,0 +1,114 @@ + + + + + + + {% block page_title %}{% endblock %} + + + + + + + + + + + + + + + + +
+ +

{% block main_title %}{% endblock %}

+ + {% block body %}{% endblock %} + +
+ + + + + + diff --git a/src/reader/_app/v2/templates/v2/macros.html b/src/reader/_app/v2/templates/v2/macros.html new file mode 100644 index 00000000..7934460f --- /dev/null +++ b/src/reader/_app/v2/templates/v2/macros.html @@ -0,0 +1,36 @@ + + +{%- macro input(field, icon=none) %} +
+
+ {% if icon -%} + + {%- endif %} + {{ field( + class="form-control" + (' is-invalid' if field.errors else ''), + placeholder=field.label.text) }} + {%- if field.errors %} +
+ {%- for error in field.errors %} + {{ error }} + {%- endfor %} +
+ {%- endif %} +
+
+{%- endmacro %} + + +{%- macro radio(field) %} +
+ {{ field.label.text }} +
+ {%- for option in field %} +
+ {{ option(class="form-check-input") }} + {{ option.label(class="form-check-label") }} +
+ {%- endfor %} +
+
+{%- endmacro %}