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
)