from __future__ import annotations from collections.abc import Mapping import htpy as h from htpy import Node, Renderable from repub.components import ( action_button, header_action_link, inline_link, input_field, muted_action_link, page_shell, section_card, select_field, status_badge, table_section, textarea_field, toggle_field, ) 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", ) def _value(source: Mapping[str, object] | None, key: str, default: str = "") -> str: if source is None: return default return str(source.get(key, default)) def _checked(source: Mapping[str, object] | None, key: str, default: bool) -> bool: if source is None: return default value = source.get(key, default) return bool(value) def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ 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"])], ], h.p(class_="font-medium whitespace-nowrap text-slate-900")[ str(source["source_type"]) ], h.p(class_="max-w-sm truncate font-mono text-xs text-slate-600")[ str(source["upstream"]) ], h.p(class_="font-medium whitespace-nowrap text-slate-900")[ str(source["schedule"]) ], h.div(class_="min-w-32 whitespace-normal")[ 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"])], ], 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( label="Delete", tone="danger", post_path=f"/actions/sources/{source['slug']}/delete", ), ], ) def sources_table( *, sources: tuple[Mapping[str, object], ...] | None = None ) -> Renderable: rows = tuple(_source_row(source) for source in (sources or ())) return table_section( eyebrow="Inventory", title="Sources", empty_message="No sources yet.", headers=("Source", "Type", "Upstream", "Schedule", "Job state", "Actions"), rows=rows, actions=header_action_link(href="/sources/create", label="Create source"), ) def sources_page( *, sources: tuple[Mapping[str, object], ...] | None = None, running_count: int = 0, ) -> Renderable: source_items = sources or () return page_shell( current_path="/sources", eyebrow="Source management", title="Sources", source_count=len(source_items), running_count=running_count, content=sources_table(sources=source_items), ) def source_form( *, mode: str, action_path: str, source: Mapping[str, object] | None = None, ) -> Renderable: source_type = _value(source, "source_type", "pangea") slug = _value(source, "slug") title = "Source and job setup" if mode == "create" else "Edit source" eyebrow = "Create" if mode == "create" else "Edit" status_label = "New source" if mode == "create" else "Existing source" submit_label = "Create source" if mode == "create" else "Save changes" initial_signals = "{sourceType: 'pangea'}" if mode == "edit": initial_signals = f"{{sourceType: '{source_type}', sourceSlug: '{slug}'}}" 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" )[eyebrow], h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[title], ], status_badge(label=status_label, tone="scheduled"), ], h.form( { "data-signals": "{_formError: '', _formSuccess: ''}", "data-signals__ifmissing": initial_signals, "data-on:submit": f"@post('{action_path}')", }, class_="mt-5 space-y-6", )[ 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", ), h.div(class_="grid gap-4 md:grid-cols-2")[ input_field( label="Source name", field_id="source-name", value=_value(source, "name"), signal_name="sourceName", ), input_field( label="Slug", field_id="source-slug", value=slug, help_text="Immutable after creation.", signal_name="sourceSlug", disabled=mode == "edit", ), 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", selected=source_type == "feed")[ "feed" ], h.option(value="pangea", selected=source_type == "pangea")[ "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.div(class_="grid gap-4 md:grid-cols-2")[ input_field( label="Feed URL", field_id="feed-url", value=_value(source, "feed_url"), placeholder="https://example.com/feed.xml", signal_name="feedUrl", ), ], ], 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.div(class_="grid gap-4 lg:grid-cols-3")[ input_field( label="Pangea domain", field_id="pangea-domain", value=_value(source, "pangea_domain"), signal_name="pangeaDomain", ), input_field( label="Category name", field_id="pangea-category", value=_value(source, "pangea_category"), signal_name="pangeaCategory", ), select_field( label="Content format", field_id="content-format", options=PANGEA_CONTENT_FORMATS, selected=_value(source, "content_format", "MOBILE_3"), signal_name="contentFormat", ), select_field( label="Content type", field_id="content-type", options=PANGEA_CONTENT_TYPES, selected=_value(source, "content_type", "articles"), signal_name="contentType", ), input_field( label="Max articles", field_id="max-articles", value=_value(source, "max_articles", "10"), signal_name="maxArticles", ), input_field( label="Oldest article (days)", field_id="oldest-article", value=_value(source, "oldest_article", "3"), 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=_checked(source, "only_newest", True), ), toggle_field( label="Include authors", description="Carry author bylines into mirrored output where upstream data exists.", signal_name="includeAuthors", checked=_checked(source, "include_authors", True), ), toggle_field( label="Exclude media", description="Skip image and media attachment mirroring for this source.", signal_name="excludeMedia", checked=_checked(source, "exclude_media", False), ), toggle_field( label="Include content", description="Store article body content in mirrored output when the upstream provides it.", signal_name="includeContent", checked=_checked(source, "include_content", True), ), ], ], h.div(class_="grid gap-4 lg:grid-cols-2")[ textarea_field( label="Notes", field_id="source-notes", value=_value(source, "notes"), signal_name="sourceNotes", ), textarea_field( label="Spider arguments", field_id="spider-arguments", value=_value(source, "spider_arguments"), signal_name="spiderArguments", ), ], 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=_value(source, "cron_minute", "*/30"), signal_name="cronMinute", ), input_field( label="Hour", field_id="cron-hour", value=_value(source, "cron_hour", "*"), signal_name="cronHour", ), input_field( label="Day of month", field_id="cron-day-of-month", value=_value(source, "cron_day_of_month", "*"), signal_name="cronDayOfMonth", ), input_field( label="Day of week", field_id="cron-day-of-week", value=_value(source, "cron_day_of_week", "*"), signal_name="cronDayOfWeek", ), input_field( label="Month", field_id="cron-month", value=_value(source, "cron_month", "*"), signal_name="cronMonth", ), ], ], 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=_checked(source, "enabled", True), ), toggle_field( label="Convert images", description="Normalize mirrored images through the image conversion pipeline for this source.", signal_name="convertImages", checked=_checked(source, "convert_images", True), ), toggle_field( label="Convert video", description="Run mirrored videos through the video conversion pipeline for this source.", signal_name="convertVideo", checked=_checked(source, "convert_video", 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"), action_button( label=submit_label, tone="dark", emphasis="regular", button_type="submit", ), ], ], ) ) def create_source_page( *, action_path: str = "/actions/sources/create", source_count: int = 0, running_count: int = 0, ) -> Renderable: 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", actions=actions, source_count=source_count, running_count=running_count, content=source_form(mode="create", action_path=action_path), ) def edit_source_page( *, slug: str, source: Mapping[str, object], action_path: str, source_count: int = 0, running_count: int = 0, ) -> Renderable: actions = ( muted_action_link(href="/sources", label="Back to sources"), header_action_link(href="/runs", label="View runs"), ) return page_shell( current_path=f"/sources/{slug}/edit", eyebrow="Source editing", title="Edit source", actions=actions, source_count=source_count, running_count=running_count, content=source_form(mode="edit", action_path=action_path, source=source), )