PublishPanel
The editor's panel that puts a post out: its status, who may read it, now or scheduled, its address, then Save draft and Publish or Schedule.
Demo
Nothing sent yet.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/publish_panel.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/publish_panel.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/publish_panel.qwe
component PublishPanelDemoLive() { state note = "Nothing sent yet." <div.demo-stack> <PublishPanel name="draft" slug="Islands, resumed" base_url="blog.example.com/" saved="Saved 2 minutes ago" timezone="Europe/Paris" on_publish={(p) => { note = `${p.intent}: ${p.status}, ${p.visibility}, /${p.slug}${p.publish_at != null ? " at " + p.publish_at : ""}` }} on_save={(p) => { note = `${p.intent}: ${p.status}, /${p.slug}` }}/> <p.demo-note id="publish-panel-demo-note">{note}</p> </div>}view PublishPanelDemo() = <div class="demo-row" style="align-items: start; gap: 24px"> <PublishPanelDemoLive/> <PublishPanel name="live" status="published" visibility="members" slug="week-notes-43" action="#publish_panel"/> </div>Source
ui/publish_panel.qwe
// PublishPanel: the side panel of a post's editor that puts it out: where it stands// (Draft, Scheduled for a time, Published), who may read it (everyone, only those with// the link, members, or those with a password), now or at a time, and its address; then// Save draft and Publish (or Schedule, or Update once it is out, with Unpublish). The// main button says what it will do, and waits for a time or a password when one is// needed. It is a real <form>: without the callbacks, the buttons post every field and// `intent` ("draft", "publish", "schedule", "update" or "unpublish") to `action`.//// <PublishPanel action="/admin/posts/42" slug="islands-resumed" base_url="https://blog.example.com/"/>// <PublishPanel status="published" visibility="members" slug={post.slug} saved="Saved 2 minutes ago"// on_publish={(p) => { publish_post(id, p) }} on_save={(p) => { save_draft(id, p) }}/>//// status: "draft" (default), "scheduled" or "published", to start with. visibility:// "public" (default), "unlisted", "members" or "password". password: the one to start// with. publish_at: a local date and time, "2026-11-02T09:00", which starts it on// Schedule. timezone: said under the time ("Times are Europe/Paris"; none says nothing).// slug: the post's address, kept to small letters, digits and hyphens as it is typed;// base_url: what comes before it ("/blog/"). saved: a line at the foot ("Saved 2// minutes ago"). locale: how the time is written ("en-US"). action: where the form posts// (this page). name: put before the fields' names and ids ("post" makes post_slug, ...).// on_publish(post) and on_save(post): inside one of your components, instead of// posting, given { intent, status, visibility, publish_at, slug, password } (publish_at// null unless scheduled; password null unless "password"); the panel then shows the new// status and says what was done. on_publish takes "publish", "schedule", "update" and// "unpublish"; on_save "draft".import ui/theme.qwe use ui_clslet publish_panel_css = css`.ui-bpub { display: grid; gap: 16px; min-width: 0; max-width: 340px; margin: 0; padding: 18px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-bpub-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }.ui-bpub .ui-bpub-title { margin: 0; font-size: 16px; font-weight: 650; }.ui-bpub-status { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; font-weight: 650; }.ui-bpub-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }.ui-bpub-status-scheduled { background: var(--ui-info-soft); color: var(--ui-info); }.ui-bpub-status-published { background: var(--ui-success-soft); color: var(--ui-success); }.ui-bpub-group { display: grid; gap: 6px; min-width: 0; margin: 0; padding: 0; border: 0; }.ui-bpub-group legend, .ui-bpub-label { margin-bottom: 6px; padding: 0; color: var(--ui-muted); font-size: 12px; font-weight: 650; letter-spacing: .05em; text-transform: uppercase; }.ui-bpub-choice { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0 10px; align-items: start; padding: 8px 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); cursor: pointer; transition: border-color .15s, background .15s; }.ui-bpub-choice:hover { border-color: var(--ui-strong); }.ui-bpub-choice:has(input:checked) { border-color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-bpub-choice:has(input:focus-visible) { box-shadow: var(--ui-focus); }.ui-bpub-choice input { grid-row: span 2; width: 16px; height: 16px; margin: 1px 0 0; accent-color: var(--ui-accent); }.ui-bpub-choice b { font-weight: 600; }.ui-bpub-choice small { color: var(--ui-muted); font-size: 12px; }.ui-bpub-when { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 3px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-bpub-when label { display: grid; place-items: center; height: 32px; border-radius: calc(var(--ui-radius-sm) - 2px); color: var(--ui-muted); font-weight: 600; cursor: pointer; }.ui-bpub-when input { position: absolute; opacity: 0; pointer-events: none; }.ui-bpub-when label:has(input:checked) { background: var(--ui-surface); color: var(--ui-fg); box-shadow: 0 1px 2px rgb(0 0 0 / .08); }.ui-bpub-when label:has(input:focus-visible) { box-shadow: var(--ui-focus); }.ui-bpub-input { width: 100%; height: 36px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); }.ui-bpub-input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-bpub-input[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-bpub-hint { margin: 0; color: var(--ui-muted); font-size: 12px; }.ui-bpub-need { color: var(--ui-danger); }.ui-bpub-url { display: flex; align-items: center; height: 36px; overflow: hidden; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-bpub-url:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-bpub-url span { flex: none; max-width: 55%; overflow: hidden; padding: 0 2px 0 10px; color: var(--ui-muted); text-overflow: ellipsis; white-space: nowrap; }.ui-bpub-url input { flex: 1; min-width: 0; height: 100%; padding: 0 10px 0 0; border: 0; outline: none; background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); }.ui-bpub-url span + input { padding-left: 2px; }.ui-bpub-actions { display: grid; grid-template-columns: auto 1fr; gap: 8px; }.ui-bpub-actions button { height: 38px; padding: 0 14px; border-radius: var(--ui-radius-sm); font: 600 14px var(--ui-font); cursor: pointer; }.ui-bpub-actions button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-bpub-save { border: 1px solid var(--ui-border); background: var(--ui-surface); color: var(--ui-fg); }.ui-bpub-save:hover { border-color: var(--ui-strong); }.ui-bpub-go { border: 0; background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-bpub-go:hover:not(:disabled) { filter: brightness(1.08); }.ui-bpub-go:disabled { opacity: .45; cursor: not-allowed; }.ui-bpub-actions .ui-bpub-unpublish { grid-column: 1 / -1; justify-self: center; height: auto; padding: 2px 6px; border: 0; background: none; color: var(--ui-danger); font-size: 13px; }.ui-bpub-actions .ui-bpub-unpublish:hover { text-decoration: underline; }.ui-bpub-foot { margin: -6px 0 0; color: var(--ui-muted); font-size: 12px; text-align: center; }.ui-bpub-done { margin: 0; padding: 8px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-success-soft); font-size: 13px; }@media (prefers-reduced-motion: reduce) { .ui-bpub-choice { transition: none; } }`let publish_panel_choices = [ { value: "public", name: "Public", text: "Anyone, listed on the blog and in its feed" }, { value: "unlisted", name: "Unlisted", text: "Anyone with the link; not listed" }, { value: "members", name: "Members only", text: "Readers who are signed in" }, { value: "password", name: "Password", text: "Only those who have the password" },]// Text made a slug as it is typed: small letters, digits and single hyphens// ("Islands, resumed" is "islands-resumed"); done also drops a hyphen at the end.fn publish_panel_slug(text: str, done: bool) -> str { let s = re.replace(re.replace(lower(text), "[^a-z0-9]+", "-"), "^-+", "") done ? re.replace(s, "-+$", "") : s}// A local date and time ("2026-11-02T09:00") as "Nov 2, 2026, 09:00".fn publish_panel_when(at: str, locale: str) -> str { let day = slice(at, 0, 10) date.valid(day) ? `${date.format(day, { locale, style: "medium" })}, ${slice(at, 11, 16)}` : at}component PublishPanel(status: str = "draft", visibility: str = "public", password: str = "", publish_at: str? = null, timezone: str? = null, slug: str = "", base_url: str = "/blog/", saved: str? = null, locale: str = "en-US", action: str? = null, name: str? = null, on_publish = null, on_save = null, class: str? = null) { style global publish_panel_css let field = (f: str) => name != null ? `${name}_${f}` : f let id = `publish-panel-${name ?? "post"}` state st = status state vis = visibility state pw = password state when = publish_at != null ? "later" : "now" state at = publish_at ?? "" state address = publish_panel_slug(slug, true) state said = "" let out = st == "published" let need_time = when == "later" && !out && at == "" let need_pw = vis == "password" && trim(pw) == "" let intent = out ? "update" : (when == "later" ? "schedule" : "publish") let go_text = out ? "Update" : (when == "later" ? "Schedule" : "Publish now") let badge = st == "published" ? "Published" : (st == "scheduled" ? `Scheduled · ${publish_panel_when(at, locale)}` : "Draft") let post = (i: str) => { intent: i, slug: address, visibility: vis, password: vis == "password" ? pw : null, status: i == "draft" || i == "unpublish" ? "draft" : (i == "schedule" ? "scheduled" : "published"), publish_at: i == "schedule" ? at : null, } let send = (e, i: str) => { let f = i == "draft" ? on_save : on_publish if f != null { e.preventDefault() let p = post(i) st = p.status said = match i { "draft" => "Draft saved." "schedule" => `Scheduled for ${publish_panel_when(at, locale)}.` "update" => "Changes published." "unpublish" => "Unpublished: it is a draft again." _ => "Published." } if i == "publish" || i == "unpublish" { when = "now" } f(p) } } <form class={ui_cls(["ui-bpub", class])} method="post" action={action} aria-labelledby={`${id}-title`} data-status={st}> <div.ui-bpub-head> <h3.ui-bpub-title id={`${id}-title`}>Publish</h3> <span class={ui_cls(["ui-bpub-status", `ui-bpub-status-${st}`])}>{badge}</span> </div> <fieldset.ui-bpub-group> <legend>Who can read it</legend> {for c in publish_panel_choices => <label.ui-bpub-choice key={c.value}> <input type="radio" name={field("visibility")} value={c.value} checked={vis == c.value} @change={() => { vis = c.value }}/> <b>{c.name}</b><small>{c.text}</small> </label>} </fieldset> {vis == "password" && <div.ui-bpub-group> <label.ui-bpub-label for={`${id}-password`}>Password</label> <input.ui-bpub-input type="text" id={`${id}-password`} name={field("password")} value={pw} autocomplete="off" aria-invalid={need_pw ? "true" : null} aria-describedby={need_pw ? `${id}-pw-need` : null} @input={(e) => { pw = e.value }}/> {need_pw && <p.ui-bpub-hint.ui-bpub-need id={`${id}-pw-need`}>Give it a password to publish.</p>} </div>} {!out && <fieldset.ui-bpub-group> <legend>When</legend> <div.ui-bpub-when> <label><input type="radio" name={field("when")} value="now" checked={when == "now"} @change={() => { when = "now" }}/>Now</label> <label><input type="radio" name={field("when")} value="later" checked={when == "later"} @change={() => { when = "later" }}/>Schedule</label> </div> {when == "later" && [ <label.ui-sr for={`${id}-at`}>Date and time</label>, <input.ui-bpub-input type="datetime-local" id={`${id}-at`} name={field("publish_at")} value={at} aria-invalid={need_time ? "true" : null} aria-describedby={`${id}-at-hint`} @input={(e) => { at = e.value }}/>, <p.ui-bpub-hint id={`${id}-at-hint`} class={need_time ? "ui-bpub-need" : null}> {need_time ? "Pick a date and time to schedule it." : (timezone != null ? `Times are ${timezone}.` : "")} </p>, ]} </fieldset>} <div.ui-bpub-group> <label.ui-bpub-label for={`${id}-slug`}>Address</label> <div.ui-bpub-url> {base_url != "" && <span aria-hidden="true" title={base_url}>{base_url}</span>} <input type="text" id={`${id}-slug`} name={field("slug")} value={address} spellcheck="false" autocomplete="off" aria-describedby={`${id}-url`} @input={(e) => { address = publish_panel_slug(e.value, false) }} @change={(e) => { address = publish_panel_slug(e.value, true) }}/> </div> <p.ui-bpub-hint id={`${id}-url`}>{base_url}{address}</p> </div> <p role="status" class={said != "" ? "ui-bpub-done" : "ui-sr"}>{said}</p> <div.ui-bpub-actions> <button.ui-bpub-save type="submit" name={field("intent")} value="draft" @click={(e) => send(e, "draft")}> {out ? "Save" : "Save draft"} </button> <button.ui-bpub-go type="submit" name={field("intent")} value={intent} disabled={need_time || need_pw} @click={(e) => { if need_time || need_pw { e.preventDefault() } else { send(e, intent) } }}>{go_text}</button> {out && <button.ui-bpub-unpublish type="submit" name={field("intent")} value="unpublish" @click={(e) => send(e, "unpublish")}> Unpublish </button>} </div> {saved != null && <p.ui-bpub-foot>{saved}</p>} </form>}Related
SeoPreviewA post's title and description for search and sharing, with counts that say short, good or too long, and a live search result and link card.7 views · 8 downloadsFeedSubscribeFollow this blog: its RSS, Atom or JSON feeds with their addresses to copy, links into feed readers, what a feed is, and an email box.8 views · 7 downloadsAuthorBioThe box at the end of a post about its author: picture or initials, name and role, a short bio, links elsewhere, their post count and a follow link.8 views · 9 downloadsBlogrollThe blogs a writer recommends, in groups: name, a line about each, its latest post and how long ago, its feed, and the list as OPML.9 views · 8 downloadsClapButtonApplause for a post: each press adds a clap up to a limit per reader, a +3 rises for presses in a row, and a small × takes them back.8 views · 8 downloadsPostArchiveA blog's posts by year and month, newest first: each year a disclosure with its count, its months and their posts, or only the months as links.10 views · 8 downloads
Thread
Thread 0 posts
On PublishPanel: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.