with htpy and css

This commit is contained in:
Abel Luck 2026-03-30 12:13:04 +02:00
parent 4b376c54a2
commit 9ce576e7e8
9 changed files with 2217 additions and 17 deletions

View file

@ -239,7 +239,10 @@
inherit src; inherit src;
dontConfigure = true; dontConfigure = true;
dontBuild = true; dontBuild = true;
nativeBuildInputs = [ testVenv ]; nativeBuildInputs = [
pkgs.pyright
testVenv
];
checkPhase = '' checkPhase = ''
runHook preCheck runHook preCheck
pyright pyright

View file

@ -19,6 +19,7 @@ dependencies = [
"aiosqlite>=0.21.0,<0.22.0", "aiosqlite>=0.21.0,<0.22.0",
"datastar-py>=0.8.0,<0.9.0", "datastar-py>=0.8.0,<0.9.0",
"greenlet>=3.2.4,<4.0.0", "greenlet>=3.2.4,<4.0.0",
"htpy>=25.12.0,<26.0.0",
"peewee>=3.19.0,<4.0.0", "peewee>=3.19.0,<4.0.0",
"pygea @ git+https://guardianproject.dev/anynews/pygea.git", "pygea @ git+https://guardianproject.dev/anynews/pygea.git",
] ]
@ -65,6 +66,14 @@ max-line-length = "88"
[tool.pyright] [tool.pyright]
include = ["repub", "tests"] include = ["repub", "tests"]
exclude = [
"repub/crawl.py",
"repub/exporters.py",
"repub/media.py",
"repub/rss.py",
"repub/spiders",
"repub/srcset.py",
]
pythonVersion = "3.13" pythonVersion = "3.13"
typeCheckingMode = "basic" typeCheckingMode = "basic"
reportMissingImports = false reportMissingImports = false

154
repub/components.py Normal file
View file

@ -0,0 +1,154 @@
from __future__ import annotations
import htpy as h
from htpy import Node, Renderable
def base_layout(*, page_title: str, stylesheet_href: str, content: Node) -> Renderable:
return h.html(lang="en", class_="h-full bg-slate-100")[
h.head[
h.meta(charset="utf-8"),
h.meta(name="viewport", content="width=device-width, initial-scale=1"),
h.title[page_title],
h.link(rel="stylesheet", href=stylesheet_href),
],
h.body(
class_="h-full bg-linear-to-br from-stone-100 via-amber-50 to-orange-100 text-slate-900"
)[content],
]
def nav_link(
*, label: str, active: bool = False, badge: str | None = None
) -> Renderable:
link_class = (
"group flex items-center justify-between rounded-xl px-3 py-2 text-sm font-medium transition "
+ (
"bg-white text-slate-950 shadow-sm ring-1 ring-white/10"
if active
else "text-slate-300 hover:bg-white/5 hover:text-white"
)
)
badge_class = "rounded-full px-2 py-0.5 text-[11px] font-semibold " + (
"bg-amber-200 text-amber-950" if active else "bg-slate-800 text-slate-300"
)
return h.a(href="#", class_=link_class)[
h.span[label],
badge and h.span(class_=badge_class)[badge],
]
def stat_card(*, label: str, value: str, detail: str) -> Renderable:
return h.div(
class_="rounded-3xl bg-white/85 p-5 shadow-sm ring-1 ring-slate-200 backdrop-blur"
)[
h.dt(class_="text-sm font-medium text-slate-500")[label],
h.dd(class_="mt-3 text-3xl font-semibold tracking-tight text-slate-950")[value],
h.p(class_="mt-2 text-sm text-slate-600")[detail],
]
def input_field(
*,
label: str,
field_id: str,
value: str = "",
placeholder: str = "",
help_text: str | None = None,
) -> Renderable:
return h.div[
h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[
label
],
h.input(
id=field_id,
name=field_id,
type="text",
value=value,
placeholder=placeholder,
class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-amber-500",
),
help_text and h.p(class_="mt-2 text-xs text-slate-500")[help_text],
]
def select_field(
*,
label: str,
field_id: str,
options: tuple[str, ...],
selected: str,
help_text: str | None = None,
) -> Renderable:
return h.div[
h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[
label
],
h.select(
id=field_id,
name=field_id,
class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 focus:outline-hidden focus:ring-2 focus:ring-amber-500",
)[
(
h.option(value=option, selected=option == selected)[option]
for option in options
)
],
help_text and h.p(class_="mt-2 text-xs text-slate-500")[help_text],
]
def textarea_field(
*, label: str, field_id: str, value: str, rows: str = "4"
) -> Renderable:
return h.div[
h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[
label
],
h.textarea(
id=field_id,
name=field_id,
rows=rows,
class_="mt-2 block w-full rounded-2xl border-0 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-amber-500",
)[value],
]
def toggle_field(*, label: str, description: str, checked: bool = False) -> Renderable:
wrapper_class = (
"group relative inline-flex w-11 shrink-0 rounded-full p-0.5 outline-offset-2 outline-amber-500 transition "
+ ("bg-amber-500" if checked else "bg-slate-200")
)
knob_class = (
"size-5 rounded-full bg-white shadow-xs ring-1 ring-slate-900/5 transition-transform "
+ ("translate-x-5" if checked else "translate-x-0")
)
return h.div(class_="rounded-3xl bg-stone-50 p-4 ring-1 ring-slate-200")[
h.div(class_="flex items-start justify-between gap-4")[
h.div[
h.h3(class_="text-sm font-semibold text-slate-900")[label],
h.p(class_="mt-1 text-sm text-slate-600")[description],
],
h.label(class_="mt-0.5 cursor-pointer")[
h.div(class_=wrapper_class)[
h.span(class_=knob_class),
h.input(type="checkbox", checked=checked, class_="sr-only"),
],
],
]
]
def status_badge(*, label: str, tone: str) -> Renderable:
tones = {
"running": "bg-emerald-100 text-emerald-800",
"scheduled": "bg-sky-100 text-sky-800",
"idle": "bg-slate-200 text-slate-700",
"failed": "bg-rose-100 text-rose-800",
"done": "bg-amber-100 text-amber-800",
}
return h.span(
class_=f"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold {tones[tone]}"
)[label]

