republisher/repub/pages/sources.py

407 lines
16 KiB
Python
Raw Normal View History

2026-03-30 13:11:37 +02:00
from __future__ import annotations
2026-03-30 13:23:36 +02:00
from collections.abc import Mapping
2026-03-30 13:11:37 +02:00
import htpy as h
from htpy import Node, Renderable
from repub.components import (
header_action_link,
inline_link,
input_field,
muted_action_link,
page_shell,
section_card,
select_field,
status_badge,
table_section,
textarea_field,
toggle_field,
)
2026-03-30 13:23:36 +02:00
PANGEA_CONTENT_FORMATS = (
"WTF_0",
"TEXT_ONLY",
"WTF_1",
"MOBILE_1",
"MOBILE_2",
"MOBILE_3",
"WTF_2",
"XML_TX",
"JSON",
)
PANGEA_CONTENT_TYPES = (
"articles",
"audioclips",
"videoclips",
"breakingnews",
"mostpopular",
"topstories",
)
DEFAULT_SOURCES: tuple[dict[str, str], ...] = (
2026-03-30 13:11:37 +02:00
{
"name": "Guardian feed mirror",
"slug": "guardian-feed",
"source_type": "Feed",
"upstream": "https://guardianproject.info/feed.xml",
"schedule": "Every 30 minutes",
"last_run": "Succeeded 53m ago",
"state": "Enabled",
"state_tone": "scheduled",
},
{
"name": "Pangea mobile articles",
"slug": "pangea-mobile",
"source_type": "Pangea",
"upstream": "guardianproject.info / News",
"schedule": "Every 4 hours",
"last_run": "Running now",
"state": "Enabled",
"state_tone": "running",
},
{
"name": "Podcast enclosure mirror",
"slug": "podcast-audio",
"source_type": "Feed",
"upstream": "https://guardianproject.info/podcast/podcast.xml",
"schedule": "Paused",
"last_run": "Failed 2h ago",
"state": "Disabled",
"state_tone": "idle",
},
)
2026-03-30 13:23:36 +02:00
def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]:
2026-03-30 13:11:37 +02:00
return (
h.div[
2026-03-30 13:23:36 +02:00
h.div(class_="font-semibold text-slate-950")[str(source["name"])],
h.p(class_="mt-1 font-mono text-xs text-slate-500")[str(source["slug"])],
2026-03-30 13:11:37 +02:00
],
h.p(class_="font-medium whitespace-nowrap text-slate-900")[
2026-03-30 13:23:36 +02:00
str(source["source_type"])
2026-03-30 13:11:37 +02:00
],
h.p(class_="max-w-sm truncate font-mono text-xs text-slate-600")[
2026-03-30 13:23:36 +02:00
str(source["upstream"])
],
h.p(class_="font-medium whitespace-nowrap text-slate-900")[
str(source["schedule"])
2026-03-30 13:11:37 +02:00
],
h.div(class_="min-w-32 whitespace-normal")[
2026-03-30 13:23:36 +02:00
status_badge(
label=str(source["state"]),
tone=str(source["state_tone"]),
),
h.p(class_="mt-2 text-xs text-slate-500")[str(source["last_run"])],
2026-03-30 13:11:37 +02:00
],
h.div(class_="flex flex-nowrap items-center gap-3")[
inline_link(href="/sources/create", label="Edit", tone="amber"),
inline_link(href="/runs", label="View runs"),
],
)
2026-03-30 13:23:36 +02:00
def sources_table(
*, sources: tuple[Mapping[str, object], ...] | None = None
) -> Renderable:
rows = tuple(_source_row(source) for source in (sources or DEFAULT_SOURCES))
2026-03-30 13:11:37 +02:00
return table_section(
eyebrow="Inventory",
title="Sources",
subtitle="Configured feed and Pangea sources live here as tables, with clear schedule and job state visibility instead of card-based CRUD.",
headers=("Source", "Type", "Upstream", "Schedule", "Job state", "Actions"),
rows=rows,
actions=header_action_link(href="/sources/create", label="Create source"),
)
2026-03-30 13:23:36 +02:00
def sources_page(
*, sources: tuple[Mapping[str, object], ...] | None = None
) -> Renderable:
2026-03-30 13:11:37 +02:00
return page_shell(
current_path="/sources",
eyebrow="Source management",
title="Sources",
description="Configured feed and Pangea sources live here as tables, with clear schedule and job state visibility instead of card-based CRUD.",
actions=header_action_link(href="/sources/create", label="Create source"),
2026-03-30 13:23:36 +02:00
content=sources_table(sources=sources),
2026-03-30 13:11:37 +02:00
)
2026-03-30 13:23:36 +02:00
def create_source_form(*, action_path: str = "/actions/sources/create") -> Renderable:
2026-03-30 13:11:37 +02:00
return section_card(
content=(
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"
)["Create"],
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 create flow lives on its own page and creates the source plus its paired job record. This pass is visual only, but the fields already reflect the intended shape."
],
],
status_badge(label="New source", tone="scheduled"),
],
h.form(
2026-03-30 13:23:36 +02:00
{
"data-signals": "{_formError: '', _formSuccess: ''}",
"data-signals__ifmissing": "{sourceType: 'pangea'}",
"data-on:submit": f"@post('{action_path}')",
},
2026-03-30 13:11:37 +02:00
class_="mt-5 space-y-6",
)[
2026-03-30 13:23:36 +02:00
h.div(
{
"data-show": "$_formError !== ''",
"data-text": "$_formError",
},
class_="rounded-2xl bg-rose-50 px-4 py-3 text-sm font-medium text-rose-800",
),
h.div(
{
"data-show": "$_formSuccess !== ''",
"data-text": "$_formSuccess",
},
class_="rounded-2xl bg-emerald-100 px-4 py-3 text-sm font-medium text-emerald-800",
),
2026-03-30 13:11:37 +02:00
h.div(class_="grid gap-4 md:grid-cols-2")[
input_field(
label="Source name",
field_id="source-name",
value="Pangea mobile articles",
2026-03-30 13:23:36 +02:00
signal_name="sourceName",
2026-03-30 13:11:37 +02:00
),
input_field(
label="Slug",
field_id="source-slug",
value="pangea-mobile",
help_text="Immutable after creation.",
2026-03-30 13:23:36 +02:00
signal_name="sourceSlug",
2026-03-30 13:11:37 +02:00
),
h.div[
h.label(
for_="source-type",
class_="block text-sm font-medium text-slate-900",
)["Source type"],
h.select(
{"data-bind": "sourceType"},
id="source-type",
name="source-type",
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="feed")["feed"],
h.option(value="pangea", selected=True)["pangea"],
],
],
],
h.div(
{"data-show": "$sourceType === 'feed'"},
class_="space-y-4 rounded-[1.5rem] bg-stone-50 p-5",
)[
h.div[
h.p(
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
)["Feed source options"],
h.h3(class_="mt-2 text-lg font-semibold text-slate-950")[
"Feed settings"
],
h.p(class_="mt-2 text-sm text-slate-600")[
"Shown only when the source type is set to feed."
],
],
h.div(class_="grid gap-4 md:grid-cols-2")[
input_field(
label="Feed URL",
field_id="feed-url",
placeholder="https://example.com/feed.xml",
2026-03-30 13:23:36 +02:00
signal_name="feedUrl",
2026-03-30 13:11:37 +02:00
),
],
],
h.div(
{"data-show": "$sourceType === 'pangea'"},
class_="space-y-4 rounded-[1.5rem] bg-stone-50 p-5",
)[
h.div[
h.p(
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
)["Pangea source options"],
h.h3(class_="mt-2 text-lg font-semibold text-slate-950")[
"Pangea settings"
],
h.p(class_="mt-2 text-sm text-slate-600")[
"Shown only when the source type is set to pangea."
],
],
h.div(class_="grid gap-4 lg:grid-cols-3")[
input_field(
label="Pangea domain",
field_id="pangea-domain",
value="guardianproject.info",
2026-03-30 13:23:36 +02:00
signal_name="pangeaDomain",
2026-03-30 13:11:37 +02:00
),
input_field(
label="Category name",
field_id="pangea-category",
value="News",
2026-03-30 13:23:36 +02:00
signal_name="pangeaCategory",
2026-03-30 13:11:37 +02:00
),
select_field(
label="Content format",
field_id="content-format",
2026-03-30 13:23:36 +02:00
options=PANGEA_CONTENT_FORMATS,
2026-03-30 13:11:37 +02:00
selected="MOBILE_3",
2026-03-30 13:23:36 +02:00
signal_name="contentFormat",
2026-03-30 13:11:37 +02:00
),
2026-03-30 13:23:36 +02:00
select_field(
2026-03-30 13:11:37 +02:00
label="Content type",
field_id="content-type",
2026-03-30 13:23:36 +02:00
options=PANGEA_CONTENT_TYPES,
selected="articles",
signal_name="contentType",
2026-03-30 13:11:37 +02:00
),
input_field(
label="Max articles",
field_id="max-articles",
value="10",
2026-03-30 13:23:36 +02:00
signal_name="maxArticles",
2026-03-30 13:11:37 +02:00
),
input_field(
label="Oldest article (days)",
field_id="oldest-article",
value="3",
2026-03-30 13:23:36 +02:00
signal_name="oldestArticle",
),
],
h.div(class_="grid gap-4 lg:grid-cols-3")[
toggle_field(
label="Only newest",
description="Limit Pangea syncs to the newest material available in the selected category.",
signal_name="onlyNewest",
checked=True,
),
toggle_field(
label="Include authors",
description="Carry author bylines into mirrored output where upstream data exists.",
signal_name="includeAuthors",
checked=True,
),
toggle_field(
label="Exclude media",
description="Skip image and media attachment mirroring for this source.",
signal_name="excludeMedia",
checked=False,
2026-03-30 13:11:37 +02:00
),
],
],
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.",
2026-03-30 13:23:36 +02:00
signal_name="sourceNotes",
2026-03-30 13:11:37 +02:00
),
textarea_field(
label="Spider arguments",
field_id="spider-arguments",
value="language=en,download_media=true",
2026-03-30 13:23:36 +02:00
signal_name="spiderArguments",
2026-03-30 13:11:37 +02:00
),
],
h.div(
class_="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(20rem,0.9fr)]"
)[
h.div(class_="rounded-[1.5rem] bg-stone-50 p-5")[
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_="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-5")[
input_field(
label="Minute",
field_id="cron-minute",
value="15",
2026-03-30 13:23:36 +02:00
signal_name="cronMinute",
2026-03-30 13:11:37 +02:00
),
input_field(
label="Hour",
field_id="cron-hour",
value="*/4",
2026-03-30 13:23:36 +02:00
signal_name="cronHour",
2026-03-30 13:11:37 +02:00
),
input_field(
label="Day of month",
field_id="cron-day-of-month",
value="*",
2026-03-30 13:23:36 +02:00
signal_name="cronDayOfMonth",
2026-03-30 13:11:37 +02:00
),
input_field(
label="Day of week",
field_id="cron-day-of-week",
value="1-6",
2026-03-30 13:23:36 +02:00
signal_name="cronDayOfWeek",
2026-03-30 13:11:37 +02:00
),
input_field(
label="Month",
field_id="cron-month",
value="*",
2026-03-30 13:23:36 +02:00
signal_name="cronMonth",
2026-03-30 13:11:37 +02:00
),
],
],
h.div(class_="rounded-[1.5rem] bg-stone-50 p-5")[
h.p(
class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600"
)["Job defaults"],
h.h3(class_="mt-2 text-lg font-semibold text-slate-950")[
"Initial job state"
],
h.div(class_="mt-5 grid gap-4")[
toggle_field(
label="Job enabled",
description="Scheduler will consider the new job immediately after creation.",
signal_name="jobEnabled",
checked=True,
),
],
],
],
h.div(
class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 pt-6"
)[
muted_action_link(href="/sources", label="Cancel"),
h.button(
2026-03-30 13:23:36 +02:00
type="submit",
2026-03-30 13:11:37 +02:00
class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800",
)["Create source"],
],
],
)
)
2026-03-30 13:23:36 +02:00
def create_source_page(*, action_path: str = "/actions/sources/create") -> Renderable:
2026-03-30 13:11:37 +02:00
actions = (
muted_action_link(href="/sources", label="Back to sources"),
header_action_link(href="/runs", label="View runs"),
)
return page_shell(
current_path="/sources/create",
eyebrow="Source creation",
title="Create source",
description="Dedicated create page for the source form. The list page stays focused on scanning existing sources, while this page handles the new source and job configuration flow.",
actions=actions,
2026-03-30 13:23:36 +02:00
content=create_source_form(action_path=action_path),
2026-03-30 13:11:37 +02:00
)