From 9ce576e7e89d4640b78587039df3657505f32296 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 12:13:04 +0200 Subject: [PATCH] with htpy and css --- flake.nix | 5 +- pyproject.toml | 9 + repub/components.py | 154 ++++ repub/pages/__init__.py | 1 + repub/pages/dashboard.py | 598 ++++++++++++++ repub/static/app.css | 1430 +++++++++++++++++++++++++++++++++ repub/static/app.tailwind.css | 3 + repub/web.py | 20 +- uv.lock | 14 + 9 files changed, 2217 insertions(+), 17 deletions(-) create mode 100644 repub/components.py create mode 100644 repub/pages/__init__.py create mode 100644 repub/pages/dashboard.py create mode 100644 repub/static/app.css create mode 100644 repub/static/app.tailwind.css diff --git a/flake.nix b/flake.nix index 2618716..2d4cda9 100644 --- a/flake.nix +++ b/flake.nix @@ -239,7 +239,10 @@ inherit src; dontConfigure = true; dontBuild = true; - nativeBuildInputs = [ testVenv ]; + nativeBuildInputs = [ + pkgs.pyright + testVenv + ]; checkPhase = '' runHook preCheck pyright diff --git a/pyproject.toml b/pyproject.toml index 474e97b..425ad43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "aiosqlite>=0.21.0,<0.22.0", "datastar-py>=0.8.0,<0.9.0", "greenlet>=3.2.4,<4.0.0", + "htpy>=25.12.0,<26.0.0", "peewee>=3.19.0,<4.0.0", "pygea @ git+https://guardianproject.dev/anynews/pygea.git", ] @@ -65,6 +66,14 @@ max-line-length = "88" [tool.pyright] include = ["repub", "tests"] +exclude = [ + "repub/crawl.py", + "repub/exporters.py", + "repub/media.py", + "repub/rss.py", + "repub/spiders", + "repub/srcset.py", +] pythonVersion = "3.13" typeCheckingMode = "basic" reportMissingImports = false diff --git a/repub/components.py b/repub/components.py new file mode 100644 index 0000000..4a6630c --- /dev/null +++ b/repub/components.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import htpy as h +from htpy import Node, Renderable + + +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, 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="#", class_=link_class)[ + h.span[label], + badge and h.span(class_=badge_class)[badge], + ] + + +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, +) -> Renderable: + return h.div[ + h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[ + label + ], + h.input( + id=field_id, + name=field_id, + type="text", + value=value, + placeholder=placeholder, + 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", + ), + 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, +) -> Renderable: + return h.div[ + h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[ + label + ], + h.select( + 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" +) -> Renderable: + return h.div[ + h.label(for_=field_id, class_="block text-sm font-medium text-slate-900")[ + label + ], + h.textarea( + 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, checked: bool = False) -> Renderable: + wrapper_class = ( + "group relative inline-flex w-11 shrink-0 rounded-full p-0.5 outline-offset-2 outline-amber-500 transition " + + ("bg-amber-500" if checked else "bg-slate-200") + ) + knob_class = ( + "size-5 rounded-full bg-white shadow-xs ring-1 ring-slate-900/5 transition-transform " + + ("translate-x-5" if checked else "translate-x-0") + ) + + return h.div(class_="rounded-3xl bg-stone-50 p-4 ring-1 ring-slate-200")[ + 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(class_=wrapper_class)[ + h.span(class_=knob_class), + h.input(type="checkbox", 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-amber-100 text-amber-800", + } + return h.span( + class_=f"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold {tones[tone]}" + )[label] diff --git a/repub/pages/__init__.py b/repub/pages/__init__.py new file mode 100644 index 0000000..5490d15 --- /dev/null +++ b/repub/pages/__init__.py @@ -0,0 +1 @@ +from repub.pages.dashboard import admin_page diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py new file mode 100644 index 0000000..a17bef7 --- /dev/null +++ b/repub/pages/dashboard.py @@ -0,0 +1,598 @@ +from __future__ import annotations + +import htpy as h +from htpy import Node, Renderable + +from repub.components import ( + base_layout, + input_field, + nav_link, + select_field, + stat_card, + status_badge, + textarea_field, + toggle_field, +) + + +def sidebar() -> Renderable: + return h.aside( + class_="relative overflow-hidden bg-slate-950 px-6 py-8 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 size-11 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.p(class_="text-sm text-slate-300")["Admin spike"], + ], + ], + h.nav(class_="mt-10 space-y-2")[ + nav_link(label="Dashboard", active=True, badge="Live"), + nav_link(label="Sources", badge="12"), + nav_link(label="Runs", badge="3"), + nav_link(label="Schedule"), + nav_link(label="Settings"), + ], + h.div(class_="mt-10 rounded-3xl border border-white/10 bg-white/5 p-5")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300" + )["Operator notes"], + h.p(class_="mt-3 text-sm leading-6 text-slate-300")[ + "Single-operator control plane over Tailscale. Everything here is static HTML for the v1 UI spike." + ], + ], + h.div(class_="mt-auto rounded-3xl bg-white/5 p-5 ring-1 ring-white/10")[ + h.p(class_="text-sm font-semibold text-white")["Output root"], + h.p(class_="mt-2 font-mono text-sm text-slate-300")["/srv/anynews/out"], + h.p(class_="mt-4 text-xs uppercase tracking-[0.22em] text-slate-400")[ + "Trusted network only" + ], + ], + ], + ] + + +def page_header() -> Renderable: + return h.section( + class_="rounded-[2rem] bg-slate-950 px-6 py-8 text-white shadow-xl sm:px-8" + )[ + h.div(class_="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between")[ + h.div(class_="max-w-3xl")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.3em] text-amber-300" + )["Republisher Redux"], + h.h1(class_="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl")[ + "Admin UI" + ], + h.p( + class_="mt-4 max-w-2xl text-sm leading-6 text-slate-300 sm:text-base" + )[ + "One page for source management, job scheduling, live execution visibility, and operator settings. This pass is HTML and CSS only." + ], + ], + h.div(class_="flex flex-wrap gap-3")[ + h.button( + type="button", + class_="rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-sm hover:bg-amber-300", + )["Add source"], + h.button( + type="button", + class_="rounded-full border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-semibold text-white hover:bg-white/10", + )["Run scheduler health check"], + ], + ] + ] + + +def overview_section() -> Renderable: + return h.section[ + h.div(class_="mb-4 flex items-end justify-between")[ + h.div[ + h.p( + class_="text-sm font-semibold uppercase tracking-[0.22em] text-slate-500" + )["Overview"], + h.h2( + class_="mt-1 text-2xl font-semibold tracking-tight text-slate-950" + )["Operational snapshot"], + ], + h.p(class_="text-sm text-slate-500")["Updated from static fixture data"], + ], + h.dl(class_="grid gap-4 md:grid-cols-2 xl:grid-cols-4")[ + stat_card(label="Active jobs", value="12", detail="9 scheduled, 3 paused"), + stat_card(label="Running now", value="2", detail="RSS and Pangea workers"), + stat_card( + label="Completed today", value="34", detail="31 succeeded, 3 failed" + ), + stat_card( + label="Output size", value="18.4 GB", detail="Media and rewritten feeds" + ), + ], + ] + + +def source_form_section() -> Renderable: + return h.section( + class_="rounded-[2rem] bg-white/90 shadow-sm ring-1 ring-slate-200" + )[ + h.div(class_="border-b border-slate-200 px-6 py-5 sm:px-8")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )["Create or edit"], + 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 form shows the intended v1 structure: source fields, subtype fields, cron controls, and job toggles. No persistence is wired yet." + ], + ], + h.div(class_="space-y-8 px-6 py-6 sm:px-8")[ + h.div(class_="grid gap-4 md:grid-cols-2")[ + input_field( + label="Source name", + field_id="source-name", + value="Pangea mobile articles", + ), + input_field( + label="Slug", + field_id="source-slug", + value="pangea-mobile", + help_text="Immutable after creation.", + ), + select_field( + label="Source type", + field_id="source-type", + options=("feed", "pangea"), + selected="pangea", + ), + input_field( + label="Feed URL", + field_id="feed-url", + placeholder="https://example.com/feed.xml", + ), + ], + h.div(class_="grid gap-4 lg:grid-cols-3")[ + input_field( + label="Pangea domain", + field_id="pangea-domain", + value="guardianproject.info", + ), + input_field( + label="Category name", field_id="pangea-category", value="News" + ), + select_field( + label="Content format", + field_id="content-format", + options=("MOBILE_3", "MOBILE_2", "WEB"), + selected="MOBILE_3", + ), + input_field( + label="Content type", field_id="content-type", value="articles" + ), + input_field(label="Max articles", field_id="max-articles", value="10"), + input_field( + label="Oldest article (days)", field_id="oldest-article", value="3" + ), + ], + 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.", + ), + textarea_field( + label="Spider arguments", + field_id="spider-arguments", + value="language=en,download_media=true", + ), + ], + h.div[ + h.div(class_="mb-4 flex items-end justify-between")[ + 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_="grid gap-4 sm:grid-cols-2 xl:grid-cols-5")[ + input_field(label="Minute", field_id="cron-minute", value="15"), + input_field(label="Hour", field_id="cron-hour", value="*/4"), + input_field( + label="Day of month", field_id="cron-day-of-month", value="*" + ), + input_field( + label="Day of week", field_id="cron-day-of-week", value="1-6" + ), + input_field(label="Month", field_id="cron-month", value="*"), + ], + ], + h.div(class_="grid gap-4 xl:grid-cols-2")[ + toggle_field( + label="Job enabled", + description="If disabled, the scheduler keeps the source definition but skips automatic runs.", + checked=True, + ), + toggle_field( + label="Only newest", + description="Restrict Pangea fetches to the newest material available in the selected category.", + checked=True, + ), + toggle_field( + label="Include authors", + description="Carry author bylines into rendered output where the upstream provides them.", + checked=True, + ), + toggle_field( + label="Exclude media", + description="Use article text only and skip image/media attachment mirroring for this source.", + checked=False, + ), + ], + ], + h.div( + class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 px-6 py-4 sm:px-8" + )[ + h.button( + type="button", + class_="rounded-full border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50", + )["Cancel"], + h.button( + type="button", + class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-800", + )["Save source"], + ], + ] + + +def source_card( + *, name: str, slug: str, source_type: str, schedule: str, state: Node +) -> Renderable: + return h.div(class_="rounded-3xl bg-stone-50 p-5 ring-1 ring-slate-200")[ + h.div(class_="flex items-start justify-between gap-4")[ + h.div[ + h.h3(class_="text-base font-semibold text-slate-950")[name], + h.p(class_="mt-1 font-mono text-xs text-slate-500")[slug], + ], + state, + ], + h.dl(class_="mt-4 grid gap-3 sm:grid-cols-2")[ + h.div[ + h.dt( + class_="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500" + )["Type"], + h.dd(class_="mt-1 text-sm text-slate-900")[source_type], + ], + h.div[ + h.dt( + class_="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500" + )["Schedule"], + h.dd(class_="mt-1 text-sm text-slate-900")[schedule], + ], + ], + h.div(class_="mt-5 flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50", + )["Edit"], + h.button( + type="button", + class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50", + )["Run now"], + h.button( + type="button", + class_="rounded-full bg-white px-3 py-2 text-sm font-semibold text-rose-700 ring-1 ring-rose-200 hover:bg-rose-50", + )["Delete"], + ], + ] + + +def configured_sources_section() -> Renderable: + return h.section( + class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8" + )[ + 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" + )["Configured"], + h.h2(class_="mt-2 text-xl font-semibold text-slate-950")["Sources"], + ], + h.p(class_="text-sm text-slate-500")[ + "Static cards for the CRUD list state" + ], + ], + h.div(class_="mt-6 grid gap-4 xl:grid-cols-3")[ + source_card( + name="Guardian feed mirror", + slug="guardian-feed", + source_type="RSS feed", + schedule="Every 30 minutes", + state=status_badge(label="Scheduled", tone="scheduled"), + ), + source_card( + name="Pangea mobile articles", + slug="pangea-mobile", + source_type="Pangea", + schedule="Every 4 hours", + state=status_badge(label="Running", tone="running"), + ), + source_card( + name="Podcast enclosure mirror", + slug="podcast-audio", + source_type="RSS feed", + schedule="Paused", + state=status_badge(label="Idle", tone="idle"), + ), + ], + ] + + +def class_cells(row: tuple[Node, ...]) -> tuple[Renderable, ...]: + return tuple( + h.td(class_="px-4 py-4 align-top text-slate-700")[cell] for cell in row + ) + + +def job_table_section( + *, title: str, subtitle: str, rows: tuple[tuple[Node, ...], ...] +) -> Renderable: + headers = ("Source", "Window", "Status", "Stats", "Actions") + return h.section( + class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8" + )[ + h.div(class_="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between")[ + h.div[ + h.h2(class_="text-xl font-semibold text-slate-950")[title], + h.p(class_="mt-1 text-sm text-slate-600")[subtitle], + ] + ], + h.div(class_="mt-6 overflow-hidden rounded-3xl ring-1 ring-slate-200")[ + h.table(class_="min-w-full divide-y divide-slate-200 text-left text-sm")[ + h.thead(class_="bg-stone-50")[ + h.tr[ + ( + h.th( + class_="px-4 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500" + )[header] + for header in headers + ) + ] + ], + h.tbody(class_="divide-y divide-slate-200 bg-white")[ + (h.tr[class_cells(row)] for row in rows) + ], + ] + ], + ] + + +def log_panel() -> Renderable: + return h.section( + class_="rounded-[2rem] bg-slate-950 p-6 text-white shadow-xl sm:p-8" + )[ + h.div(class_="flex items-end justify-between gap-4")[ + h.div[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300" + )["Live view"], + h.h2(class_="mt-2 text-xl font-semibold")["Execution log"], + ], + status_badge(label="Streaming", tone="running"), + ], + h.pre( + class_="mt-5 overflow-x-auto rounded-3xl bg-black/30 p-4 text-xs leading-6 text-emerald-200 ring-1 ring-white/10" + )[ + "\n".join( + [ + "11:42:01 scheduler: run_now requested for job 7", + "11:42:02 worker[7]: starting pangea-mobile", + "11:42:08 stats: requests=18 items=4 bytes=1.8MB", + "11:42:11 stats: requests=26 items=7 bytes=2.6MB", + "11:42:17 worker[7]: writing out/logs/exec-0007.log", + "11:42:24 worker[7]: finished successfully", + ] + ) + ], + h.div(class_="mt-5 flex flex-wrap gap-3")[ + h.button( + type="button", + class_="rounded-full bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/10 hover:bg-white/15", + )["Open full log"], + h.button( + type="button", + class_="rounded-full bg-rose-500/15 px-4 py-2.5 text-sm font-semibold text-rose-200 ring-1 ring-rose-400/20 hover:bg-rose-500/20", + )["Stop job"], + ], + ] + + +def settings_panel() -> Renderable: + return h.section( + class_="rounded-[2rem] bg-white/90 p-6 shadow-sm ring-1 ring-slate-200 sm:p-8" + )[ + h.p(class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600")[ + "Global" + ], + h.h2(class_="mt-2 text-xl font-semibold text-slate-950")[ + "Application settings" + ], + h.div(class_="mt-6 grid gap-4")[ + input_field(label="Bind host", field_id="bind-host", value="127.0.0.1"), + input_field(label="Bind port", field_id="bind-port", value="8080"), + input_field( + label="Output root", field_id="output-root", value="/srv/anynews/out" + ), + input_field( + label="Log directory", + field_id="log-directory", + value="/srv/anynews/out/logs", + ), + ], + h.div(class_="mt-6 flex flex-wrap gap-3")[ + h.button( + type="button", + class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white hover:bg-slate-800", + )["Save settings"], + h.button( + type="button", + class_="rounded-full border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50", + )["Reload schedule preview"], + ], + ] + + +def admin_page(*, stylesheet_href: str) -> Renderable: + running_rows = ( + ( + h.div(class_="font-semibold text-slate-950")["Pangea mobile articles"], + h.span["Started 11:42"], + status_badge(label="Running", tone="running"), + h.div["26 requests • 7 items • 2.6 MB"], + h.div(class_="flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["View log"], + h.button( + type="button", + class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100", + )["Stop"], + ], + ), + ( + h.div(class_="font-semibold text-slate-950")["Guardian feed mirror"], + h.span["Started 11:33"], + status_badge(label="Running", tone="running"), + h.div["91 requests • 13 items • 5.1 MB"], + h.div(class_="flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["View log"], + h.button( + type="button", + class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100", + )["Stop"], + ], + ), + ) + upcoming_rows = ( + ( + h.div(class_="font-semibold text-slate-950")["Podcast enclosure mirror"], + h.span["Today, 12:15"], + status_badge(label="Scheduled", tone="scheduled"), + h.div["cron: 15 */4 * * 1-6"], + h.div(class_="flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["Run now"], + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["Disable"], + h.button( + type="button", + class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100", + )["Delete"], + ], + ), + ( + h.div(class_="font-semibold text-slate-950")["Weekly digest feed"], + h.span["Tomorrow, 08:00"], + status_badge(label="Idle", tone="idle"), + h.div["cron: 0 8 * * 1"], + h.div(class_="flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["Run now"], + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["Enable"], + h.button( + type="button", + class_="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100", + )["Delete"], + ], + ), + ) + completed_rows = ( + ( + h.div(class_="font-semibold text-slate-950")["Guardian feed mirror"], + h.span["Ended 10:57"], + status_badge(label="Succeeded", tone="done"), + h.div["204 requests • 28 items • 9.4 MB"], + h.div(class_="flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["View log"], + ], + ), + ( + h.div(class_="font-semibold text-slate-950")["Podcast enclosure mirror"], + h.span["Ended 09:12"], + status_badge(label="Failed", tone="failed"), + h.div["timeout after 3 retries"], + h.div(class_="flex flex-wrap gap-2")[ + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["View log"], + h.button( + type="button", + class_="rounded-full bg-stone-100 px-3 py-1.5 font-semibold text-slate-700 hover:bg-stone-200", + )["Retry"], + ], + ), + ) + + return base_layout( + page_title="Republisher Admin UI", + stylesheet_href=stylesheet_href, + content=h.div(class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]")[ + sidebar(), + h.main(class_="px-4 py-5 sm:px-6 lg:px-8 lg:py-8")[ + h.div(class_="mx-auto max-w-7xl space-y-6")[ + page_header(), + overview_section(), + h.div( + class_="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.95fr)]" + )[ + h.div(class_="space-y-6")[ + source_form_section(), + configured_sources_section(), + job_table_section( + title="Running executions", + subtitle="Operators can inspect active crawls and stop them if needed.", + rows=running_rows, + ), + job_table_section( + title="Upcoming jobs", + subtitle="Schedule preview with enable, disable, run now, and delete affordances.", + rows=upcoming_rows, + ), + job_table_section( + title="Completed executions", + subtitle="Recent history with direct access to text logs.", + rows=completed_rows, + ), + ], + h.div(class_="space-y-6")[log_panel(), settings_panel()], + ], + ] + ], + ], + ) diff --git a/repub/static/app.css b/repub/static/app.css new file mode 100644 index 0000000..672a945 --- /dev/null +++ b/repub/static/app.css @@ -0,0 +1,1430 @@ +/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + --color-orange-100: oklch(95.4% 0.038 75.164); + --color-amber-50: oklch(98.7% 0.022 95.277); + --color-amber-100: oklch(96.2% 0.059 95.617); + --color-amber-200: oklch(92.4% 0.12 95.746); + --color-amber-300: oklch(87.9% 0.169 91.605); + --color-amber-400: oklch(82.8% 0.189 84.429); + --color-amber-500: oklch(76.9% 0.188 70.08); + --color-amber-600: oklch(66.6% 0.179 58.318); + --color-amber-800: oklch(47.3% 0.137 46.201); + --color-amber-950: oklch(27.9% 0.077 45.635); + --color-emerald-100: oklch(95% 0.052 163.051); + --color-emerald-200: oklch(90.5% 0.093 164.15); + --color-emerald-800: oklch(43.2% 0.095 166.913); + --color-sky-100: oklch(95.1% 0.026 236.824); + --color-sky-800: oklch(44.3% 0.11 240.79); + --color-rose-50: oklch(96.9% 0.015 12.422); + --color-rose-100: oklch(94.1% 0.03 12.58); + --color-rose-200: oklch(89.2% 0.058 10.001); + --color-rose-400: oklch(71.2% 0.194 13.428); + --color-rose-500: oklch(64.5% 0.246 16.439); + --color-rose-700: oklch(51.4% 0.222 16.935); + --color-rose-800: oklch(45.5% 0.188 13.697); + --color-slate-50: oklch(98.4% 0.003 247.858); + --color-slate-100: oklch(96.8% 0.007 247.896); + --color-slate-200: oklch(92.9% 0.013 255.508); + --color-slate-300: oklch(86.9% 0.022 252.894); + --color-slate-400: oklch(70.4% 0.04 256.788); + --color-slate-500: oklch(55.4% 0.046 257.417); + --color-slate-600: oklch(44.6% 0.043 257.281); + --color-slate-700: oklch(37.2% 0.044 257.287); + --color-slate-800: oklch(27.9% 0.041 260.031); + --color-slate-900: oklch(20.8% 0.042 265.755); + --color-slate-950: oklch(12.9% 0.042 264.695); + --color-stone-50: oklch(98.5% 0.001 106.423); + --color-stone-100: oklch(97% 0.001 106.424); + --color-stone-200: oklch(92.3% 0.003 48.717); + --color-black: #000; + --color-white: #fff; + --spacing: 0.25rem; + --container-2xl: 42rem; + --container-3xl: 48rem; + --container-7xl: 80rem; + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-black: 900; + --tracking-tight: -0.025em; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --radius-3xl: 1.5rem; + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden='until-found'])) { + display: none !important; + } +} +@layer utilities { + .visible { + visibility: visible; + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + } + .absolute { + position: absolute; + } + .fixed { + position: fixed; + } + .relative { + position: relative; + } + .static { + position: static; + } + .inset-x-0 { + inset-inline: calc(var(--spacing) * 0); + } + .start { + inset-inline-start: var(--spacing); + } + .top-0 { + top: calc(var(--spacing) * 0); + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } + .mx-auto { + margin-inline: auto; + } + .mt-0\.5 { + margin-top: calc(var(--spacing) * 0.5); + } + .mt-1 { + margin-top: calc(var(--spacing) * 1); + } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + .mt-3 { + margin-top: calc(var(--spacing) * 3); + } + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + .mt-5 { + margin-top: calc(var(--spacing) * 5); + } + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } + .mt-10 { + margin-top: calc(var(--spacing) * 10); + } + .mt-auto { + margin-top: auto; + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .block { + display: block; + } + .contents { + display: contents; + } + .flex { + display: flex; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .inline-flex { + display: inline-flex; + } + .table { + display: table; + } + .size-5 { + width: calc(var(--spacing) * 5); + height: calc(var(--spacing) * 5); + } + .size-11 { + width: calc(var(--spacing) * 11); + height: calc(var(--spacing) * 11); + } + .h-40 { + height: calc(var(--spacing) * 40); + } + .h-full { + height: 100%; + } + .min-h-screen { + min-height: 100vh; + } + .w-11 { + width: calc(var(--spacing) * 11); + } + .w-full { + width: 100%; + } + .max-w-2xl { + max-width: var(--container-2xl); + } + .max-w-3xl { + max-width: var(--container-3xl); + } + .max-w-7xl { + max-width: var(--container-7xl); + } + .min-w-full { + min-width: 100%; + } + .shrink-0 { + flex-shrink: 0; + } + .translate-x-0 { + --tw-translate-x: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .translate-x-5 { + --tw-translate-x: calc(var(--spacing) * 5); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .cursor-pointer { + cursor: pointer; + } + .flex-col { + flex-direction: column; + } + .flex-wrap { + flex-wrap: wrap; + } + .items-center { + align-items: center; + } + .items-end { + align-items: flex-end; + } + .items-start { + align-items: flex-start; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .justify-end { + justify-content: flex-end; + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-3 { + gap: calc(var(--spacing) * 3); + } + .gap-4 { + gap: calc(var(--spacing) * 4); + } + .gap-6 { + gap: calc(var(--spacing) * 6); + } + .space-y-2 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-6 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-8 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); + } + } + .divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } + } + .divide-slate-200 { + :where(& > :not(:last-child)) { + border-color: var(--color-slate-200); + } + } + .overflow-hidden { + overflow: hidden; + } + .overflow-x-auto { + overflow-x: auto; + } + .rounded-2xl { + border-radius: var(--radius-2xl); + } + .rounded-3xl { + border-radius: var(--radius-3xl); + } + .rounded-\[2rem\] { + border-radius: 2rem; + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .rounded-xl { + border-radius: var(--radius-xl); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-0 { + border-style: var(--tw-border-style); + border-width: 0px; + } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-slate-200 { + border-color: var(--color-slate-200); + } + .border-white\/10 { + border-color: color-mix(in srgb, #fff 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + } + .border-white\/15 { + border-color: color-mix(in srgb, #fff 15%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-white) 15%, transparent); + } + } + .bg-amber-100 { + background-color: var(--color-amber-100); + } + .bg-amber-200 { + background-color: var(--color-amber-200); + } + .bg-amber-400 { + background-color: var(--color-amber-400); + } + .bg-amber-500 { + background-color: var(--color-amber-500); + } + .bg-black\/30 { + background-color: color-mix(in srgb, #000 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 30%, transparent); + } + } + .bg-emerald-100 { + background-color: var(--color-emerald-100); + } + .bg-rose-50 { + background-color: var(--color-rose-50); + } + .bg-rose-100 { + background-color: var(--color-rose-100); + } + .bg-rose-500\/15 { + background-color: color-mix(in srgb, oklch(64.5% 0.246 16.439) 15%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-rose-500) 15%, transparent); + } + } + .bg-sky-100 { + background-color: var(--color-sky-100); + } + .bg-slate-100 { + background-color: var(--color-slate-100); + } + .bg-slate-200 { + background-color: var(--color-slate-200); + } + .bg-slate-800 { + background-color: var(--color-slate-800); + } + .bg-slate-950 { + background-color: var(--color-slate-950); + } + .bg-stone-50 { + background-color: var(--color-stone-50); + } + .bg-stone-100 { + background-color: var(--color-stone-100); + } + .bg-white { + background-color: var(--color-white); + } + .bg-white\/5 { + background-color: color-mix(in srgb, #fff 5%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 5%, transparent); + } + } + .bg-white\/10 { + background-color: color-mix(in srgb, #fff 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + } + .bg-white\/85 { + background-color: color-mix(in srgb, #fff 85%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 85%, transparent); + } + } + .bg-white\/90 { + background-color: color-mix(in srgb, #fff 90%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 90%, transparent); + } + } + .bg-linear-to-br { + --tw-gradient-position: to bottom right; + @supports (background-image: linear-gradient(in lab, red, red)) { + --tw-gradient-position: to bottom right in oklab; + } + background-image: linear-gradient(var(--tw-gradient-stops)); + } + .bg-radial { + --tw-gradient-position: in oklab; + background-image: radial-gradient(var(--tw-gradient-stops)); + } + .from-amber-400\/25 { + --tw-gradient-from: color-mix(in srgb, oklch(82.8% 0.189 84.429) 25%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-gradient-from: color-mix(in oklab, var(--color-amber-400) 25%, transparent); + } + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + .from-stone-100 { + --tw-gradient-from: var(--color-stone-100); + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + .via-amber-50 { + --tw-gradient-via: var(--color-amber-50); + --tw-gradient-via-stops: var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-via-stops); + } + .via-amber-400\/10 { + --tw-gradient-via: color-mix(in srgb, oklch(82.8% 0.189 84.429) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-gradient-via: color-mix(in oklab, var(--color-amber-400) 10%, transparent); + } + --tw-gradient-via-stops: var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-via-stops); + } + .to-orange-100 { + --tw-gradient-to: var(--color-orange-100); + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + .to-transparent { + --tw-gradient-to: transparent; + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + .p-0\.5 { + padding: calc(var(--spacing) * 0.5); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-5 { + padding: calc(var(--spacing) * 5); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .px-2\.5 { + padding-inline: calc(var(--spacing) * 2.5); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-3\.5 { + padding-inline: calc(var(--spacing) * 3.5); + } + .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); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-1\.5 { + padding-block: calc(var(--spacing) * 1.5); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-2\.5 { + padding-block: calc(var(--spacing) * 2.5); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + .py-5 { + padding-block: calc(var(--spacing) * 5); + } + .py-6 { + padding-block: calc(var(--spacing) * 6); + } + .py-8 { + padding-block: calc(var(--spacing) * 8); + } + .text-left { + text-align: left; + } + .align-top { + vertical-align: top; + } + .font-mono { + font-family: var(--font-mono); + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + .text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .text-\[11px\] { + font-size: 11px; + } + .leading-6 { + --tw-leading: calc(var(--spacing) * 6); + line-height: calc(var(--spacing) * 6); + } + .font-black { + --tw-font-weight: var(--font-weight-black); + font-weight: var(--font-weight-black); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .tracking-\[0\.3em\] { + --tw-tracking: 0.3em; + letter-spacing: 0.3em; + } + .tracking-\[0\.18em\] { + --tw-tracking: 0.18em; + letter-spacing: 0.18em; + } + .tracking-\[0\.22em\] { + --tw-tracking: 0.22em; + letter-spacing: 0.22em; + } + .tracking-\[0\.24em\] { + --tw-tracking: 0.24em; + letter-spacing: 0.24em; + } + .tracking-tight { + --tw-tracking: var(--tracking-tight); + letter-spacing: var(--tracking-tight); + } + .text-amber-300 { + color: var(--color-amber-300); + } + .text-amber-600 { + color: var(--color-amber-600); + } + .text-amber-800 { + color: var(--color-amber-800); + } + .text-amber-950 { + color: var(--color-amber-950); + } + .text-emerald-200 { + color: var(--color-emerald-200); + } + .text-emerald-800 { + color: var(--color-emerald-800); + } + .text-rose-200 { + color: var(--color-rose-200); + } + .text-rose-700 { + color: var(--color-rose-700); + } + .text-rose-800 { + color: var(--color-rose-800); + } + .text-sky-800 { + color: var(--color-sky-800); + } + .text-slate-300 { + color: var(--color-slate-300); + } + .text-slate-400 { + color: var(--color-slate-400); + } + .text-slate-500 { + color: var(--color-slate-500); + } + .text-slate-600 { + color: var(--color-slate-600); + } + .text-slate-700 { + color: var(--color-slate-700); + } + .text-slate-900 { + color: var(--color-slate-900); + } + .text-slate-950 { + color: var(--color-slate-950); + } + .text-white { + color: var(--color-white); + } + .uppercase { + text-transform: uppercase; + } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-xl { + --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-xs { + --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-1 { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-rose-200 { + --tw-ring-color: var(--color-rose-200); + } + .ring-rose-400\/20 { + --tw-ring-color: color-mix(in srgb, oklch(71.2% 0.194 13.428) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-ring-color: color-mix(in oklab, var(--color-rose-400) 20%, transparent); + } + } + .ring-slate-200 { + --tw-ring-color: var(--color-slate-200); + } + .ring-slate-900\/5 { + --tw-ring-color: color-mix(in srgb, oklch(20.8% 0.042 265.755) 5%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-ring-color: color-mix(in oklab, var(--color-slate-900) 5%, transparent); + } + } + .ring-white\/10 { + --tw-ring-color: color-mix(in srgb, #fff 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-ring-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + } + .outline-offset-2 { + outline-offset: 2px; + } + .outline-amber-500 { + outline-color: var(--color-amber-500); + } + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .backdrop-blur { + --tw-backdrop-blur: blur(8px); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .placeholder\:text-slate-400 { + &::placeholder { + color: var(--color-slate-400); + } + } + .hover\:bg-amber-300 { + &:hover { + @media (hover: hover) { + background-color: var(--color-amber-300); + } + } + } + .hover\:bg-rose-50 { + &:hover { + @media (hover: hover) { + background-color: var(--color-rose-50); + } + } + } + .hover\:bg-rose-100 { + &:hover { + @media (hover: hover) { + background-color: var(--color-rose-100); + } + } + } + .hover\:bg-rose-500\/20 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, oklch(64.5% 0.246 16.439) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-rose-500) 20%, transparent); + } + } + } + } + .hover\:bg-slate-50 { + &:hover { + @media (hover: hover) { + background-color: var(--color-slate-50); + } + } + } + .hover\:bg-slate-800 { + &:hover { + @media (hover: hover) { + background-color: var(--color-slate-800); + } + } + } + .hover\:bg-stone-200 { + &:hover { + @media (hover: hover) { + background-color: var(--color-stone-200); + } + } + } + .hover\:bg-white\/5 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, #fff 5%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 5%, transparent); + } + } + } + } + .hover\:bg-white\/10 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, #fff 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + } + } + } + .hover\:bg-white\/15 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, #fff 15%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 15%, transparent); + } + } + } + } + .hover\:text-white { + &:hover { + @media (hover: hover) { + color: var(--color-white); + } + } + } + .focus\:ring-2 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-amber-500 { + &:focus { + --tw-ring-color: var(--color-amber-500); + } + } + .focus\:outline-hidden { + &:focus { + --tw-outline-style: none; + outline-style: none; + @media (forced-colors: active) { + outline: 2px solid transparent; + outline-offset: 2px; + } + } + } + .sm\:grid-cols-2 { + @media (width >= 40rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .sm\:flex-row { + @media (width >= 40rem) { + flex-direction: row; + } + } + .sm\:items-end { + @media (width >= 40rem) { + align-items: flex-end; + } + } + .sm\:justify-between { + @media (width >= 40rem) { + justify-content: space-between; + } + } + .sm\:p-8 { + @media (width >= 40rem) { + padding: calc(var(--spacing) * 8); + } + } + .sm\:px-6 { + @media (width >= 40rem) { + padding-inline: calc(var(--spacing) * 6); + } + } + .sm\:px-8 { + @media (width >= 40rem) { + padding-inline: calc(var(--spacing) * 8); + } + } + .sm\:text-4xl { + @media (width >= 40rem) { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + } + .sm\:text-base { + @media (width >= 40rem) { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + } + .md\:grid-cols-2 { + @media (width >= 48rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .lg\:grid { + @media (width >= 64rem) { + display: grid; + } + } + .lg\:min-h-screen { + @media (width >= 64rem) { + min-height: 100vh; + } + } + .lg\:grid-cols-2 { + @media (width >= 64rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .lg\:grid-cols-3 { + @media (width >= 64rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + .lg\:grid-cols-\[18rem_minmax\(0\,1fr\)\] { + @media (width >= 64rem) { + grid-template-columns: 18rem minmax(0,1fr); + } + } + .lg\:flex-row { + @media (width >= 64rem) { + flex-direction: row; + } + } + .lg\:items-end { + @media (width >= 64rem) { + align-items: flex-end; + } + } + .lg\:justify-between { + @media (width >= 64rem) { + justify-content: space-between; + } + } + .lg\:px-8 { + @media (width >= 64rem) { + padding-inline: calc(var(--spacing) * 8); + } + } + .lg\:py-8 { + @media (width >= 64rem) { + padding-block: calc(var(--spacing) * 8); + } + } + .xl\:grid-cols-2 { + @media (width >= 80rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .xl\:grid-cols-3 { + @media (width >= 80rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + .xl\:grid-cols-4 { + @media (width >= 80rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + .xl\:grid-cols-5 { + @media (width >= 80rem) { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + } + .xl\:grid-cols-\[minmax\(0\,1\.35fr\)_minmax\(22rem\,0\.95fr\)\] { + @media (width >= 80rem) { + grid-template-columns: minmax(0,1.35fr) minmax(22rem,0.95fr); + } + } +} +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-gradient-position { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-from { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-via { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-to { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-stops { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-via-stops { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-from-position { + syntax: ""; + inherits: false; + initial-value: 0%; +} +@property --tw-gradient-via-position { + syntax: ""; + inherits: false; + initial-value: 50%; +} +@property --tw-gradient-to-position { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-space-y-reverse: 0; + --tw-divide-y-reverse: 0; + --tw-border-style: solid; + --tw-gradient-position: initial; + --tw-gradient-from: #0000; + --tw-gradient-via: #0000; + --tw-gradient-to: #0000; + --tw-gradient-stops: initial; + --tw-gradient-via-stops: initial; + --tw-gradient-from-position: 0%; + --tw-gradient-via-position: 50%; + --tw-gradient-to-position: 100%; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; + } + } +} diff --git a/repub/static/app.tailwind.css b/repub/static/app.tailwind.css new file mode 100644 index 0000000..13ce457 --- /dev/null +++ b/repub/static/app.tailwind.css @@ -0,0 +1,3 @@ +@import "tailwindcss"; +@source "../components.py"; +@source "../web.py"; diff --git a/repub/web.py b/repub/web.py index c6fe715..8c3e1c2 100644 --- a/repub/web.py +++ b/repub/web.py @@ -1,6 +1,8 @@ from __future__ import annotations -from quart import Quart +from quart import Quart, url_for + +from repub.pages import admin_page def create_app() -> Quart: @@ -8,20 +10,6 @@ def create_app() -> Quart: @app.get("/") async def index() -> str: - return """ - - - - - Republisher - - -
-

