from __future__ import annotations from collections.abc import Mapping 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}" 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-4 py-6 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-2.5")[ h.div( class_="flex size-10 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-8 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-4 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_=_button_classes(tone="amber", emphasis="regular"), )[label] def header_secondary_link(*, href: str, label: str) -> Renderable: return h.a( href=href, class_=_button_classes(tone="header-secondary", emphasis="regular"), )[label] def muted_action_link(*, href: str, label: str) -> Renderable: return h.a( href=href, class_=_button_classes(tone="muted", emphasis="soft"), )[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, ) -> 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 return h.button( attributes, type=button_type, disabled=disabled, title=title, class_=_button_classes(tone=tone, emphasis=emphasis, disabled=disabled), )[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( *, current_path: str, 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-[14rem_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-4 lg:px-5 lg:py-4")[ h.div(class_="mx-auto max-w-7xl space-y-4")[content] ], ] 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, ), ) 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, ...], ...], row_attrs: tuple[Mapping[str, str], ...] | None = None, first_header_class: str | None = None, first_cell_class: str | None = None, actions: Node | None = None, ) -> Renderable: def render_row( row: tuple[Node, ...], attrs: Mapping[str, str] | None = None ) -> Renderable: 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], ( h.td( class_="px-2.5 py-3 align-top text-sm whitespace-nowrap text-slate-600" )[cell] for cell in other_cells ), ] 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) ) 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", )[empty_message] ] return h.section[ h.div( class_="flex flex-col gap-2.5 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-[64rem] divide-y divide-slate-200 table-auto" )[ 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" ), )[header] for index, header in enumerate(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 p-0.5 outline-offset-2 outline-amber-500 transition", )[ h.span( { "data-class:translate-x-5": f"${signal_name}", }, class_="size-5 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]