1
repub/pages/__init__.py Normal file
View file

@ -0,0 +1 @@
from repub.pages.dashboard import admin_page

598
repub/pages/dashboard.py Normal file
View file

@ -0,0 +1,598 @@
from __future__ import annotations
import htpy as h
from htpy import Node, Renderable
from repub.components import (
base_layout,
input_field,
nav_link,
select_field,
stat_card,
status_badge,
textarea_field,
toggle_field,
)
def sidebar() -> Renderable:
return h.aside(
class_="relative overflow-hidden bg-slate-950 px-6 py-8 text-white lg:min-h-screen"
)[
h.div(
class_="absolute inset-x-0 top-0 h-40 bg-radial from-amber-400/25 via-amber-400/10 to-transparent"
),
h.div(class_="relative flex h-full flex-col")[
h.div(class_="flex items-center gap-3")[
h.div(
class_="flex size-11 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950"
)["AR"],
h.div[
h.p(
class_="text-xs font-semibold uppercase tracking-[0.24em] text-amber-300"
)["Republisher"],
h.p(class_="text-sm text-slate-300")["Admin spike"],
],
],
h.nav(class_="mt-10 space-y-2")[
nav_link(label="Dashboard", active=True, badge="Live"),
nav_link(label="Sources", badge="12"),
nav_link(label="Runs", badge="3"),
nav_link(label="Schedule"),
nav_link(label="Settings"),
],
h.div(class_="mt-10 rounded-3xl border border-white/10 bg-white/5 p-5")[
h.p(
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300"
)["Operator notes"],
h.p(class_="mt-3 text-sm leading-6 text-slate-300")[
"Single-operator control plane over Tailscale. Everything here is static HTML for the v1 UI spike."
],
],
h.div(class_="mt-auto rounded-3xl bg-white/5 p-5 ring-1 ring-white/10")[
h.p(class_="text-sm font-semibold text-white")["Output root"],
h.p(class_="mt-2 font-mono text-sm text-slate-300")["/srv/anynews/out"],
h.p(class_="mt-4 text-xs uppercase tracking-[0.22em] text-slate-400")[
"Trusted network only"
],
],
],
]
def page_header() -> Renderable:
return h.section(
class_="rounded-[2rem] bg-slate-950 px-6 py-8 text-white shadow-xl sm:px-8"
)[
h.div(class_="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between")[
h.div(class_="max-w-3xl")[
h.p(
class_="text-xs font-semibold uppercase tracking-[0.3em] text-amber-300"
)["Republisher Redux"],
h.h1(class_="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl")[
"Admin UI"
],
h.p(
class_="mt-4 max-w-2xl text-sm leading-6 text-slate-300 sm:text-base"
)[
"One page for source management, job scheduling, live execution visibility, and operator settings. This pass is HTML and CSS only."
],
],
h.div(class_="flex flex-wrap gap-3")[
h.button(
type="button",
class_="rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-sm hover:bg-amber-300",
)["Add source"],
h.button(
type="button",
class_="rounded-full border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-semibold text-white hover:bg-white/10",
)["Run scheduler health check"],
],
]
]
def overview_section() -> Renderable:
return h.section[
h.div(class_="mb-4 flex items-end justify-between")[
h.div[
h.p(
class_="text-sm font-semibold uppercase tracking-[0.22em] text-slate-500"
)["Overview"],
h.h2(
class_="mt-1 text-2xl font-semibold tracking-tight text-slate-950"
)["Operational snapshot"],
],
h.p(class_="text-sm text-slate-500")["Updated from static fixture data"],
],
h.dl(class_="grid gap-4 md:grid-cols-2 xl:grid-cols-4")[
stat_card(label="Active jobs", value="12", detail="9 scheduled, 3 paused"),
stat_card(label="Running now", value="2", detail="RSS and Pangea workers"),
stat_card(
label="Completed today", value="34", detail="31 succeeded, 3 failed"
),
stat_card(
label="Output size", value="18.4 GB", detail="Media and rewritten feeds"
),
],
]
def source_form_section() -> Renderable:
return h.section(
class_="rounded-[2rem] bg-white/90 shadow-sm ring-1 ring-slate-200"
)[
h.div(class_="border-b border-slate-200 px-6 py-5 sm:px-8")[
h.p(
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
)["Create or edit"],
h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[
"Source and job setup"
],
h.p(class_="mt-2 max-w-3xl text-sm text-slate-600")[
"The form shows the intended v1 structure: source fields, subtype fields, cron controls, and job toggles. No persistence is wired yet."
],
],
h.div(class_="space-y-8 px-6 py-6 sm:px-8")[
h.div(class_="grid gap-4 md:grid-cols-2")[
input_field(
label="Source name",
field_id="source-name",
value="Pangea mobile articles",
),
input_field(
label="Slug",
field_id="source-slug",
value="pangea-mobile",
help_text="Immutable after creation.",
),
select_field(
label="Source type",
field_id="source-type",
options=("feed", "pangea"),
selected="pangea",
),
input_field(
label="Feed URL",
field_id="feed-url",
placeholder="https://example.com/feed.xml",
),
],
h.div(class_="grid gap-4 lg:grid-cols-3")[
input_field(
label="Pangea domain",
field_id="pangea-domain",
value="guardianproject.info",
),
input_field(
label="Category name", field_id="pangea-category", value="News"
),
select_field(
label="Content format",
field_id="content-format",
options=("MOBILE_3", "MOBILE_2", "WEB"),
selected="MOBILE_3",
),
input_field(
label="Content type", field_id="content-type", value="articles"
),
input_field(label="Max articles", field_id="max-articles", value="10"),
input_field(
label="Oldest article (days)", field_id="oldest-article", value="3"
),
],
h.div(class_="grid gap-4 lg:grid-cols-2")[
textarea_field(
label="Notes",
field_id="source-notes",
value="Primary Pangea mobile article mirror for the operator landing page.",
),
textarea_field(
label="Spider arguments",
field_id="spider-arguments",
value="language=en,download_media=true",
),
],
h.div[
h.div(class_="mb-4 flex items-end justify-between")[
h.div[
h.h3(class_="text-lg font-semibold text-slate-950")[
"Cron schedule"
],
h.p(class_="mt-1 text-sm text-slate-600")[
"Stored in UTC and displayed in the browser timezone."
],
]
],
h.div(class_="grid gap-4 sm:grid-cols-2 xl:grid-cols-5")[
input_field(label="Minute", field_id="cron-minute", value="15"),
input_field(label="Hour", field_id="cron-hour", value="*/4"),
input_field(
label="Day of month", field_id="cron-day-of-month", value="*"
),
input_field(
label="Day of week", field_id="cron-day-of-week", value="1-6"
),
input_field(label="Month", field_id="cron-month", value="*"),
],
],
h.div(class_="grid gap-4 xl:grid-cols-2")[
toggle_field(
label="Job enabled",
description="If disabled, the scheduler keeps the source definition but skips automatic runs.",
checked=True,
),
toggle_field(
label="Only newest",
description="Restrict Pangea fetches to the newest material available in the selected category.",
checked=True,
),
toggle_field(
label="Include authors",
description="Carry author bylines into rendered output where the upstream provides them.",
checked=True,
),
toggle_field(
label="Exclude media",
description="Use article text only and skip image/media attachment mirroring for this source.",
checked=False,
),
],
],
h.div(
class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 px-6 py-4 sm:px-8"
)[
h.button(
type="button",
class_="rounded-full border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50",
)["Cancel"],
h.button(
type="button",
class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-800",
)["Save source"],
],
]
def source_card(
*, name: str, slug: str, source_type: str, schedule: str, state: Node
) -> Renderable:
return h.div(class_="rounded-3xl bg-stone-50 p-5 ring-1 ring-slate-200")[
h.div(class_="flex items-start justify-between gap-4")[
h.div[
h.h3(class_="text-base font-semibold text-slate-950")[name],
h.p(class_="mt-1 font-mono text-xs text-slate-500")[slug],
],
state,
],
h.dl(class_="mt-4 grid gap-3 sm:grid-cols-2")[
h.div[
h.dt(
class_="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500"
)["Type"],
h.dd(class_="mt-1 text-sm text-slate-900")[source_type],
],
h.div[
h.dt(
class_="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500"
)["Schedule"],
h.dd(class_="mt-1 text-sm text-slate-900")[schedule],
],
],
h.div(class_="mt-5 flex flex-wrap gap-2")[
h.button(
type="button",
class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50",
)["Edit"],
h.button(
type="button",
class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50",
)["Run now"],
h.button(
type="button",
class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-rose-700 ring-1 ring-rose-200 hover:bg-rose-50",
)["Delete"],
],
]
def configured_sources_section() -> Renderable:
return h.section(
class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
)[
h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between")[
h.div[
h.p(
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
)["Configured"],
h.h2(class_="mt-2 text-xl font-semibold text-slate-950")["Sources"],
],
h.p(class_="text-sm text-slate-500")[
"Static cards for the CRUD list state"
],
],
h.div(class_="mt-6 grid gap-4 xl:grid-cols-3")[
source_card(
name="Guardian feed mirror",
slug="guardian-feed",
source_type="RSS feed",
schedule="Every 30 minutes",
state=status_badge(label="Scheduled", tone="scheduled"),
),
source_card(
name="Pangea mobile articles",
slug="pangea-mobile",
source_type="Pangea",
schedule="Every 4 hours",
state=status_badge(label="Running", tone="running"),
),
source_card(
name="Podcast enclosure mirror",
slug="podcast-audio",
source_type="RSS feed",
schedule="Paused",
state=status_badge(label="Idle", tone="idle"),
),
],
]
def class_cells(row: tuple[Node, ...]) -> tuple[Renderable, ...]:
return tuple(
h.td(class_="px-4 py-4 align-top text-slate-700")[cell] for cell in row
)
def job_table_section(
*, title: str, subtitle: str, rows: tuple[tuple[Node, ...], ...]
) -> Renderable:
headers = ("Source", "Window", "Status", "Stats", "Actions")
return h.section(
class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
)[
h.div(class_="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between")[
h.div[
h.h2(class_="text-xl font-semibold text-slate-950")[title],
h.p(class_="mt-1 text-sm text-slate-600")[subtitle],
]
],
h.div(class_="mt-6 overflow-hidden rounded-3xl ring-1 ring-slate-200")[
h.table(class_="min-w-full divide-y divide-slate-200 text-left text-sm")[
h.thead(class_="bg-stone-50")[
h.tr[
(
h.th(
class_="px-4 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500"
)[header]
for header in headers
)
]
],
h.tbody(class_="divide-y divide-slate-200 bg-white")[
(h.tr[class_cells(row)] for row in rows)
],
]
],
]
def log_panel() -> Renderable:
return h.section(
class_="rounded-[2rem] bg-slate-950 p-6 text-white shadow-xl sm:p-8"
)[
h.div(class_="flex items-end justify-between gap-4")[
h.div[
h.p(
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300"
)["Live view"],
h.h2(class_="mt-2 text-xl font-semibold")["Execution log"],
],
status_badge(label="Streaming", tone="running"),
],
h.pre(
class_="mt-5 overflow-x-auto rounded-3xl bg-black/30 p-4 text-xs leading-6 text-emerald-200 ring-1 ring-white/10"
)[
"\n".join(
[
"11:42:01 scheduler: run_now requested for job 7",
"11:42:02 worker[7]: starting pangea-mobile",
"11:42:08 stats: requests=18 items=4 bytes=1.8MB",
"11:42:11 stats: requests=26 items=7 bytes=2.6MB",
"11:42:17 worker[7]: writing out/logs/exec-0007.log",
"11:42:24 worker[7]: finished successfully",
]
)
],
h.div(class_="mt-5 flex flex-wrap gap-3")[
h.button(
type="button",
class_="rounded-full bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/10 hover:bg-white/15",
)["Open full log"],
h.button(
type="button",
class_="rounded-full bg-rose-500/15 px-4 py-2.5 text-sm font-semibold text-rose-200 ring-1 ring-rose-400/20 hover:bg-rose-500/20",
)["Stop job"],
],
]
def settings_panel() -> Renderable:
return h.section(
class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
)[
h.p(class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600")[
"Global"
],
h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[
"Application settings"
],
h.div(class_="mt-6 grid gap-4")[
input_field(label="Bind host", field_id="bind-host", value="127.0.0.1"),
input_field(label="Bind port", field_id="bind-port", value="8080"),
input_field(
label="Output root", field_id="output-root", value="/srv/anynews/out"
),
input_field(
label="Log directory",
field_id="log-directory",
value="/srv/anynews/out/logs",
),
],
h.div(class_="mt-6 flex flex-wrap gap-3")[
h.button(
type="button",
class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-800",
)["Save settings"],
h.button(
type="button",
class_="rounded-full border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50",
)["Reload schedule preview"],
],
]
def admin_page(*, stylesheet_href: str) -> Renderable:
running_rows = (
(
h.div(class_="font-semibold text-slate-950")["Pangea mobile articles"],
h.span["Started 11:42"],
status_badge(label="Running", tone="running"),
h.div["26 requests • 7 items • 2.6 MB"],
h.div(class_="flex flex-wrap gap-2")[
h.button(
type="button",
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
)["View log"],
h.button(
type="button",
class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100",
)["Stop"],
],
),
(
h.div(class_="font-semibold text-slate-950")["Guardian feed mirror"],
h.span["Started 11:33"],
status_badge(label="Running", tone="running"),
h.div["91 requests • 13 items • 5.1 MB"],
h.div(class_="flex flex-wrap gap-2")[
h.button(
type="button",
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
)["View log"],
h.button(
type="button",
class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100",
)["Stop"],
],
),
)
upcoming_rows = (
(
h.div(class_="font-semibold text-slate-950")["Podcast enclosure mirror"],
h.span["Today, 12:15"],
status_badge(label="Scheduled", tone="scheduled"),
h.div["cron: 15 */4 * * 1-6"],
h.div(class_="flex flex-wrap gap-2")[
h.button(
type="button",
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
)["Run now"],
h.button(
type="button",
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
)["Disable"],
h.button(
type="button",
class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100",
)["Delete"],
],
),
(
h.div(class_="font-semibold text-slate-950")["Weekly digest feed"],
h.span["Tomorrow, 08:00"],
status_badge(label="Idle", tone="idle"),
h.div["cron: 0 8 * * 1"],
h.div(class_="flex flex-wrap gap-2")[
h.button(
type="button",
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
)["Run now"],
h.button(
type="button",
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
)["Enable"],
h.button(
type="button",
class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100",
)["Delete"],
],
),
)
completed_rows = (
(
h.div(class_="font-semibold text-slate-950")["Guardian feed mirror"],
h.span["Ended 10:57"],
status_badge(label="Succeeded", tone="done"),
h.div["204 requests • 28 items • 9.4 MB"],
h.div(class_="flex flex-wrap gap-2")[
h.button(
type="button",
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
)["View log"],
],
),
(
h.div(class_="font-semibold text-slate-950")["Podcast enclosure mirror"],
h.span["Ended 09:12"],
status_badge(label="Failed", tone="failed"),
h.div["timeout after 3 retries"],
h.div(class_="flex flex-wrap gap-2")[
h.button(
type="button",
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
)["View log"],
h.button(
type="button",
class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200",
)["Retry"],
],
),
)
return base_layout(
page_title="Republisher Admin UI",
stylesheet_href=stylesheet_href,
content=h.div(class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]")[
sidebar(),
h.main(class_="px-4 py-5 sm:px-6 lg:px-8 lg:py-8")[
h.div(class_="mx-auto max-w-7xl space-y-6")[
page_header(),
overview_section(),
h.div(
class_="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.95fr)]"
)[
h.div(class_="space-y-6")[
source_form_section(),
configured_sources_section(),
job_table_section(
title="Running executions",
subtitle="Operators can inspect active crawls and stop them if needed.",
rows=running_rows,
),
job_table_section(
title="Upcoming jobs",
subtitle="Schedule preview with enable, disable, run now, and delete affordances.",
rows=upcoming_rows,
),
job_table_section(
title="Completed executions",
subtitle="Recent history with direct access to text logs.",
rows=completed_rows,
),
],
h.div(class_="space-y-6")[log_panel(), settings_panel()],
],
]
],
],
)

