republisher/repub/components.py

514 lines
17 KiB
Python
Raw Normal View History

2026-03-30 12:13:04 +02:00
from __future__ import annotations
from collections.abc import Mapping
2026-03-30 12:13:04 +02:00
import htpy as h
from htpy import Node, Renderable
def _button_classes(*, tone: str, emphasis: str, disabled: bool = False) -> str:
base = "inline-flex shrink-0 items-center justify-center rounded-full font-semibold transition "
emphasis_classes = {
"compact": "px-3 py-1.5 text-sm",
"regular": "px-4 py-2.5 text-sm",
"soft": "px-3.5 py-2 text-sm",
"icon": "size-8 p-0",
}
tone_classes = {
"amber": "bg-amber-400 text-slate-950 hover:bg-amber-300",
"header-secondary": (
"border border-white/15 bg-white/5 text-white hover:bg-white/10"
),
"muted": "border border-slate-200 bg-white text-slate-700 shadow-sm hover:bg-slate-50",
"default": "bg-stone-100 text-slate-700 hover:bg-stone-200",
"danger": "bg-rose-50 text-rose-700 hover:bg-rose-100",
"success": "bg-emerald-100 text-emerald-800 hover:bg-emerald-200",
"dark": "bg-slate-950 text-white hover:bg-slate-800",
}
disabled_classes = {
"default": "bg-slate-100 text-slate-400",
"danger": "bg-slate-100 text-slate-400",
"success": "bg-slate-100 text-slate-400",
"dark": "bg-slate-300 text-white/80",
}
interactive = "cursor-not-allowed" if disabled else "cursor-pointer"
colors = (
disabled_classes.get(tone, "bg-slate-100 text-slate-400")
if disabled
else tone_classes[tone]
)
return f"{base}{emphasis_classes[emphasis]} {interactive} {colors}"
2026-03-30 12:13:04 +02:00
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(
2026-03-30 13:11:37 +02:00
*, label: str, href: str, active: bool = False, badge: str | None = None
2026-03-30 12:13:04 +02:00
) -> 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"
)
2026-03-30 13:11:37 +02:00
return h.a(href=href, class_=link_class)[
2026-03-30 12:13:04 +02:00
h.span[label],
badge and h.span(class_=badge_class)[badge],
]
2026-03-30 18:26:02 +02:00
def admin_sidebar(
*, current_path: str, source_count: int = 0, running_count: int = 0
) -> Renderable:
2026-03-30 13:11:37 +02:00
return h.aside(
class_="relative overflow-hidden bg-slate-950 px-4 py-6 text-white lg:min-h-screen"
2026-03-30 13:11:37 +02:00
)[
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-2.5")[
2026-03-30 13:11:37 +02:00
h.div(
class_="flex size-10 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950"
2026-03-30 13:11:37 +02:00
)["AR"],
h.div[
h.p(
class_="text-xs font-semibold uppercase tracking-[0.24em] text-amber-300"
)["Republisher"],
],
],
h.nav(class_="mt-8 space-y-2")[
2026-03-30 13:11:37 +02:00
nav_link(
label="Dashboard",
href="/",
active=current_path == "/",
badge="Live",
),
nav_link(
label="Sources",
href="/sources",
active=current_path.startswith("/sources"),
2026-03-30 18:26:02 +02:00
badge=str(source_count),
2026-03-30 13:11:37 +02:00
),
nav_link(
label="Runs",
href="/runs",
active=current_path.startswith("/runs")
or current_path.startswith("/job/"),
2026-03-30 18:26:02 +02:00
badge=str(running_count),
),
nav_link(
label="Settings",
href="/settings",
active=current_path.startswith("/settings"),
badge="App",
2026-03-30 13:11:37 +02:00
),
],
h.div(class_="mt-auto rounded-3xl bg-white/5 p-4 ring-1 ring-white/10")[
2026-03-30 14:18:51 +02:00
h.p(class_="text-sm font-semibold text-white")[
"AnyNews Republisher v2.0"
],
2026-03-30 13:11:37 +02:00
h.p(class_="mt-4 text-xs uppercase tracking-[0.22em] text-slate-400")[
2026-03-30 14:18:51 +02:00
"by Guardian Project"
2026-03-30 13:11:37 +02:00
],
],
],
]
def header_action_link(*, href: str, label: str) -> Renderable:
return h.a(
href=href,
class_=_button_classes(tone="amber", emphasis="regular"),
2026-03-30 13:11:37 +02:00
)[label]
def header_secondary_link(*, href: str, label: str) -> Renderable:
return h.a(
href=href,
class_=_button_classes(tone="header-secondary", emphasis="regular"),
2026-03-30 13:11:37 +02:00
)[label]
def muted_action_link(*, href: str, label: str) -> Renderable:
return h.a(
href=href,
class_=_button_classes(tone="muted", emphasis="soft"),
2026-03-30 13:11:37 +02:00
)[label]
def inline_link(*, href: str, label: str, tone: str = "default") -> Renderable:
classes = {
"default": "text-slate-700 hover:text-slate-950",
"amber": "text-amber-700 hover:text-amber-800",
"rose": "text-rose-700 hover:text-rose-800",
}
return h.a(
href=href,
class_=f"inline-flex items-center whitespace-nowrap text-sm font-semibold {classes[tone]}",
)[label]
def action_button(
*,
label: Node,
tone: str = "default",
emphasis: str = "compact",
disabled: bool = False,
button_type: str = "button",
post_path: str | None = None,
title: str | None = None,
2026-03-30 13:11:37 +02:00
) -> Renderable:
attributes: dict[str, str] = {}
if post_path is not None and not disabled:
attributes["data-on:pointerdown"] = f"@post('{post_path}')"
if title is not None:
attributes["aria-label"] = title
2026-03-30 13:11:37 +02:00
return h.button(
attributes,
type=button_type,
2026-03-30 13:11:37 +02:00
disabled=disabled,
title=title,
class_=_button_classes(tone=tone, emphasis=emphasis, disabled=disabled),
2026-03-30 13:11:37 +02:00
)[label]
def inline_button(
*, label: str, tone: str = "default", disabled: bool = False
) -> Renderable:
return action_button(
label=label,
tone=tone,
emphasis="compact",
button_type="button",
disabled=disabled,
)
def app_shell(
2026-03-30 13:11:37 +02:00
*,
current_path: str,
2026-03-30 18:26:02 +02:00
source_count: int = 0,
running_count: int = 0,
2026-03-30 13:11:37 +02:00
content: Node,
) -> Renderable:
return h.main(
id="morph",
class_="min-h-screen lg:grid lg:grid-cols-[14rem_minmax(0,1fr)]",
2026-03-30 13:11:37 +02:00
)[
2026-03-30 18:26:02 +02:00
admin_sidebar(
current_path=current_path,
source_count=source_count,
running_count=running_count,
),
h.div(class_="px-4 py-4 sm:px-4 lg:px-5 lg:py-4")[
h.div(class_="mx-auto max-w-7xl space-y-4")[content]
2026-03-30 13:11:37 +02:00
],
]
def page_shell(
*,
current_path: str,
eyebrow: str,
title: str,
description: str | None = None,
actions: Node | None = None,
source_count: int = 0,
running_count: int = 0,
content: Node,
) -> Renderable:
return app_shell(
current_path=current_path,
source_count=source_count,
running_count=running_count,
content=(
h.section[
h.div(
class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
)[
h.div(class_="max-w-3xl")[
h.h1(
class_="text-3xl font-semibold tracking-tight text-slate-950"
)[title],
(
description
and h.p(class_="mt-1 text-sm text-slate-600")[description]
),
],
actions and h.div(class_="flex flex-wrap gap-2")[actions],
]
],
content,
),
)
2026-03-30 13:11:37 +02:00
def section_card(*, content: Node) -> Renderable:
return h.section(class_="space-y-4")[content]
def table_section(
*,
eyebrow: str | None = None,
title: str,
2026-03-30 15:25:10 +02:00
subtitle: str | None = None,
2026-03-30 15:28:56 +02:00
empty_message: str,
2026-03-30 13:11:37 +02:00
headers: tuple[str, ...],
rows: tuple[tuple[Node, ...], ...],
row_attrs: tuple[Mapping[str, str], ...] | None = None,
first_header_class: str | None = None,
first_cell_class: str | None = None,
2026-03-30 13:11:37 +02:00
actions: Node | None = None,
) -> Renderable:
def render_row(
row: tuple[Node, ...], attrs: Mapping[str, str] | None = None
) -> Renderable:
2026-03-30 13:11:37 +02:00
first_cell, *other_cells = row
row_attributes = dict(attrs or {})
row_attributes["class"] = f"align-top {row_attributes.get('class', '')}".strip()
return h.tr(row_attributes)[
h.td(
class_=(
first_cell_class
or "py-3 pr-5 pl-3 text-sm font-medium text-slate-950 sm:pl-4"
)
)[first_cell],
2026-03-30 13:11:37 +02:00
(
h.td(
class_="px-2.5 py-3 align-top text-sm whitespace-nowrap text-slate-600"
2026-03-30 13:11:37 +02:00
)[cell]
for cell in other_cells
),
]
2026-03-30 15:28:56 +02:00
body_rows: Node
if rows:
row_attributes = row_attrs or tuple({} for _ in rows)
body_rows = (
render_row(row, attrs)
for row, attrs in zip(rows, row_attributes, strict=False)
)
2026-03-30 15:28:56 +02:00
else:
body_rows = h.tr[
h.td(
colspan=str(len(headers)),
class_="px-3 py-8 text-center text-sm text-slate-500 sm:px-4",
2026-03-30 15:28:56 +02:00
)[empty_message]
]
2026-03-30 13:11:37 +02:00
return h.section[
h.div(
class_="flex flex-col gap-2.5 sm:flex-row sm:items-end sm:justify-between"
)[
2026-03-30 13:11:37 +02:00
h.div[
eyebrow
and h.p(
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
)[eyebrow],
h.h2(class_="mt-1 text-xl font-semibold text-slate-950")[title],
2026-03-30 15:25:10 +02:00
subtitle and h.p(class_="mt-1 text-sm text-slate-600")[subtitle],
2026-03-30 13:11:37 +02:00
],
actions,
],
h.div(
class_="mt-3 overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200"
)[
h.div(class_="overflow-x-auto")[
h.table(
class_="relative w-full min-w-[64rem] divide-y divide-slate-200 table-auto"
2026-03-30 13:11:37 +02:00
)[
h.thead(class_="bg-stone-50")[
h.tr[
(
h.th(
scope="col",
class_=(
first_header_class
if index == 0 and first_header_class is not None
else "px-2.5 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 first:pl-3 sm:first:pl-4"
),
2026-03-30 13:11:37 +02:00
)[header]
for index, header in enumerate(headers)
2026-03-30 13:11:37 +02:00
)
]
],
2026-03-30 15:28:56 +02:00
h.tbody(class_="divide-y divide-slate-200 bg-white")[body_rows],
2026-03-30 13:11:37 +02:00
]
]
],
]
2026-03-30 12:13:04 +02:00
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,
2026-03-30 13:23:36 +02:00
signal_name: str | None = None,
2026-03-30 13:49:00 +02:00
disabled: bool = False,
2026-03-30 12:13:04 +02:00
) -> Renderable:
2026-03-30 13:49:00 +02:00
class_name = (
"mt-2 block w-full rounded-2xl border-0 px-3.5 py-2.5 text-sm shadow-sm ring-1 "
+ (
"cursor-not-allowed bg-slate-100 text-slate-500 ring-slate-200"
if disabled
else "bg-white text-slate-900 ring-slate-200 placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-amber-500"
)
)
2026-03-30 12:13:04 +02:00
return h.div[
h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[
label
],
h.input(
2026-03-30 13:23:36 +02:00
{"data-bind": signal_name} if signal_name is not None else {},
2026-03-30 12:13:04 +02:00
id=field_id,
name=field_id,
type="text",
value=value,
placeholder=placeholder,
2026-03-30 13:49:00 +02:00
disabled=disabled,
class_=class_name,
2026-03-30 12:13:04 +02:00
),
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,
2026-03-30 13:23:36 +02:00
signal_name: str | None = None,
2026-03-30 12:13:04 +02:00
) -> Renderable:
return h.div[
h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[
label
],
h.select(
2026-03-30 13:23:36 +02:00
{"data-bind": signal_name} if signal_name is not None else {},
2026-03-30 12:13:04 +02:00
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(
2026-03-30 13:23:36 +02:00
*,
label: str,
field_id: str,
value: str,
rows: str = "4",
signal_name: str | None = None,
2026-03-30 12:13:04 +02:00
) -> Renderable:
return h.div[
h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[
label
],
h.textarea(
2026-03-30 13:23:36 +02:00
{"data-bind": signal_name} if signal_name is not None else {},
2026-03-30 12:13:04 +02:00
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],
]
2026-03-30 13:11:37 +02:00
def toggle_field(
*,
label: str,
description: str,
signal_name: str,
checked: bool = False,
) -> Renderable:
signal_value = str(checked).lower()
2026-03-30 12:13:04 +02:00
2026-03-30 13:11:37 +02:00
return h.div(
{"data-signals__ifmissing": f"{{{signal_name}: {signal_value}}}"},
class_="rounded-3xl bg-white p-4 shadow-sm",
)[
2026-03-30 12:13:04 +02:00
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")[
2026-03-30 13:11:37 +02:00
h.div(
{
"data-class:bg-amber-500": f"${signal_name}",
"data-class:bg-slate-200": f"!${signal_name}",
},
2026-03-30 18:56:07 +02:00
class_="group relative inline-flex w-11 shrink-0 rounded-full p-0.5 outline-offset-2 outline-amber-500 transition",
2026-03-30 13:11:37 +02:00
)[
h.span(
{
"data-class:translate-x-5": f"${signal_name}",
},
2026-03-30 18:56:07 +02:00
class_="size-5 rounded-full bg-white shadow-xs ring-1 ring-slate-900/5 transition-transform",
2026-03-30 13:11:37 +02:00
),
h.input(
{"data-bind": signal_name},
type="checkbox",
name=signal_name,
checked=checked,
class_="sr-only",
),
2026-03-30 12:13:04 +02:00
],
],
]
]
def status_badge(*, label: str, tone: str) -> Renderable:
tones = {
"running": "bg-emerald-100 text-emerald-800",
"scheduled": "bg-sky-100 text-sky-800",
"queued": "bg-amber-200 text-amber-950",
2026-03-30 12:13:04 +02:00
"idle": "bg-slate-200 text-slate-700",
"failed": "bg-rose-100 text-rose-800",
2026-03-30 14:14:59 +02:00
"done": "bg-emerald-100 text-emerald-800",
2026-03-30 12:13:04 +02:00
}
return h.span(
class_=f"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold {tones[tone]}"
)[label]