tighten whitespace, DRY shell and buttons
This commit is contained in:
parent
0b3b1b2731
commit
a88eba7dd1
9 changed files with 439 additions and 225 deletions
|
|
@ -80,7 +80,7 @@ The only way for actions to affect the view returned by the render-fn running in
|
|||
- Enter the dev environment with `nix develop` if you are not already inside it
|
||||
- Sync Python dependencies with `uv sync --all-groups`.
|
||||
- Run the app with `uv run repub`.
|
||||
- Generate CSS with `tailwindcss -i ./path/to/input.css -o ./path/to/output.css` and add `--watch` when you need live rebuilds.
|
||||
- Generate CSS with `tailwindcss -i ./repub/static/app.tailwind.css -o ./repub/static/app.css` and add `--watch` when you need live rebuilds.
|
||||
|
||||
```sh
|
||||
uv sync --all-groups
|
||||
|
|
|
|||
|
|
@ -4,6 +4,41 @@ import htpy as h
|
|||
from htpy import Node, Renderable
|
||||
|
||||
|
||||
def _button_classes(*, tone: str, emphasis: str, disabled: bool = False) -> str:
|
||||
base = (
|
||||
"inline-flex 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",
|
||||
}
|
||||
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[
|
||||
|
|
@ -43,15 +78,15 @@ 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"
|
||||
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-3")[
|
||||
h.div(class_="flex items-center gap-2.5")[
|
||||
h.div(
|
||||
class_="flex size-11 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950"
|
||||
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(
|
||||
|
|
@ -59,7 +94,7 @@ def admin_sidebar(
|
|||
)["Republisher"],
|
||||
],
|
||||
],
|
||||
h.nav(class_="mt-10 space-y-2")[
|
||||
h.nav(class_="mt-8 space-y-2")[
|
||||
nav_link(
|
||||
label="Dashboard",
|
||||
href="/",
|
||||
|
|
@ -86,7 +121,7 @@ def admin_sidebar(
|
|||
badge="App",
|
||||
),
|
||||
],
|
||||
h.div(class_="mt-auto rounded-3xl bg-white/5 p-5 ring-1 ring-white/10")[
|
||||
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"
|
||||
],
|
||||
|
|
@ -101,21 +136,21 @@ def admin_sidebar(
|
|||
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",
|
||||
class_=_button_classes(tone="amber", emphasis="regular"),
|
||||
)[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",
|
||||
class_=_button_classes(tone="header-secondary", emphasis="regular"),
|
||||
)[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",
|
||||
class_=_button_classes(tone="muted", emphasis="soft"),
|
||||
)[label]
|
||||
|
||||
|
||||
|
|
@ -131,22 +166,58 @@ def inline_link(*, href: str, label: str, tone: str = "default") -> Renderable:
|
|||
)[label]
|
||||
|
||||
|
||||
def action_button(
|
||||
*,
|
||||
label: str,
|
||||
tone: str = "default",
|
||||
emphasis: str = "compact",
|
||||
disabled: bool = False,
|
||||
button_type: str = "button",
|
||||
post_path: 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}')"
|
||||
return h.button(
|
||||
attributes,
|
||||
type=button_type,
|
||||
disabled=disabled,
|
||||
class_=_button_classes(tone=tone, emphasis=emphasis, disabled=disabled),
|
||||
)[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",
|
||||
return action_button(
|
||||
label=label,
|
||||
tone=tone,
|
||||
emphasis="compact",
|
||||
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 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(
|
||||
|
|
@ -160,17 +231,11 @@ def page_shell(
|
|||
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(
|
||||
return app_shell(
|
||||
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")[
|
||||
content=(
|
||||
h.section[
|
||||
h.div(
|
||||
class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
|
|
@ -181,18 +246,15 @@ def page_shell(
|
|||
)[title],
|
||||
(
|
||||
description
|
||||
and h.p(class_="mt-1 text-sm text-slate-600")[
|
||||
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:
|
||||
|
|
@ -212,12 +274,12 @@ def table_section(
|
|||
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")[
|
||||
h.td(class_="py-3 pr-5 pl-3 text-sm font-medium text-slate-950 sm:pl-4")[
|
||||
first_cell
|
||||
],
|
||||
(
|
||||
h.td(
|
||||
class_="px-3 py-4 align-top text-sm whitespace-nowrap text-slate-600"
|
||||
class_="px-2.5 py-3 align-top text-sm whitespace-nowrap text-slate-600"
|
||||
)[cell]
|
||||
for cell in other_cells
|
||||
),
|
||||
|
|
@ -230,12 +292,14 @@ def table_section(
|
|||
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",
|
||||
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-3 sm:flex-row sm:items-end sm:justify-between")[
|
||||
h.div(
|
||||
class_="flex flex-col gap-2.5 sm:flex-row sm:items-end sm:justify-between"
|
||||
)[
|
||||
h.div[
|
||||
eyebrow
|
||||
and h.p(
|
||||
|
|
@ -251,14 +315,14 @@ def table_section(
|
|||
)[
|
||||
h.div(class_="overflow-x-auto")[
|
||||
h.table(
|
||||
class_="relative w-full min-w-[72rem] divide-y divide-slate-200 table-auto"
|
||||
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_="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",
|
||||
class_="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 header in headers
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import htpy as h
|
|||
from htpy import Node, Renderable
|
||||
|
||||
from repub.components import (
|
||||
admin_sidebar,
|
||||
app_shell,
|
||||
header_action_link,
|
||||
inline_button,
|
||||
inline_link,
|
||||
|
|
@ -253,21 +253,14 @@ def dashboard_page_with_data(
|
|||
) -> Renderable:
|
||||
running_items = running_executions or ()
|
||||
source_items = source_feeds or ()
|
||||
return h.main(
|
||||
id="morph",
|
||||
class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]",
|
||||
)[
|
||||
admin_sidebar(
|
||||
return app_shell(
|
||||
current_path="/",
|
||||
source_count=len(source_items),
|
||||
running_count=len(running_items),
|
||||
),
|
||||
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")[
|
||||
content=(
|
||||
dashboard_header(),
|
||||
operational_snapshot(snapshot=snapshot),
|
||||
running_executions_table(running_executions=running_items),
|
||||
published_feeds_table(source_feeds=source_items),
|
||||
]
|
||||
],
|
||||
]
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import htpy as h
|
|||
from htpy import Node, Renderable
|
||||
|
||||
from repub.components import (
|
||||
action_button,
|
||||
inline_link,
|
||||
muted_action_link,
|
||||
page_shell,
|
||||
|
|
@ -15,34 +16,6 @@ from repub.components import (
|
|||
)
|
||||
|
||||
|
||||
def _action_button(
|
||||
*,
|
||||
label: str,
|
||||
tone: str = "default",
|
||||
disabled: bool = False,
|
||||
post_path: str | None = None,
|
||||
) -> Renderable:
|
||||
classes = {
|
||||
"default": "bg-stone-100 text-slate-700 hover:bg-stone-200",
|
||||
"danger": "bg-rose-50 text-rose-700 hover:bg-rose-100",
|
||||
}
|
||||
class_name = (
|
||||
"cursor-not-allowed bg-slate-100 text-slate-400" if disabled else classes[tone]
|
||||
)
|
||||
attributes: dict[str, str] = {}
|
||||
if post_path is not None and not disabled:
|
||||
attributes["data-on:pointerdown"] = f"@post('{post_path}')"
|
||||
return h.button(
|
||||
attributes,
|
||||
type="button",
|
||||
disabled=disabled,
|
||||
class_=(
|
||||
"inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 "
|
||||
f"text-sm font-semibold transition {class_name}"
|
||||
),
|
||||
)[label]
|
||||
|
||||
|
||||
def _text(values: Mapping[str, object], key: str) -> str:
|
||||
return str(values[key])
|
||||
|
||||
|
|
@ -62,7 +35,7 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
|
|||
return (
|
||||
h.div[
|
||||
h.div(class_="font-semibold text-slate-950")[_text(execution, "source")],
|
||||
h.p(class_="mt-1 font-mono text-xs text-slate-500")[
|
||||
h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[
|
||||
_text(execution, "slug")
|
||||
],
|
||||
],
|
||||
|
|
@ -73,20 +46,20 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
|
|||
],
|
||||
h.div[
|
||||
h.p(class_="font-medium text-slate-900")[_text(execution, "started_at")],
|
||||
h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "runtime")],
|
||||
h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "runtime")],
|
||||
],
|
||||
status_badge(label=_text(execution, "status"), tone="running"),
|
||||
h.div(class_="min-w-56 whitespace-normal")[
|
||||
h.div(class_="max-w-xs whitespace-normal")[
|
||||
h.p(class_="font-medium text-slate-900")[_text(execution, "stats")],
|
||||
h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "worker")],
|
||||
h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "worker")],
|
||||
],
|
||||
h.div(class_="flex flex-nowrap items-center gap-3")[
|
||||
h.div(class_="flex flex-wrap items-center gap-2")[
|
||||
inline_link(
|
||||
href=_text(execution, "log_href"),
|
||||
label="View log",
|
||||
tone="amber",
|
||||
),
|
||||
_action_button(
|
||||
action_button(
|
||||
label=_text(execution, "cancel_label"),
|
||||
tone="danger",
|
||||
post_path=_maybe_text(execution, "cancel_post_path"),
|
||||
|
|
@ -113,7 +86,7 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
|
|||
return (
|
||||
h.div[
|
||||
h.div(class_="font-semibold text-slate-950")[_text(execution, "source")],
|
||||
h.p(class_="mt-1 font-mono text-xs text-slate-500")[
|
||||
h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[
|
||||
_text(execution, "slug")
|
||||
],
|
||||
],
|
||||
|
|
@ -128,13 +101,13 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
|
|||
f"#{_text(execution, 'queue_position')}"
|
||||
],
|
||||
],
|
||||
_action_button(
|
||||
action_button(
|
||||
label=_text(execution, "run_label"),
|
||||
disabled=_flag(execution, "run_disabled"),
|
||||
post_path=_maybe_text(execution, "run_post_path"),
|
||||
),
|
||||
h.div(class_="flex flex-nowrap items-center gap-2")[
|
||||
_action_button(
|
||||
h.div(class_="flex flex-wrap items-center gap-2")[
|
||||
action_button(
|
||||
label="Cancel",
|
||||
tone="danger",
|
||||
post_path=_maybe_text(execution, "cancel_post_path"),
|
||||
|
|
@ -161,7 +134,7 @@ def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]:
|
|||
return (
|
||||
h.div[
|
||||
h.div(class_="font-semibold text-slate-950")[_text(job, "source")],
|
||||
h.p(class_="mt-1 font-mono text-xs text-slate-500")[_text(job, "slug")],
|
||||
h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[_text(job, "slug")],
|
||||
],
|
||||
h.div[next_run_label,],
|
||||
h.p(class_="font-mono text-xs text-slate-600")[_text(job, "schedule")],
|
||||
|
|
@ -169,20 +142,20 @@ def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]:
|
|||
label=_text(job, "enabled_label"),
|
||||
tone=_text(job, "enabled_tone"),
|
||||
),
|
||||
h.p(class_="max-w-40 whitespace-normal text-sm text-slate-500")[
|
||||
h.p(class_="max-w-32 whitespace-normal text-sm text-slate-500")[
|
||||
_text(job, "run_reason")
|
||||
],
|
||||
h.div(class_="flex flex-nowrap items-center gap-2")[
|
||||
_action_button(
|
||||
h.div(class_="flex flex-wrap items-center gap-2")[
|
||||
action_button(
|
||||
label="Run now",
|
||||
disabled=_flag(job, "run_disabled"),
|
||||
post_path=_maybe_text(job, "run_post_path"),
|
||||
),
|
||||
_action_button(
|
||||
action_button(
|
||||
label=_text(job, "toggle_label"),
|
||||
post_path=_maybe_text(job, "toggle_post_path"),
|
||||
),
|
||||
_action_button(
|
||||
action_button(
|
||||
label="Delete",
|
||||
tone="danger",
|
||||
post_path=_maybe_text(job, "delete_post_path"),
|
||||
|
|
@ -209,7 +182,7 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
|
|||
return (
|
||||
h.div[
|
||||
h.div(class_="font-semibold text-slate-950")[_text(execution, "source")],
|
||||
h.p(class_="mt-1 font-mono text-xs text-slate-500")[
|
||||
h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[
|
||||
_text(execution, "slug")
|
||||
],
|
||||
],
|
||||
|
|
@ -220,13 +193,13 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
|
|||
],
|
||||
h.div[
|
||||
ended_at_label,
|
||||
h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "summary")],
|
||||
h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "summary")],
|
||||
],
|
||||
status_badge(
|
||||
label=_text(execution, "status"),
|
||||
tone=_text(execution, "status_tone"),
|
||||
),
|
||||
h.div(class_="min-w-48 whitespace-normal")[
|
||||
h.div(class_="max-w-[14rem] whitespace-normal")[
|
||||
h.p(class_="font-medium text-slate-900")[_text(execution, "stats")]
|
||||
],
|
||||
inline_link(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ from collections.abc import Mapping
|
|||
import htpy as h
|
||||
from htpy import Renderable
|
||||
|
||||
from repub.components import input_field, muted_action_link, page_shell, section_card
|
||||
from repub.components import (
|
||||
action_button,
|
||||
input_field,
|
||||
muted_action_link,
|
||||
page_shell,
|
||||
section_card,
|
||||
)
|
||||
|
||||
|
||||
def _value(settings: Mapping[str, object] | None, key: str, default: str = "") -> str:
|
||||
|
|
@ -71,10 +77,12 @@ def settings_page(
|
|||
],
|
||||
h.div(class_="flex flex-wrap justify-end gap-3 pt-2")[
|
||||
muted_action_link(href="/", label="Back to dashboard"),
|
||||
h.button(
|
||||
type="submit",
|
||||
class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800",
|
||||
)["Save settings"],
|
||||
action_button(
|
||||
label="Save settings",
|
||||
tone="dark",
|
||||
emphasis="regular",
|
||||
button_type="submit",
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
import htpy as h
|
||||
from htpy import Node, Renderable
|
||||
|
||||
from repub.components import admin_sidebar
|
||||
from repub.components import app_shell
|
||||
|
||||
ON_LOAD_JS = (
|
||||
"@post(window.location.pathname + "
|
||||
|
|
@ -33,17 +33,9 @@ def shim_page(
|
|||
}
|
||||
),
|
||||
h.noscript["Your browser does not support JavaScript!"],
|
||||
h.main(
|
||||
id="morph",
|
||||
class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]",
|
||||
)[
|
||||
admin_sidebar(
|
||||
app_shell(
|
||||
current_path=current_path,
|
||||
source_count=0,
|
||||
running_count=0,
|
||||
),
|
||||
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")[
|
||||
content=(
|
||||
h.section[
|
||||
h.div(
|
||||
class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
|
|
@ -68,8 +60,7 @@ def shim_page(
|
|||
h.div(class_="h-12 rounded-2xl bg-stone-100"),
|
||||
]
|
||||
],
|
||||
]
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import htpy as h
|
|||
from htpy import Node, Renderable
|
||||
|
||||
from repub.components import (
|
||||
action_button,
|
||||
header_action_link,
|
||||
inline_link,
|
||||
input_field,
|
||||
|
|
@ -54,29 +55,6 @@ def _checked(source: Mapping[str, object] | None, key: str, default: bool) -> bo
|
|||
return bool(value)
|
||||
|
||||
|
||||
def _action_button(
|
||||
*,
|
||||
label: str,
|
||||
tone: str = "default",
|
||||
post_path: str | None = None,
|
||||
) -> Renderable:
|
||||
classes = {
|
||||
"default": "bg-stone-100 text-slate-700 hover:bg-stone-200",
|
||||
"danger": "bg-rose-50 text-rose-700 hover:bg-rose-100",
|
||||
}
|
||||
attributes: dict[str, str] = {}
|
||||
if post_path is not None:
|
||||
attributes["data-on:pointerdown"] = f"@post('{post_path}')"
|
||||
return h.button(
|
||||
attributes,
|
||||
type="button",
|
||||
class_=(
|
||||
"inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 "
|
||||
f"text-sm font-semibold transition {classes[tone]}"
|
||||
),
|
||||
)[label]
|
||||
|
||||
|
||||
def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]:
|
||||
return (
|
||||
h.div[
|
||||
|
|
@ -99,12 +77,12 @@ def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]:
|
|||
),
|
||||
h.p(class_="mt-2 text-xs text-slate-500")[str(source["last_run"])],
|
||||
],
|
||||
h.div(class_="flex flex-nowrap items-center gap-3")[
|
||||
h.div(class_="flex flex-wrap items-center gap-2")[
|
||||
inline_link(
|
||||
href=f"/sources/{source['slug']}/edit", label="Edit", tone="amber"
|
||||
),
|
||||
inline_link(href="/runs", label="View runs"),
|
||||
_action_button(
|
||||
action_button(
|
||||
label="Delete",
|
||||
tone="danger",
|
||||
post_path=f"/actions/sources/{source['slug']}/delete",
|
||||
|
|
@ -422,10 +400,12 @@ def source_form(
|
|||
class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 pt-6"
|
||||
)[
|
||||
muted_action_link(href="/sources", label="Cancel"),
|
||||
h.button(
|
||||
type="submit",
|
||||
class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800",
|
||||
)[submit_label],
|
||||
action_button(
|
||||
label=submit_label,
|
||||
tone="dark",
|
||||
emphasis="regular",
|
||||
button_type="submit",
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
--color-stone-200: oklch(92.3% 0.003 48.717);
|
||||
--color-white: #fff;
|
||||
--spacing: 0.25rem;
|
||||
--container-xs: 20rem;
|
||||
--container-sm: 24rem;
|
||||
--container-3xl: 48rem;
|
||||
--container-7xl: 80rem;
|
||||
|
|
@ -289,8 +290,8 @@
|
|||
.mt-5 {
|
||||
margin-top: calc(var(--spacing) * 5);
|
||||
}
|
||||
.mt-10 {
|
||||
margin-top: calc(var(--spacing) * 10);
|
||||
.mt-8 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
}
|
||||
.mt-auto {
|
||||
margin-top: auto;
|
||||
|
|
@ -320,9 +321,9 @@
|
|||
width: calc(var(--spacing) * 5);
|
||||
height: calc(var(--spacing) * 5);
|
||||
}
|
||||
.size-11 {
|
||||
width: calc(var(--spacing) * 11);
|
||||
height: calc(var(--spacing) * 11);
|
||||
.size-10 {
|
||||
width: calc(var(--spacing) * 10);
|
||||
height: calc(var(--spacing) * 10);
|
||||
}
|
||||
.h-5 {
|
||||
height: calc(var(--spacing) * 5);
|
||||
|
|
@ -354,30 +355,33 @@
|
|||
.max-w-7xl {
|
||||
max-width: var(--container-7xl);
|
||||
}
|
||||
.max-w-40 {
|
||||
max-width: calc(var(--spacing) * 40);
|
||||
.max-w-32 {
|
||||
max-width: calc(var(--spacing) * 32);
|
||||
}
|
||||
.max-w-\[14rem\] {
|
||||
max-width: 14rem;
|
||||
}
|
||||
.max-w-sm {
|
||||
max-width: var(--container-sm);
|
||||
}
|
||||
.max-w-xs {
|
||||
max-width: var(--container-xs);
|
||||
}
|
||||
.min-w-32 {
|
||||
min-width: calc(var(--spacing) * 32);
|
||||
}
|
||||
.min-w-48 {
|
||||
min-width: calc(var(--spacing) * 48);
|
||||
}
|
||||
.min-w-56 {
|
||||
min-width: calc(var(--spacing) * 56);
|
||||
}
|
||||
.min-w-64 {
|
||||
min-width: calc(var(--spacing) * 64);
|
||||
}
|
||||
.min-w-\[64rem\] {
|
||||
min-width: 64rem;
|
||||
}
|
||||
.min-w-\[70rem\] {
|
||||
min-width: 70rem;
|
||||
}
|
||||
.min-w-\[72rem\] {
|
||||
min-width: 72rem;
|
||||
}
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
@ -427,6 +431,9 @@
|
|||
.gap-2 {
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
.gap-2\.5 {
|
||||
gap: calc(var(--spacing) * 2.5);
|
||||
}
|
||||
.gap-3 {
|
||||
gap: calc(var(--spacing) * 3);
|
||||
}
|
||||
|
|
@ -552,6 +559,9 @@
|
|||
.bg-slate-200 {
|
||||
background-color: var(--color-slate-200);
|
||||
}
|
||||
.bg-slate-300 {
|
||||
background-color: var(--color-slate-300);
|
||||
}
|
||||
.bg-slate-800 {
|
||||
background-color: var(--color-slate-800);
|
||||
}
|
||||
|
|
@ -649,9 +659,6 @@
|
|||
.px-4 {
|
||||
padding-inline: calc(var(--spacing) * 4);
|
||||
}
|
||||
.px-6 {
|
||||
padding-inline: calc(var(--spacing) * 6);
|
||||
}
|
||||
.py-0\.5 {
|
||||
padding-block: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
|
|
@ -673,6 +680,9 @@
|
|||
.py-4 {
|
||||
padding-block: calc(var(--spacing) * 4);
|
||||
}
|
||||
.py-6 {
|
||||
padding-block: calc(var(--spacing) * 6);
|
||||
}
|
||||
.py-8 {
|
||||
padding-block: calc(var(--spacing) * 8);
|
||||
}
|
||||
|
|
@ -682,9 +692,15 @@
|
|||
.pt-6 {
|
||||
padding-top: calc(var(--spacing) * 6);
|
||||
}
|
||||
.pr-5 {
|
||||
padding-right: calc(var(--spacing) * 5);
|
||||
}
|
||||
.pr-6 {
|
||||
padding-right: calc(var(--spacing) * 6);
|
||||
}
|
||||
.pl-3 {
|
||||
padding-left: calc(var(--spacing) * 3);
|
||||
}
|
||||
.pl-4 {
|
||||
padding-left: calc(var(--spacing) * 4);
|
||||
}
|
||||
|
|
@ -820,6 +836,12 @@
|
|||
.text-white {
|
||||
color: var(--color-white);
|
||||
}
|
||||
.text-white\/80 {
|
||||
color: color-mix(in srgb, #fff 80%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, var(--color-white) 80%, transparent);
|
||||
}
|
||||
}
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
|
@ -879,6 +901,11 @@
|
|||
color: var(--color-slate-400);
|
||||
}
|
||||
}
|
||||
.first\:pl-3 {
|
||||
&:first-child {
|
||||
padding-left: calc(var(--spacing) * 3);
|
||||
}
|
||||
}
|
||||
.first\:pl-4 {
|
||||
&:first-child {
|
||||
padding-left: calc(var(--spacing) * 4);
|
||||
|
|
@ -1036,30 +1063,25 @@
|
|||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
.sm\:px-4 {
|
||||
@media (width >= 40rem) {
|
||||
padding-inline: calc(var(--spacing) * 4);
|
||||
}
|
||||
}
|
||||
.sm\:px-5 {
|
||||
@media (width >= 40rem) {
|
||||
padding-inline: calc(var(--spacing) * 5);
|
||||
}
|
||||
}
|
||||
.sm\:px-6 {
|
||||
@media (width >= 40rem) {
|
||||
padding-inline: calc(var(--spacing) * 6);
|
||||
}
|
||||
}
|
||||
.sm\:pl-4 {
|
||||
@media (width >= 40rem) {
|
||||
padding-left: calc(var(--spacing) * 4);
|
||||
}
|
||||
}
|
||||
.sm\:pl-6 {
|
||||
@media (width >= 40rem) {
|
||||
padding-left: calc(var(--spacing) * 6);
|
||||
}
|
||||
}
|
||||
.sm\:first\:pl-6 {
|
||||
.sm\:first\:pl-4 {
|
||||
@media (width >= 40rem) {
|
||||
&:first-child {
|
||||
padding-left: calc(var(--spacing) * 6);
|
||||
padding-left: calc(var(--spacing) * 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1088,16 +1110,31 @@
|
|||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.lg\:grid-cols-\[14rem_minmax\(0\,1fr\)\] {
|
||||
@media (width >= 64rem) {
|
||||
grid-template-columns: 14rem minmax(0,1fr);
|
||||
}
|
||||
}
|
||||
.lg\:grid-cols-\[18rem_minmax\(0\,1fr\)\] {
|
||||
@media (width >= 64rem) {
|
||||
grid-template-columns: 18rem minmax(0,1fr);
|
||||
}
|
||||
}
|
||||
.lg\:px-5 {
|
||||
@media (width >= 64rem) {
|
||||
padding-inline: calc(var(--spacing) * 5);
|
||||
}
|
||||
}
|
||||
.lg\:px-6 {
|
||||
@media (width >= 64rem) {
|
||||
padding-inline: calc(var(--spacing) * 6);
|
||||
}
|
||||
}
|
||||
.lg\:py-4 {
|
||||
@media (width >= 64rem) {
|
||||
padding-block: calc(var(--spacing) * 4);
|
||||
}
|
||||
}
|
||||
.lg\:py-5 {
|
||||
@media (width >= 64rem) {
|
||||
padding-block: calc(var(--spacing) * 5);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
|
|||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from repub.components import status_badge, toggle_field
|
||||
from repub.components import action_button, status_badge, toggle_field
|
||||
from repub.datastar import RefreshBroker, render_sse_event, render_stream
|
||||
from repub.jobs import load_dashboard_view
|
||||
from repub.model import (
|
||||
|
|
@ -61,6 +61,52 @@ def test_toggle_field_active_state_utilities_exist_in_built_css() -> None:
|
|||
assert ".translate-x-5" in css
|
||||
|
||||
|
||||
def test_action_button_adds_cursor_pointer_for_active_buttons() -> None:
|
||||
markup = str(action_button(label="Run now"))
|
||||
|
||||
assert "cursor-pointer" in markup
|
||||
assert 'type="button"' in markup
|
||||
|
||||
|
||||
def test_action_button_omits_post_handler_when_disabled() -> None:
|
||||
markup = str(
|
||||
action_button(
|
||||
label="Queued",
|
||||
disabled=True,
|
||||
post_path="/actions/jobs/7/run-now",
|
||||
)
|
||||
)
|
||||
|
||||
assert "cursor-not-allowed" in markup
|
||||
assert "@post(" not in markup
|
||||
|
||||
|
||||
def test_action_button_supports_submit_variant() -> None:
|
||||
markup = str(
|
||||
action_button(
|
||||
label="Save settings",
|
||||
tone="dark",
|
||||
button_type="submit",
|
||||
)
|
||||
)
|
||||
|
||||
assert 'type="submit"' in markup
|
||||
assert "bg-slate-950" in markup
|
||||
assert "cursor-pointer" in markup
|
||||
|
||||
|
||||
def test_action_button_supports_datastar_pointerdown_post() -> None:
|
||||
markup = str(
|
||||
action_button(
|
||||
label="Delete",
|
||||
tone="danger",
|
||||
post_path="/actions/jobs/7/delete",
|
||||
)
|
||||
)
|
||||
|
||||
assert 'data-on:pointerdown="@post('/actions/jobs/7/delete')"' in markup
|
||||
|
||||
|
||||
def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_time() -> (
|
||||
None
|
||||
):
|
||||
|
|
@ -140,6 +186,8 @@ def test_root_get_serves_datastar_shim() -> None:
|
|||
assert "retryMaxCount: Infinity" in body
|
||||
assert "data-on:online__window=" in body
|
||||
assert '<main id="morph"' in body
|
||||
assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body
|
||||
assert "lg:px-5 lg:py-4" in body
|
||||
assert 'href="/sources"' in body
|
||||
assert 'href="/runs"' in body
|
||||
assert 'href="/settings"' in body
|
||||
|
|
@ -264,6 +312,8 @@ def test_render_dashboard_shows_dashboard_information_architecture(
|
|||
assert 'href="/sources"' in body
|
||||
assert 'href="/runs"' in body
|
||||
assert "Create source" in body
|
||||
assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body
|
||||
assert "lg:px-5 lg:py-4" in body
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
|
@ -694,6 +744,8 @@ def test_render_settings_shows_current_max_concurrent_jobs(
|
|||
assert "/actions/settings" in body
|
||||
assert 'value="3"' in body
|
||||
assert "Max concurrent jobs" in body
|
||||
assert 'type="submit"' in body
|
||||
assert "cursor-pointer" in body
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
|
@ -1110,6 +1162,23 @@ def test_render_runs_shows_running_upcoming_and_completed_tables(
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_render_runs_uses_compact_shell_and_table_classes(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
db_path = tmp_path / "runs-compact.db"
|
||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||
|
||||
async def run() -> None:
|
||||
app = create_app()
|
||||
body = str(await render_runs(app))
|
||||
|
||||
assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body
|
||||
assert "lg:px-5 lg:py-4" in body
|
||||
assert "min-w-[64rem]" in body
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_render_runs_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "runs-empty.db"
|
||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||
|
|
@ -1231,6 +1300,87 @@ def test_render_runs_shows_cancel_button_for_running_row_with_queued_follow_up(
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction() -> (
|
||||
None
|
||||
):
|
||||
body = str(
|
||||
runs_page(
|
||||
running_executions=(
|
||||
{
|
||||
"source": "Running source",
|
||||
"slug": "running-source",
|
||||
"job_id": 1,
|
||||
"execution_id": 11,
|
||||
"started_at": "2026-03-30 12:00 UTC",
|
||||
"runtime": "running for 10s",
|
||||
"status": "Running",
|
||||
"stats": "1 requests • 1 items • 1 byte",
|
||||
"worker": "streaming stats from worker",
|
||||
"log_href": "/job/1/execution/11/logs",
|
||||
"cancel_label": "Stop",
|
||||
"cancel_post_path": "/actions/executions/11/cancel",
|
||||
},
|
||||
),
|
||||
queued_executions=(
|
||||
{
|
||||
"source": "Queued source",
|
||||
"slug": "queued-source",
|
||||
"job_id": 2,
|
||||
"execution_id": 22,
|
||||
"queued_at": "2 minutes ago",
|
||||
"queued_at_iso": "2026-03-30T12:28:00+00:00",
|
||||
"queue_position": 1,
|
||||
"status": "Queued",
|
||||
"status_tone": "idle",
|
||||
"run_label": "Queued",
|
||||
"run_disabled": True,
|
||||
"run_post_path": "/actions/jobs/2/run-now",
|
||||
"cancel_post_path": "/actions/queued-executions/22/cancel",
|
||||
},
|
||||
),
|
||||
upcoming_jobs=(
|
||||
{
|
||||
"source": "Scheduled source",
|
||||
"slug": "scheduled-source",
|
||||
"job_id": 3,
|
||||
"next_run": "in 5 minutes",
|
||||
"next_run_at": "2026-03-30T12:35:00+00:00",
|
||||
"schedule": "*/5 * * * *",
|
||||
"enabled_label": "Enabled",
|
||||
"enabled_tone": "scheduled",
|
||||
"run_disabled": False,
|
||||
"run_reason": "Ready",
|
||||
"toggle_label": "Disable",
|
||||
"toggle_post_path": "/actions/jobs/3/toggle-enabled",
|
||||
"run_post_path": "/actions/jobs/3/run-now",
|
||||
"delete_post_path": "/actions/jobs/3/delete",
|
||||
},
|
||||
),
|
||||
completed_executions=(
|
||||
{
|
||||
"source": "Completed source",
|
||||
"slug": "completed-source",
|
||||
"job_id": 4,
|
||||
"execution_id": 44,
|
||||
"ended_at": "2 minutes ago",
|
||||
"ended_at_iso": "2026-03-30T12:28:00+00:00",
|
||||
"status": "Succeeded",
|
||||
"status_tone": "done",
|
||||
"stats": "1 requests • 1 items • 1 byte",
|
||||
"summary": "Worker exited successfully",
|
||||
"log_href": "/job/4/execution/44/logs",
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert ">Stop<" in body
|
||||
assert ">Cancel<" in body
|
||||
assert ">Run now<" in body
|
||||
assert ">Disable<" in body
|
||||
assert "/job/4/execution/44/logs" in body
|
||||
|
||||
|
||||
def test_cancel_queued_execution_action_deletes_pending_row_without_touching_running_execution(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
|
@ -1327,6 +1477,24 @@ def test_toggle_job_enabled_action_removes_queued_execution(
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_render_create_source_uses_shared_submit_button(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
db_path = tmp_path / "create-source-shared-submit.db"
|
||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||
|
||||
async def run() -> None:
|
||||
app = create_app()
|
||||
body = str(await render_create_source(app))
|
||||
|
||||
assert 'type="submit"' in body
|
||||
assert "Create source" in body
|
||||
assert "cursor-pointer" in body
|
||||
assert "bg-slate-950" in body
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_render_execution_logs_uses_app_route(monkeypatch, tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "logs-render.db"
|
||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue