From 232f1499bb9a2cc85a63abcc1c22776ef77781fb Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 9 Mar 2025 13:48:00 +0100 Subject: build: introduce NixOS package & service --- .gitignore | 1 - Makefile | 2 - README.md | 16 +++ default.nix | 27 ++++ deps/pywikiapi.nix | 16 +++ find_archived_proposals_without_template.py | 38 ------ index.html | 20 --- osm_proposals/archived_without_template.py | 34 +++++ osm_proposals/proposals.py | 187 +++++++++++++++++++++++++++ proposals.py | 191 ---------------------------- pyproject.toml | 10 ++ script.js | 110 ---------------- service.nix | 52 ++++++++ static/index.html | 20 +++ static/script.js | 110 ++++++++++++++++ static/style.css | 39 ++++++ style.css | 39 ------ 17 files changed, 511 insertions(+), 401 deletions(-) delete mode 100644 .gitignore delete mode 100644 Makefile create mode 100644 README.md create mode 100644 default.nix create mode 100644 deps/pywikiapi.nix delete mode 100755 find_archived_proposals_without_template.py delete mode 100644 index.html create mode 100755 osm_proposals/archived_without_template.py create mode 100755 osm_proposals/proposals.py delete mode 100755 proposals.py create mode 100644 pyproject.toml delete mode 100644 script.js create mode 100644 service.nix create mode 100644 static/index.html create mode 100644 static/script.js create mode 100644 static/style.css delete mode 100644 style.css diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 204a590..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -proposals.json diff --git a/Makefile b/Makefile deleted file mode 100644 index 31de770..0000000 --- a/Makefile +++ /dev/null @@ -1,2 +0,0 @@ -all: - ./proposals.py > proposals.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb4eab7 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# osm-proposals + +An autogenerated web page listing [OpenStreetMap proposals]. + +Hosted under https://osm-proposals.push-f.com/. + +# Local development + + python -m venv venv + . venv/bin/activate + pip install -r requirements.txt + pip install -e . + osm-proposals static/proposals.json + python -m http.server --directory static + +[OpenStreetMap proposals]: https://wiki.openstreetmap.org/wiki/Proposal diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..d923d2e --- /dev/null +++ b/default.nix @@ -0,0 +1,27 @@ +{ pkgs ? import {} }: +with pkgs.python310Packages; + +let + pywikiapi = import ./deps/pywikiapi.nix { inherit pkgs; }; +in +buildPythonApplication rec { + pname = "osm-proposals"; + version = "git"; + src = ./.; + pyproject = true; + + build-system = [ + setuptools + ]; + + dependencies = [ + requests + mwparserfromhell + pywikiapi + ]; + + postInstall = '' + mkdir -p $out/share/osm-proposals + cp -r ${./static}/. $out/share/osm-proposals/ + ''; +} diff --git a/deps/pywikiapi.nix b/deps/pywikiapi.nix new file mode 100644 index 0000000..856f0f0 --- /dev/null +++ b/deps/pywikiapi.nix @@ -0,0 +1,16 @@ +{ pkgs }: +with pkgs.python310Packages; + +buildPythonPackage rec { + pname = "pywikiapi"; + version = "4.3.0"; + src = pkgs.fetchPypi { + inherit pname; + inherit version; + hash = "sha256-gynkx98y8Vx/N3i6xoQ7x9MgTW6qACoYcDwy71FDYIE="; + }; + + dependencies = [ + requests + ]; +} diff --git a/find_archived_proposals_without_template.py b/find_archived_proposals_without_template.py deleted file mode 100755 index 6424745..0000000 --- a/find_archived_proposals_without_template.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -""" -Queries wiki.openstreetmap.org for archived proposal pages without the {{Proposal page}} template. - -Sometimes when archiving a page people accidentally also replace the -{{Proposal page}} template, which however means that proposal.py -won't find the page anymore. This script lists such pages so that the -template can be manually restored. -""" -import argparse - -import pywikiapi -import mwparserfromhell - -OSMWIKI_ENDPOINT = 'https://wiki.openstreetmap.org/w/api.php' - - -def run(): - arg_parser = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter - ) - arg_parser.parse_args() - - osmwiki = pywikiapi.Site(OSMWIKI_ENDPOINT) - - for page in osmwiki.query_pages( - generator='categorymembers', - gcmtitle='Category:Archived proposals', - gcmlimit='max', - prop='templates', - tltemplates='Template:Proposal page', - ): - if not 'templates' in page: - print(page['title']) - - -if __name__ == "__main__": - run() diff --git a/index.html b/index.html deleted file mode 100644 index 8d7bd7a..0000000 --- a/index.html +++ /dev/null @@ -1,20 +0,0 @@ - - - OSM proposals - -