Hello, world!

-

Republisher web UI is starting here.

-
- - -""" + return str(admin_page(stylesheet_href=url_for("static", filename="app.css"))) return app diff --git a/uv.lock b/uv.lock index a106ad6..bfa042b 100644 --- a/uv.lock +++ b/uv.lock @@ -504,6 +504,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, ] +[[package]] +name = "htpy" +version = "25.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/23/e00bbc355e70444d16c90a0f1fdce108c67379fe65e9312cd026c13db976/htpy-25.12.0.tar.gz", hash = "sha256:7d3f4aaa10b35c5e46dfa804df1f3f18772caf8efee6e6a035b5dee89a5d6af8", size = 291259, upload-time = "2025-12-01T20:35:01.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/f1/a2f2caf14b03e7fab4801ac6018a4ac996de3e82a573e7aa21f3cb11a7cc/htpy-25.12.0-py3-none-any.whl", hash = "sha256:642e69278d6f8f4643acc2d2d13c21682ceb5fb4860ecbbce042f171577fff54", size = 21141, upload-time = "2025-12-01T20:35:00.13Z" }, +] + [[package]] name = "hypercorn" version = "0.18.0" @@ -1077,6 +1089,7 @@ dependencies = [ { name = "feedparser" }, { name = "ffmpeg-python" }, { name = "greenlet" }, + { name = "htpy" }, { name = "lxml" }, { name = "peewee" }, { name = "pillow" }, @@ -1108,6 +1121,7 @@ requires-dist = [ { name = "feedparser", specifier = ">=6.0.11,<7.0.0" }, { name = "ffmpeg-python", specifier = ">=0.2.0,<0.3.0" }, { name = "greenlet", specifier = ">=3.2.4,<4.0.0" }, + { name = "htpy", specifier = ">=25.12.0,<26.0.0" }, { name = "lxml", specifier = ">=5.2.1,<6.0.0" }, { name = "peewee", specifier = ">=3.19.0,<4.0.0" }, { name = "pillow", specifier = ">=10.3.0,<11.0.0" },