Blogging

AuthorBio

The 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.

#blogging#user#card#profile#no-js

Demo

Preview

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/author_bio.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/author_bio.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/author_bio.qwe
view AuthorBioDemo() =    <div.demo-stack>        <AuthorBio id="author-bio-demo" name="Ada Lovelace" href="#author_bio-ada" role="Compiler engineer at Quillo" posts={42}                   bio="Ada writes about compilers, browsers and the odd analytical engine. Before Quillo she spent ten years making databases fast, and she still answers every email about them."                   links={[{ kind: "website", href: "https://ada.example.dev" }, { kind: "mastodon", href: "https://hachyderm.io/@ada" },                           { kind: "github", href: "https://github.com/ada" }, { kind: "rss", href: "#author_bio-feed", label: "Her feed" }]}                   follow_href="#author_bio-follow"/>        <div style="max-width: 300px">            <AuthorBio id="author-bio-demo-stacked" name="Ben Okafor" layout="stacked" eyebrow="Guest post by" posts={1}                       posts_href="#author_bio-ben">                <p>Ben runs <a href="#author_bio-ben">a small bakery</a> and writes its website in Quillo, between batches.</p>            </AuthorBio>        </div>    </div>

Source

ui/author_bio.qwe
// AuthorBio: the box at the end of a post about who wrote it: their picture (or// initials), name, role, a few lines about them, links to their pages elsewhere, how// many posts they have written with a link to them all, and a link to follow them// (their own feed, a newsletter). It is an <aside> named by its heading.////   <AuthorBio name="Ada Lovelace" href="/authors/ada" role="Engineer at Quillo" posts={42}//              bio="Ada writes about compilers, browsers and the odd analytical engine."//              links={[{ kind: "website", href: "https://ada.dev" }, { kind: "mastodon", href: "https://hachyderm.io/@ada" }]}//              follow_href="/authors/ada/feed.xml"/>//// name: who they are, the heading, a link to href (rel="author") when given. src: their// picture (else their initials on the accent colour). role: a line under the name ("Staff// writer"). bio: a paragraph about them, plain text; children: richer HTML in its place.// links: a list of { kind, href, label? }: kind one of "website", "x", "mastodon",// "bluesky", "github", "linkedin", "email" or "rss", which picks the icon and the name// shown (label to say another); links to other sites open in a new tab, rel="me noopener".// posts: how many posts they wrote ("42 posts"), a link to posts_href (else href) saying// "All 42 posts by Ada" ("More from Ben" for one). follow_href and follow_label// ("Follow"): a button-styled link. eyebrow: the small words over the name ("Written// by"). layout: "row" (default, the picture beside the text) or "stacked" (centred, for a// sidebar). locale: how the count is written ("en-US"). id: the <aside>'s id// ("author-bio"); the heading's is id + "-name".import ui/theme.qwe use ui_clslet author_bio_css = css`.ui-bauthor { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 20px; margin: 0; padding: 22px 24px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius); background: var(--ui-subtle); font: 15px/1.55 var(--ui-font); color: var(--ui-fg); }.ui-bauthor-pic { grid-row: span 2; display: grid; place-items: center; width: 72px; height: 72px; overflow: hidden; border-radius: 50%;  background: var(--ui-accent); color: var(--ui-accent-fg); font-size: 24px; font-weight: 700; }.ui-bauthor-pic img { width: 100%; height: 100%; object-fit: cover; }.ui-bauthor-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; min-width: 0; }.ui-bauthor-eyebrow { display: block; color: var(--ui-muted); font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }.ui-bauthor .ui-bauthor-name { margin: 0; font-size: 19px; font-weight: 700; line-height: 1.3; }.ui-bauthor-name a { color: inherit; text-decoration: none; border-radius: 3px; }.ui-bauthor-name a:hover { color: var(--ui-accent); }.ui-bauthor-name a:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-bauthor-role { display: block; color: var(--ui-muted); font-size: 14px; font-weight: 400; }.ui-bauthor-follow { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px; border: 1px solid var(--ui-accent);  border-radius: 999px; color: var(--ui-accent); font-size: 14px; font-weight: 600; text-decoration: none; white-space: nowrap; }.ui-bauthor-follow:hover { background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-bauthor-follow:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-bauthor-follow svg { width: 15px; height: 15px; }.ui-bauthor-body { display: grid; gap: 10px; min-width: 0; }.ui-bauthor-bio { margin: 0; color: var(--ui-fg); }.ui-bauthor-bio a { color: var(--ui-accent); }.ui-bauthor-bio > :first-child { margin-top: 0; } .ui-bauthor-bio > :last-child { margin-bottom: 0; }.ui-bauthor-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }.ui-bauthor-links { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 0; padding: 0; list-style: none; font-size: 14px; }.ui-bauthor-links a, .ui-bauthor-all { display: inline-flex; align-items: center; gap: 6px; color: var(--ui-muted); text-decoration: none; border-radius: 3px; }.ui-bauthor-links a:hover, .ui-bauthor-all:hover { color: var(--ui-accent); }.ui-bauthor-links a:focus-visible, .ui-bauthor-all:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-bauthor-links svg { flex: none; width: 16px; height: 16px; }.ui-bauthor-all { color: var(--ui-accent); font-size: 14px; font-weight: 600; }.ui-bauthor-stacked { grid-template-columns: minmax(0, 1fr); justify-items: center; text-align: center; }.ui-bauthor-stacked .ui-bauthor-pic { grid-row: auto; margin-bottom: 8px; }.ui-bauthor-stacked .ui-bauthor-head, .ui-bauthor-stacked .ui-bauthor-foot, .ui-bauthor-stacked .ui-bauthor-links { justify-content: center; }.ui-bauthor-stacked .ui-bauthor-head { flex-direction: column; }@media (max-width: 520px) {  .ui-bauthor { grid-template-columns: minmax(0, 1fr); }  .ui-bauthor-pic { grid-row: auto; width: 56px; height: 56px; font-size: 19px; }}`// The name shown for a link's kind.fn author_bio_name(kind: str) -> str =    match kind {        "x" => "X"        "mastodon" => "Mastodon"        "bluesky" => "Bluesky"        "github" => "GitHub"        "linkedin" => "LinkedIn"        "email" => "Email"        "rss" => "RSS"        _ => "Website"    }// A link's icon, on a 24 by 24 grid in currentColor.fn author_bio_icon(kind: str) {    let filled = (d: str) => <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d={d}/></svg>    let lined = (body) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"                              stroke-linejoin="round" aria-hidden="true">{body}</svg>    match kind {        "x" => filled("M18.2 2h3.4l-7.4 8.5L23 22h-6.8l-5.3-7-6.1 7H1.4l7.9-9.1L1 2h7l4.8 6.4L18.2 2zm-1.2 18h1.9L7.1 3.9H5.1L17 20z")        "github" => filled("M12 .5a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2.2c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1.1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.7-.3-5.5-1.3-5.5-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .5z")        "linkedin" => filled("M20.4 2H3.6A1.6 1.6 0 0 0 2 3.6v16.8A1.6 1.6 0 0 0 3.6 22h16.8a1.6 1.6 0 0 0 1.6-1.6V3.6A1.6 1.6 0 0 0 20.4 2zM8 19H5V9.5h3V19zM6.5 8.2a1.7 1.7 0 1 1 0-3.4 1.7 1.7 0 0 1 0 3.4zM19 19h-3v-4.6c0-1.1 0-2.5-1.5-2.5s-1.8 1.2-1.8 2.4V19h-3V9.5h2.9v1.3h.1c.4-.8 1.4-1.5 2.8-1.5 3 0 3.5 2 3.5 4.5V19z")        "mastodon" => filled("M21.3 13.6c-.3 1.5-2.6 3.1-5.2 3.4-1.4.2-2.7.3-4.1.2-2.3-.1-4.1-.5-4.1-.5v.6c.3 2.3 2.3 2.4 4.2 2.5 1.9.1 3.6-.5 3.6-.5l.1 1.7s-1.3.7-3.7.9c-1.3.1-2.9-.1-4.8-.5C3.2 20.4 2.5 16 2.4 11.6V8.1c0-4.5 3-5.8 3-5.8C6.9 1.6 9.4 1.3 12 1.3h.1c2.6 0 5.1.3 6.6 1 0 0 3 1.3 3 5.8 0 0 0 3.3-.4 5.5zm-3.1-5.2v5.5h-2.2V8.6c0-1.1-.5-1.7-1.4-1.7-1 0-1.6.7-1.6 2v2.9h-2.2V8.9c0-1.3-.5-2-1.6-2-1 0-1.4.6-1.4 1.7v5.3H5.6V8.4c0-1.1.3-2 .9-2.6.6-.7 1.4-1 2.4-1 1.1 0 2 .4 2.6 1.3l.5.9.6-.9c.6-.9 1.5-1.3 2.6-1.3 1 0 1.8.3 2.4 1 .6.6.9 1.5.9 2.6z")        "bluesky" => filled("M12 10.8c-1.1-2.1-4-6-6.8-8C2.6 1 1.6 1.3.9 1.6.2 2 0 3.1 0 3.8c0 .7.4 5.6.6 6.4.8 2.7 3.7 3.6 6.4 3.3-3.9.6-7.4 2-2.8 7 5 5.2 6.9-1.1 7.8-4.3.9 3.2 2 9.3 7.7 4.3 4.3-4.3 1.2-6.4-2.7-7 2.7.3 5.6-.6 6.4-3.3.2-.8.6-5.7.6-6.4 0-.7-.2-1.8-.9-2.2-.7-.3-1.7-.6-4.3 1.2-2.8 2-5.7 5.9-6.8 8z")        "email" => lined([<rect x="2.5" y="5" width="19" height="14" rx="2.5"/>, <path d="m3 7 9 6 9-6"/>])        "rss" => lined([<path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16"/>, <circle cx="5" cy="19" r="1.2" fill="currentColor"/>])        _ => lined([<circle cx="12" cy="12" r="9.5"/>, <path d="M2.5 12h19M12 2.5c2.8 3 2.8 16 0 19M12 2.5c-2.8 3-2.8 16 0 19"/>])    }}view AuthorBio(name: str, href: str? = null, src: str? = null, role: str? = null, bio: str? = null, children,               links: list? = null, posts: int? = null, posts_href: str? = null, follow_href: str? = null,               follow_label: str = "Follow", eyebrow: str = "Written by", layout: str = "row", locale: str = "en-US",               id: str = "author-bio", class: str? = null) {    style global author_bio_css    let initials = split(trim(name), " ") | filter((w) => w != "") | take(2) | map((w) => upper(slice(w, 0, 1))) | join("")    let first_name = split(trim(name), " ")[0]    let all_href = posts_href ?? href    let given = len(children) > 0    let has_body = bio != null || given    let has_foot = (links != null && len(links) > 0) || (posts != null && posts > 0 && all_href != null)    <aside id={id} class={ui_cls(["ui-bauthor", layout == "stacked" && "ui-bauthor-stacked", class])} aria-labelledby={`${id}-name`}>        <span.ui-bauthor-pic aria-hidden="true">{src != null ? <img src={src} alt="" loading="lazy"/> : initials}</span>        <div.ui-bauthor-head>            <div>                <span.ui-bauthor-eyebrow>{eyebrow}</span>                <h2.ui-bauthor-name id={`${id}-name`}>{href != null ? <a href={href} rel="author">{name}</a> : name}</h2>                {(role != null || posts != null) && <span.ui-bauthor-role>                    {role}{role != null && posts != null && " · "}{posts != null && `${format_number(posts, locale)} ${posts == 1 ? "post" : "posts"}`}                </span>}            </div>            {follow_href != null && <a.ui-bauthor-follow href={follow_href} aria-label={`${follow_label} ${name}`}>                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>                {follow_label}            </a>}        </div>        {(has_body || has_foot) && <div.ui-bauthor-body>            {given ? <div.ui-bauthor-bio>{children}</div> : (bio != null && <p.ui-bauthor-bio>{bio}</p>)}            {has_foot && <div.ui-bauthor-foot>                {links != null && len(links) > 0 && <ul.ui-bauthor-links aria-label={`${first_name} elsewhere`}>                    {for l in links => {                        let kind = l.kind ?? "website"                        let away = starts_with(l.href, "http")                        <li><a href={l.href} target={away ? "_blank" : null} rel={away ? "me noopener" : null}>                            {author_bio_icon(kind)}<span>{l.label ?? author_bio_name(kind)}</span>                        </a></li>                    }}                </ul>}                {posts != null && posts > 0 && all_href != null && <a.ui-bauthor-all href={all_href}>                    {posts == 1 ? `More from ${first_name}` : `All ${format_number(posts, locale)} posts by ${first_name}`}<span aria-hidden="true"> →</span>                </a>}            </div>}        </div>}    </aside>}
Thread
89Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.