OSM proposals

-

This page provides an overview of proposals for OpenStreetMap. The page is updated every hour.

- -

- - - - - - - - - -
StatusDateName
Authors
- - diff --git a/osm_proposals/archived_without_template.py b/osm_proposals/archived_without_template.py new file mode 100755 index 0000000..ea026d3 --- /dev/null +++ b/osm_proposals/archived_without_template.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +""" +Queries wiki.openstreetmap.org for archived proposal pages without the {{Proposal page}} template. + +Sometimes when archiving a page people accidentally also replace the +{{Proposal page}} template, which however means that proposal.py +won't find the page anymore. This script lists such pages so that the +template can be manually restored. +""" +import argparse + +import pywikiapi +import mwparserfromhell + +OSMWIKI_ENDPOINT = 'https://wiki.openstreetmap.org/w/api.php' + + +def run(): + arg_parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + arg_parser.parse_args() + + osmwiki = pywikiapi.Site(OSMWIKI_ENDPOINT) + + for page in osmwiki.query_pages( + generator='categorymembers', + gcmtitle='Category:Archived proposals', + gcmlimit='max', + prop='templates', + tltemplates='Template:Proposal page', + ): + if not 'templates' in page: + print(page['title']) diff --git a/osm_proposals/proposals.py b/osm_proposals/proposals.py new file mode 100755 index 0000000..c111699 --- /dev/null +++ b/osm_proposals/proposals.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Queries wiki.openstreetmap.org for proposals and writes a JSON list of them to the given file.""" +import argparse +import html +import json +import sys +from collections.abc import Container + +import pywikiapi +import mwparserfromhell +import requests + +OSMWIKI_ENDPOINT = 'https://wiki.openstreetmap.org/w/api.php' + +# https://wiki.openstreetmap.org/w/index.php?title=Template:Proposal_page&action=edit + + +def run(): + arg_parser = argparse.ArgumentParser(description=__doc__) + arg_parser.add_argument("out_file") + args = arg_parser.parse_args() + + res = requests.get( + OSMWIKI_ENDPOINT, + params=dict( + action='expandtemplates', + prop='wikitext', + format='json', + text='{{#invoke:languages/table|json}}', + ), + ) + langs: dict[str, dict] = json.loads(res.json()['expandtemplates']['wikitext']) + + osmwiki = pywikiapi.Site(OSMWIKI_ENDPOINT) + + proposals = [] + for page in osmwiki.query_pages( + generator='embeddedin', + geititle='Template:Proposal page', + geilimit='max', + prop='revisions', + rvprop='content', + rvslots='main', + ): + proposal = parse_proposal(page, langs) + if proposal: + proposals.append(proposal) + + proposals.sort(key=sort_key, reverse=True) + + with open(args.out_file, 'w') as f: + json.dump([{k: v for k, v in p.items() if v is not None} for p in proposals], f) + + +def get_template_val(tpl, name): + param = tpl.get(name, None) + if param: + value = param.value.strip() + if value: + # turn empty strings into None + return value + + +def eprint(*args): + print(*args, file=sys.stderr) + + +def is_stub(doc): + if any( + doc.ifilter_templates(matches=lambda t: t.name.matches('Archived proposal')) + ): + return False + + if not any(doc.ifilter_headings()): + # detect proposals without headings as stubs + return True + + if not any( + n + for n in doc.nodes + # any text + if isinstance(n, mwparserfromhell.nodes.text.Text) + # other than newlines + and n.strip() + # and "Please comment on the [[{{TALKPAGENAME}}|discussion page]]." + and n.strip() not in ('Please comment on the', '.') + ): + # detect proposals without text as stubs + return True + + return False + + +def parse_proposal(page: dict, langs: Container[str]) -> dict | None: + page_title = page['title'] + text = page['revisions'][0]['slots']['main']['content'] + doc = mwparserfromhell.parse(text) + proposal_page_templates = doc.filter_templates( + matches=lambda t: t.name.matches('Proposal page') + or t.name.matches('Proposal Page') + ) + + if not proposal_page_templates: + eprint('{{Proposal Page}} not found in', page_title) + return None + + for comment in doc.ifilter_comments(): + # remove comments like + doc.remove(comment) + + tpl = proposal_page_templates[0] + + status = get_template_val(tpl, 'status') + if status: + status = status.lower() + + if is_stub(doc): + if status in ('approved', 'rejected'): + eprint(f'WARNING {status} proposal is a stub', page['title']) + else: + eprint('skipping stub', page['title']) + return None + + name = get_template_val(tpl, 'name') + if name: + name = html.unescape(name) + + draft_start = get_template_val(tpl, 'draftStartDate') + if draft_start in ('*', '-'): + draft_start = None + + rfc_start = get_template_val(tpl, 'rfcStartDate') + if rfc_start in ('*', '-'): + rfc_start = None + + vote_start = get_template_val(tpl, 'voteStartDate') + if vote_start in ('*', '-'): + vote_start = None + + definition = get_template_val(tpl, 'definition') + users = get_template_val(tpl, 'users') or get_template_val(tpl, 'user') + + parts = page_title.split(':', maxsplit=1) + parts[0] = parts[0].lower() + + lang = None + if parts[0] in langs: + lang = parts[0] + + return dict( + page_title=page_title, + lang=lang, + name=name, + status=status, + definition=definition, + draft_start=draft_start, + rfc_start=rfc_start, + vote_start=vote_start, + authors=users, + ) + + +STATUSES = { + 'voting': 0, + 'post-vote': 1, + 'proposed': 2, + 'draft': 3, + 'approved': 4, + 'inactive': 5, + 'rejected': 6, + 'abandoned': 7, + 'canceled': 8, + 'obsoleted': 9, +} + + +def sort_key(proposal): + status = proposal['status'] + + if status in ('voting', 'approved', 'rejected'): + date = proposal['vote_start'] or '' + elif status == 'proposed': + date = proposal['rfc_start'] or '' + else: + date = proposal['draft_start'] or '' + + return (-STATUSES.get(proposal['status'], 10), date) diff --git a/proposals.py b/proposals.py deleted file mode 100755 index e16412f..0000000 --- a/proposals.py +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env python3 -"""Queries wiki.openstreetmap.org for proposals and outputs a JSON list of them to stdout.""" -import argparse -import html -import json -import sys -from collections.abc import Container - -import pywikiapi -import mwparserfromhell -import requests - -OSMWIKI_ENDPOINT = 'https://wiki.openstreetmap.org/w/api.php' - -# https://wiki.openstreetmap.org/w/index.php?title=Template:Proposal_page&action=edit - - -def run(): - arg_parser = argparse.ArgumentParser(description=__doc__) - arg_parser.parse_args() - - res = requests.get( - OSMWIKI_ENDPOINT, - params=dict( - action='expandtemplates', - prop='wikitext', - format='json', - text='{{#invoke:languages/table|json}}', - ), - ) - langs: dict[str, dict] = json.loads(res.json()['expandtemplates']['wikitext']) - - osmwiki = pywikiapi.Site(OSMWIKI_ENDPOINT) - - proposals = [] - for page in osmwiki.query_pages( - generator='embeddedin', - geititle='Template:Proposal page', - geilimit='max', - prop='revisions', - rvprop='content', - rvslots='main', - ): - proposal = parse_proposal(page, langs) - if proposal: - proposals.append(proposal) - - proposals.sort(key=sort_key, reverse=True) - - json.dump( - [{k: v for k, v in p.items() if v is not None} for p in proposals], sys.stdout - ) - - -def get_template_val(tpl, name): - param = tpl.get(name, None) - if param: - value = param.value.strip() - if value: - # turn empty strings into None - return value - - -def eprint(*args): - print(*args, file=sys.stderr) - - -def is_stub(doc): - if any( - doc.ifilter_templates(matches=lambda t: t.name.matches('Archived proposal')) - ): - return False - - if not any(doc.ifilter_headings()): - # detect proposals without headings as stubs - return True - - if not any( - n - for n in doc.nodes - # any text - if isinstance(n, mwparserfromhell.nodes.text.Text) - # other than newlines - and n.strip() - # and "Please comment on the [[{{TALKPAGENAME}}|discussion page]]." - and n.strip() not in ('Please comment on the', '.') - ): - # detect proposals without text as stubs - return True - - return False - - -def parse_proposal(page: dict, langs: Container[str]) -> dict | None: - page_title = page['title'] - text = page['revisions'][0]['slots']['main']['content'] - doc = mwparserfromhell.parse(text) - proposal_page_templates = doc.filter_templates( - matches=lambda t: t.name.matches('Proposal page') - or t.name.matches('Proposal Page') - ) - - if not proposal_page_templates: - eprint('{{Proposal Page}} not found in', page_title) - return None - - for comment in doc.ifilter_comments(): - # remove comments like - doc.remove(comment) - - tpl = proposal_page_templates[0] - - status = get_template_val(tpl, 'status') - if status: - status = status.lower() - - if is_stub(doc): - if status in ('approved', 'rejected'): - eprint(f'WARNING {status} proposal is a stub', page['title']) - else: - eprint('skipping stub', page['title']) - return None - - name = get_template_val(tpl, 'name') - if name: - name = html.unescape(name) - - draft_start = get_template_val(tpl, 'draftStartDate') - if draft_start in ('*', '-'): - draft_start = None - - rfc_start = get_template_val(tpl, 'rfcStartDate') - if rfc_start in ('*', '-'): - rfc_start = None - - vote_start = get_template_val(tpl, 'voteStartDate') - if vote_start in ('*', '-'): - vote_start = None - - definition = get_template_val(tpl, 'definition') - users = get_template_val(tpl, 'users') or get_template_val(tpl, 'user') - - parts = page_title.split(':', maxsplit=1) - parts[0] = parts[0].lower() - - lang = None - if parts[0] in langs: - lang = parts[0] - - return dict( - page_title=page_title, - lang=lang, - name=name, - status=status, - definition=definition, - draft_start=draft_start, - rfc_start=rfc_start, - vote_start=vote_start, - authors=users, - ) - - -STATUSES = { - 'voting': 0, - 'post-vote': 1, - 'proposed': 2, - 'draft': 3, - 'approved': 4, - 'inactive': 5, - 'rejected': 6, - 'abandoned': 7, - 'canceled': 8, - 'obsoleted': 9, -} - - -def sort_key(proposal): - status = proposal['status'] - - if status in ('voting', 'approved', 'rejected'): - date = proposal['vote_start'] or '' - elif status == 'proposed': - date = proposal['rfc_start'] or '' - else: - date = proposal['draft_start'] or '' - - return (-STATUSES.get(proposal['status'], 10), date) - - -if __name__ == "__main__": - run() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1bad629 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "osm-proposals" +version = "0.0.0" + +[tool.setuptools.packages.find] +where = ["."] + +[project.scripts] +osm-proposals = "osm_proposals.proposals:run" +osm-proposals-archived-without-template = "osm_proposals.archived_without_template:run" diff --git a/script.js b/script.js deleted file mode 100644 index 35aa43e..0000000 --- a/script.js +++ /dev/null @@ -1,110 +0,0 @@ -const tbody = document.getElementById('tbody'); -const statusSpan = document.getElementById('status'); - -function newEl(tagname, content) { - const cell = document.createElement(tagname); - if (content instanceof Node) - cell.appendChild(content); - else - cell.textContent = content; - return cell; -} - -const updateUrl = debounce(params => { - if (Object.keys(params).length == 0) - window.history.pushState({}, '', '/'); - else - window.history.pushState({}, '', '?' + new URLSearchParams(params).toString()); -}, 500); - -function normalize(str) { - return str.toLowerCase().replaceAll('_', ' ') -} - -function escapeForRegex(str) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -function display(proposals) { - const params = {}; - if (nameInput.value) { - const regex = new RegExp('\\b' + escapeForRegex(normalize(nameInput.value))); - proposals = proposals.filter(p => regex.test(normalize(p.name || p.page_title))); - params.q = nameInput.value; - } - - if (authorsInput.value) { - // The first letter of MediaWiki usernames is case-insensitive. - const firstChar = authorsInput.value.charAt(0); - const rest = authorsInput.value.slice(1); - const lowercase = firstChar.toLowerCase() + rest; - const uppercase = firstChar.toUpperCase() + rest; - proposals = proposals.filter(p => p.authors && (p.authors.includes(lowercase) || p.authors.includes(uppercase))); - params.author = authorsInput.value; - } - - const nf = Intl.NumberFormat(); - statusSpan.textContent = `Found ${nf.format(proposals.length)} proposals.`; - - updateUrl(params); - - tbody.innerHTML = ''; - proposals.forEach(proposal => { - const row = document.createElement('tr'); - - const statusCell = newEl('td', proposal.status); - statusCell.className = 'status-' + proposal.status; - row.appendChild(statusCell); - - let date = '???'; - if (proposal.status == 'voting' || proposal.status == 'approved' || proposal.status == 'rejected') { - date = proposal.vote_start; - } else if (proposal.status == 'proposed') { - date = proposal.rfc_start; - } else { - date = proposal.draft_start; - } - row.appendChild(newEl('td', date)); - - const link = newEl('a', proposal.name || proposal.page_title); - link.href = 'https://wiki.openstreetmap.org/wiki/' + proposal.page_title.replaceAll(' ', '_'); - - const nameCell = newEl('td', link); - if (proposal.lang) - nameCell.appendChild(document.createTextNode(` (${proposal.lang})`)); - row.appendChild(nameCell); - row.appendChild(newEl('td', proposal.authors)); - - tbody.appendChild(row); - }); -} - -const nameInput = document.getElementById('name'); -const authorsInput = document.getElementById('authors'); - -function debounce(callback, wait) { - let timeoutId = null; - return (...args) => { - window.clearTimeout(timeoutId); - timeoutId = window.setTimeout(() => { - callback.apply(null, args); - }, wait); - }; -} - -(async function() { - const proposals = await (await fetch('proposals.json')).json(); - - const params = new URLSearchParams(location.search); - nameInput.value = params.get('q'); - authorsInput.value = params.get('author'); - - display(proposals); - - nameInput.addEventListener('input', debounce(e => { - display(proposals); - }, 100)); - authorsInput.addEventListener('input', debounce(e => { - display(proposals); - }, 100)); -})(); diff --git a/service.nix b/service.nix new file mode 100644 index 0000000..fcd665e --- /dev/null +++ b/service.nix @@ -0,0 +1,52 @@ +{ config, lib, pkgs, ... }: + +let + osm_proposals = pkgs.callPackage ./default.nix {}; + cfg = config.services.osm_proposals; +in +{ + options.services.osm_proposals = { + enable = lib.mkEnableOption "osm_proposals"; + + virtualHost = lib.mkOption { + type = lib.types.str; + description = "Name of the nginx virtualhost to set up."; + }; + + nginx = lib.mkOption { + type = lib.types.submodule (import ); + default = {}; + }; + }; + + config = lib.mkIf cfg.enable { + services.nginx = { + enable = true; + + virtualHosts.${cfg.virtualHost} = lib.mkMerge [ + cfg.nginx + { + locations."/" = { + root = "${osm_proposals}/share/osm-proposals"; + }; + locations."=/proposals.json" = { + extraConfig = '' + alias /var/lib/osm-proposals/proposals.json; + ''; + }; + } + ]; + }; + + systemd.services.osm-proposals = { + serviceConfig = { + Type = "oneshot"; + StateDirectory = "osm-proposals"; # creates /var/lib/osm-proposals + ExecStart = "${osm_proposals}/bin/osm-proposals /var/lib/osm-proposals/proposals.json"; + # Not using DynamicUser because then the StateDirectory becomes unreadable + # by other users, even when setting StateDirectoryMode for some reason. + }; + startAt = "hourly"; + }; + }; +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..8d7bd7a --- /dev/null +++ b/static/index.html @@ -0,0 +1,20 @@ + + + OSM proposals + +

OSM proposals

+

This page provides an overview of proposals for OpenStreetMap. The page is updated every hour.

+ +

+ + + + + + + + + +
StatusDateName
Authors
+ + diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..35aa43e --- /dev/null +++ b/static/script.js @@ -0,0 +1,110 @@ +const tbody = document.getElementById('tbody'); +const statusSpan = document.getElementById('status'); + +function newEl(tagname, content) { + const cell = document.createElement(tagname); + if (content instanceof Node) + cell.appendChild(content); + else + cell.textContent = content; + return cell; +} + +const updateUrl = debounce(params => { + if (Object.keys(params).length == 0) + window.history.pushState({}, '', '/'); + else + window.history.pushState({}, '', '?' + new URLSearchParams(params).toString()); +}, 500); + +function normalize(str) { + return str.toLowerCase().replaceAll('_', ' ') +} + +function escapeForRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function display(proposals) { + const params = {}; + if (nameInput.value) { + const regex = new RegExp('\\b' + escapeForRegex(normalize(nameInput.value))); + proposals = proposals.filter(p => regex.test(normalize(p.name || p.page_title))); + params.q = nameInput.value; + } + + if (authorsInput.value) { + // The first letter of MediaWiki usernames is case-insensitive. + const firstChar = authorsInput.value.charAt(0); + const rest = authorsInput.value.slice(1); + const lowercase = firstChar.toLowerCase() + rest; + const uppercase = firstChar.toUpperCase() + rest; + proposals = proposals.filter(p => p.authors && (p.authors.includes(lowercase) || p.authors.includes(uppercase))); + params.author = authorsInput.value; + } + + const nf = Intl.NumberFormat(); + statusSpan.textContent = `Found ${nf.format(proposals.length)} proposals.`; + + updateUrl(params); + + tbody.innerHTML = ''; + proposals.forEach(proposal => { + const row = document.createElement('tr'); + + const statusCell = newEl('td', proposal.status); + statusCell.className = 'status-' + proposal.status; + row.appendChild(statusCell); + + let date = '???'; + if (proposal.status == 'voting' || proposal.status == 'approved' || proposal.status == 'rejected') { + date = proposal.vote_start; + } else if (proposal.status == 'proposed') { + date = proposal.rfc_start; + } else { + date = proposal.draft_start; + } + row.appendChild(newEl('td', date)); + + const link = newEl('a', proposal.name || proposal.page_title); + link.href = 'https://wiki.openstreetmap.org/wiki/' + proposal.page_title.replaceAll(' ', '_'); + + const nameCell = newEl('td', link); + if (proposal.lang) + nameCell.appendChild(document.createTextNode(` (${proposal.lang})`)); + row.appendChild(nameCell); + row.appendChild(newEl('td', proposal.authors)); + + tbody.appendChild(row); + }); +} + +const nameInput = document.getElementById('name'); +const authorsInput = document.getElementById('authors'); + +function debounce(callback, wait) { + let timeoutId = null; + return (...args) => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + callback.apply(null, args); + }, wait); + }; +} + +(async function() { + const proposals = await (await fetch('proposals.json')).json(); + + const params = new URLSearchParams(location.search); + nameInput.value = params.get('q'); + authorsInput.value = params.get('author'); + + display(proposals); + + nameInput.addEventListener('input', debounce(e => { + display(proposals); + }, 100)); + authorsInput.addEventListener('input', debounce(e => { + display(proposals); + }, 100)); +})(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..66fe28f --- /dev/null +++ b/static/style.css @@ -0,0 +1,39 @@ +body { + font-family: sans-serif; +} +a { + text-decoration: none; +} +th { + position: sticky; + top: 0; + background: gainsboro; +} +td, th { + padding: 0.2em; +} +td:first-of-type { text-align: center; } +.status-voting { background: #baf; } +.status-proposed { background: #eef; } +.status-approved { background: #dfd; } +.status-rejected { background: #fbb; } + +/* Cleaner table borders compatible with the sticky table header */ +table { + border-spacing: 0; + border: none; + --border: 1px solid #999; +} +table th { + border: var(--border); + border-left: none; +} +table td { + border: var(--border); + border-left: none; + border-top: none; +} +table tr > th:first-child, +table tr > td:first-child { + border-left: var(--border); +} diff --git a/style.css b/style.css deleted file mode 100644 index 66fe28f..0000000 --- a/style.css +++ /dev/null @@ -1,39 +0,0 @@ -body { - font-family: sans-serif; -} -a { - text-decoration: none; -} -th { - position: sticky; - top: 0; - background: gainsboro; -} -td, th { - padding: 0.2em; -} -td:first-of-type { text-align: center; } -.status-voting { background: #baf; } -.status-proposed { background: #eef; } -.status-approved { background: #dfd; } -.status-rejected { background: #fbb; } - -/* Cleaner table borders compatible with the sticky table header */ -table { - border-spacing: 0; - border: none; - --border: 1px solid #999; -} -table th { - border: var(--border); - border-left: none; -} -table td { - border: var(--border); - border-left: none; - border-top: none; -} -table tr > th:first-child, -table tr > td:first-child { - border-left: var(--border); -} -- cgit v1.2.3