Blogging

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.

#blogging#forms#editor#publishing#interactive

Demo

Preview

Publish

Draft
Who can read it
When

blog.example.com/islands-resumed

Saved 2 minutes ago

Nothing sent yet.

Publish

Published
Who can read it

/blog/week-notes-43

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.qwe
main.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>}
Thread
89Useful? Like it, or add to its thread below.

Thread 0 posts

On PublishPanel: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.