1430
repub/static/app.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
@import "tailwindcss";
@source "../components.py";
@source "../web.py";

View file

@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from quart import Quart from quart import Quart, url_for
from repub.pages import admin_page
def create_app() -> Quart: def create_app() -> Quart:
@ -8,20 +10,6 @@ def create_app() -> Quart:
@app.get("/") @app.get("/")
async def index() -> str: async def index() -> str:
return """<!doctype html> return str(admin_page(stylesheet_href=url_for("static", filename="app.css")))
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Republisher</title>
</head>
<body>
<main>
<h1>Hello, world!</h1>
<p>Republisher web UI is starting here.</p>
</main>
</body>
</html>
"""
return app return app

14
uv.lock generated
View file

@ -504,6 +504,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
] ]
[[package]]
name = "htpy"
version = "25.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b6/23/e00bbc355e70444d16c90a0f1fdce108c67379fe65e9312cd026c13db976/htpy-25.12.0.tar.gz", hash = "sha256:7d3f4aaa10b35c5e46dfa804df1f3f18772caf8efee6e6a035b5dee89a5d6af8", size = 291259, upload-time = "2025-12-01T20:35:01.666Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/f1/a2f2caf14b03e7fab4801ac6018a4ac996de3e82a573e7aa21f3cb11a7cc/htpy-25.12.0-py3-none-any.whl", hash = "sha256:642e69278d6f8f4643acc2d2d13c21682ceb5fb4860ecbbce042f171577fff54", size = 21141, upload-time = "2025-12-01T20:35:00.13Z" },
]
[[package]] [[package]]
name = "hypercorn" name = "hypercorn"
version = "0.18.0" version = "0.18.0"
@ -1077,6 +1089,7 @@ dependencies = [
{ name = "feedparser" }, { name = "feedparser" },
{ name = "ffmpeg-python" }, { name = "ffmpeg-python" },
{ name = "greenlet" }, { name = "greenlet" },
{ name = "htpy" },
{ name = "lxml" }, { name = "lxml" },
{ name = "peewee" }, { name = "peewee" },
{ name = "pillow" }, { name = "pillow" },
@ -1108,6 +1121,7 @@ requires-dist = [
{ name = "feedparser", specifier = ">=6.0.11,<7.0.0" }, { name = "feedparser", specifier = ">=6.0.11,<7.0.0" },
{ name = "ffmpeg-python", specifier = ">=0.2.0,<0.3.0" }, { name = "ffmpeg-python", specifier = ">=0.2.0,<0.3.0" },
{ name = "greenlet", specifier = ">=3.2.4,<4.0.0" }, { name = "greenlet", specifier = ">=3.2.4,<4.0.0" },
{ name = "htpy", specifier = ">=25.12.0,<26.0.0" },
{ name = "lxml", specifier = ">=5.2.1,<6.0.0" }, { name = "lxml", specifier = ">=5.2.1,<6.0.0" },
{ name = "peewee", specifier = ">=3.19.0,<4.0.0" }, { name = "peewee", specifier = ">=3.19.0,<4.0.0" },
{ name = "pillow", specifier = ">=10.3.0,<11.0.0" }, { name = "pillow", specifier = ">=10.3.0,<11.0.0" },