426 lines
15 KiB
Python
426 lines
15 KiB
Python
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, href: 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=href, class_=link_class)[
|
|
h.span[label],
|
|
badge and h.span(class_=badge_class)[badge],
|
|
]
|
|
|
|
|
|
def admin_sidebar(
|
|
*, current_path: str, source_count: int = 0, running_count: int = 0
|
|
) -> 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.nav(class_="mt-10 space-y-2")[
|
|
nav_link(
|
|
label="Dashboard",
|
|
href="/",
|
|
active=current_path == "/",
|
|
badge="Live",
|
|
),
|
|
nav_link(
|
|
label="Sources",
|
|
href="/sources",
|
|
active=current_path.startswith("/sources"),
|
|
badge=str(source_count),
|
|
),
|
|
nav_link(
|
|
label="Runs",
|
|
href="/runs",
|
|
active=current_path.startswith("/runs")
|
|
or current_path.startswith("/job/"),
|
|
badge=str(running_count),
|
|
),
|
|
nav_link(
|
|
label="Settings",
|
|
href="/settings",
|
|
active=current_path.startswith("/settings"),
|
|
badge="App",
|
|
),
|
|
],
|
|
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")[
|
|
"AnyNews Republisher v2.0"
|
|
],
|
|
h.p(class_="mt-4 text-xs uppercase tracking-[0.22em] text-slate-400")[
|
|
"by Guardian Project"
|
|
],
|
|
],
|
|
],
|
|
]
|
|
|
|
|
|
def header_action_link(*, href: str, label: str) -> Renderable:
|
|
return h.a(
|
|
href=href,
|
|
class_="inline-flex items-center rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-sm transition hover:bg-amber-300",
|
|
)[label]
|
|
|
|
|
|
def header_secondary_link(*, href: str, label: str) -> Renderable:
|
|
return h.a(
|
|
href=href,
|
|
class_="inline-flex items-center rounded-full border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/10",
|
|
)[label]
|
|
|
|
|
|
def muted_action_link(*, href: str, label: str) -> Renderable:
|
|
return h.a(
|
|
href=href,
|
|
class_="inline-flex items-center rounded-full border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50",
|
|
)[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 inline_button(
|
|
*, label: str, tone: str = "default", disabled: bool = False
|
|
) -> Renderable:
|
|
classes = {
|
|
"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",
|
|
}
|
|
class_name = (
|
|
"cursor-not-allowed bg-slate-100 text-slate-400" if disabled else classes[tone]
|
|
)
|
|
return h.button(
|
|
type="button",
|
|
disabled=disabled,
|
|
class_=f"inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-semibold transition {class_name}",
|
|
)[label]
|
|
|
|
|
|
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 h.main(
|
|
id="morph",
|
|
class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]",
|
|
)[
|
|
admin_sidebar(
|
|
current_path=current_path,
|
|
source_count=source_count,
|
|
running_count=running_count,
|
|
),
|
|
h.div(class_="px-4 py-4 sm:px-5 lg:px-6 lg:py-5")[
|
|
h.div(class_="mx-auto max-w-7xl space-y-5")[
|
|
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,
|
|
]
|
|
],
|
|
]
|
|
|
|
|
|
def section_card(*, content: Node) -> Renderable:
|
|
return h.section(class_="space-y-4")[content]
|
|
|
|
|
|
def table_section(
|
|
*,
|
|
eyebrow: str | None = None,
|
|
title: str,
|
|
subtitle: str | None = None,
|
|
empty_message: str,
|
|
headers: tuple[str, ...],
|
|
rows: tuple[tuple[Node, ...], ...],
|
|
actions: Node | None = None,
|
|
) -> Renderable:
|
|
def render_row(row: tuple[Node, ...]) -> Renderable:
|
|
first_cell, *other_cells = row
|
|
return h.tr(class_="align-top")[
|
|
h.td(class_="py-4 pr-6 pl-4 text-sm font-medium text-slate-950 sm:pl-6")[
|
|
first_cell
|
|
],
|
|
(
|
|
h.td(
|
|
class_="px-3 py-4 align-top text-sm whitespace-nowrap text-slate-600"
|
|
)[cell]
|
|
for cell in other_cells
|
|
),
|
|
]
|
|
|
|
body_rows: Node
|
|
if rows:
|
|
body_rows = (render_row(row) for row in rows)
|
|
else:
|
|
body_rows = h.tr[
|
|
h.td(
|
|
colspan=str(len(headers)),
|
|
class_="px-4 py-8 text-center text-sm text-slate-500 sm:px-6",
|
|
)[empty_message]
|
|
]
|
|
|
|
return h.section[
|
|
h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between")[
|
|
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],
|
|
subtitle and h.p(class_="mt-1 text-sm text-slate-600")[subtitle],
|
|
],
|
|
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-[72rem] divide-y divide-slate-200 table-auto"
|
|
)[
|
|
h.thead(class_="bg-stone-50")[
|
|
h.tr[
|
|
(
|
|
h.th(
|
|
scope="col",
|
|
class_="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 first:pl-4 sm:first:pl-6",
|
|
)[header]
|
|
for header in headers
|
|
)
|
|
]
|
|
],
|
|
h.tbody(class_="divide-y divide-slate-200 bg-white")[body_rows],
|
|
]
|
|
]
|
|
],
|
|
]
|
|
|
|
|
|
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,
|
|
signal_name: str | None = None,
|
|
disabled: bool = False,
|
|
) -> Renderable:
|
|
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"
|
|
)
|
|
)
|
|
return h.div[
|
|
h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[
|
|
label
|
|
],
|
|
h.input(
|
|
{"data-bind": signal_name} if signal_name is not None else {},
|
|
id=field_id,
|
|
name=field_id,
|
|
type="text",
|
|
value=value,
|
|
placeholder=placeholder,
|
|
disabled=disabled,
|
|
class_=class_name,
|
|
),
|
|
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,
|
|
signal_name: str | None = None,
|
|
) -> Renderable:
|
|
return h.div[
|
|
h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[
|
|
label
|
|
],
|
|
h.select(
|
|
{"data-bind": signal_name} if signal_name is not None else {},
|
|
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",
|
|
signal_name: str | None = None,
|
|
) -> Renderable:
|
|
return h.div[
|
|
h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[
|
|
label
|
|
],
|
|
h.textarea(
|
|
{"data-bind": signal_name} if signal_name is not None else {},
|
|
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,
|
|
signal_name: str,
|
|
checked: bool = False,
|
|
) -> Renderable:
|
|
signal_value = str(checked).lower()
|
|
|
|
return h.div(
|
|
{"data-signals__ifmissing": f"{{{signal_name}: {signal_value}}}"},
|
|
class_="rounded-3xl bg-white p-4 shadow-sm",
|
|
)[
|
|
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(
|
|
{
|
|
"data-class:bg-amber-500": f"${signal_name}",
|
|
"data-class:bg-slate-200": f"!${signal_name}",
|
|
},
|
|
class_="group relative inline-flex w-11 shrink-0 rounded-full bg-slate-200 p-0.5 outline-offset-2 outline-amber-500 transition",
|
|
)[
|
|
h.span(
|
|
{
|
|
"data-class:translate-x-5": f"${signal_name}",
|
|
"data-class:translate-x-0": f"!${signal_name}",
|
|
},
|
|
class_="size-5 translate-x-0 rounded-full bg-white shadow-xs ring-1 ring-slate-900/5 transition-transform",
|
|
),
|
|
h.input(
|
|
{"data-bind": signal_name},
|
|
type="checkbox",
|
|
name=signal_name,
|
|
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-emerald-100 text-emerald-800",
|
|
}
|
|
return h.span(
|
|
class_=f"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold {tones[tone]}"
|
|
)[label]
|