Social

ProfileHeader

The top of a profile: cover, avatar, name, handle, bio, facts, counts and a Follow button that becomes Following.

#social#profile#user#page#no-js

Demo

Preview

Ana Silva

@ana

Builds things with Quillo. Runs by the river. Opinions my own.

You do not follow Ana.

Ben Okafor

@ben

Photos, mostly.

  • 42Posts
  • 1.5MFollowers

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

Example

The code of the preview above.

demos/profile_header.qwe
component ProfileHeaderDemoLive() {    state following = false    state followers = 2399    <div.demo-stack>        <ProfileHeader id="profile-header-demo-live" name="Ana Silva" handle="@ana" verified={true}                       avatar="https://i.pravatar.cc/160?img=47" cover="https://picsum.photos/seed/cover/1200/300"                       bio="Builds things with Quillo. Runs by the river.Opinions my own."                       meta={[{ text: "Lisbon", kind: "place" }, { text: "quillolang.org", kind: "link", href: "https://quillolang.org" }, { text: "Joined March 2024", kind: "joined" }]}                       stats={[{ label: "Posts", value: 128 }, { label: "Followers", value: followers, href: "#profile_header" }, { label: "Following", value: 312 }]}                       following={following} on_follow={(on) => { following = on; followers = on ? followers + 1 : followers - 1 }}/>        <p.demo-note>{following ? "You follow Ana." : "You do not follow Ana."}</p>    </div>}view ProfileHeaderDemo() =    <div.demo-stack>        <ProfileHeaderDemoLive/>        <ProfileHeader id="profile-header-demo-plain" name="Ben Okafor" handle="@ben" bio="Photos, mostly."                       stats={[{ label: "Posts", value: 42 }, { label: "Followers", value: 1500000 }]}                       following={true} follow_href="#profile_header">            <a href="#profile_header" class="ui-profile-header-follow" aria-pressed="true">Message</a>        </ProfileHeader>    </div>

Source

ui/profile_header.qwe
// ProfileHeader: the top of a profile page: a cover picture, the avatar over its// edge, the name, handle and bio, a few counts (posts, followers, following), and a// Follow button that becomes Following.////   <ProfileHeader name="Ana Silva" handle="@ana" bio="Builds things with Quillo." cover="/img/cover.jpg"//                  avatar="/img/ana.jpg" stats={[{ label: "Posts", value: 128 }, { label: "Followers", value: 2400 }]}//                  follow_href="/follow/ana"/>//   <ProfileHeader name="Ana Silva" handle="@ana" following={is_following}//                  on_follow={(on) => { is_following = on }}/>//// name, handle (@name), bio (a line or two, line breaks kept), verified: a tick after// the name. cover: a wide picture (a gradient without one); avatar: their picture (else// their initials). stats: a list of { label, value, href? }: the value is a number (2400// shows as 2.4K) or text. meta: a list of small facts with icons: { text, kind? ("place",// "link", "joined"), href? }.// following: whether you follow them. follow_href: a link for the button; follow_label// ("Follow"), following_label ("Following"). follow: false drops the button.// children: anything more at the right (an Edit profile button of your own).// on_follow(following): inside one of your components, called with true or false.import ui/theme.qwe use ui_clslet profile_header_css = css`.ui-profile-header { min-width: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); overflow: hidden; font: 14px/1.5 var(--ui-font); color: var(--ui-fg); }.ui-profile-header-cover { height: 140px; background: linear-gradient(120deg, var(--ui-accent-soft), var(--ui-accent)); }.ui-profile-header-cover img { display: block; width: 100%; height: 100%; object-fit: cover; }.ui-profile-header-body { padding: 0 20px 18px; }.ui-profile-header-top { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-top: -40px; }.ui-profile-header-avatar {  display: grid; place-items: center; flex: none; width: 88px; height: 88px; border-radius: 50%; overflow: hidden;  border: 4px solid var(--ui-surface); background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 28px; font-weight: 650; user-select: none;}.ui-profile-header-avatar img { width: 100%; height: 100%; object-fit: cover; }.ui-profile-header-actions { display: flex; align-items: center; gap: 8px; padding-bottom: 6px; }.ui-profile-header-follow {  display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 18px; border: 1px solid transparent; border-radius: 999px;  background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 14px/1 var(--ui-font); text-decoration: none; cursor: pointer;  transition: background .15s, color .15s, border-color .15s;}.ui-profile-header-follow:hover { background: color-mix(in srgb, var(--ui-accent) 88%, var(--ui-fg)); }.ui-profile-header-follow:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-profile-header-follow[aria-pressed="true"] { background: transparent; border-color: var(--ui-border); color: var(--ui-fg); }.ui-profile-header-follow[aria-pressed="true"]:hover { border-color: var(--ui-danger); color: var(--ui-danger); background: var(--ui-danger-soft); }.ui-profile-header-follow svg { width: 15px; height: 15px; }.ui-profile-header-name { display: flex; align-items: center; gap: 6px; margin: 10px 0 0; font-size: 20px; font-weight: 700; line-height: 1.25; }.ui-profile-header-name svg { width: 18px; height: 18px; color: var(--ui-info); }.ui-profile-header-handle { color: var(--ui-muted); }.ui-profile-header-bio { margin: 10px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }.ui-profile-header-meta { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 10px 0 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-profile-header-meta li { display: inline-flex; align-items: center; gap: 5px; }.ui-profile-header-meta svg { width: 15px; height: 15px; }.ui-profile-header-meta a { color: var(--ui-accent); text-decoration: none; }.ui-profile-header-meta a:hover { text-decoration: underline; }.ui-profile-header-stats { display: flex; flex-wrap: wrap; gap: 6px 20px; margin: 12px 0 0; padding: 0; list-style: none; }.ui-profile-header-stats li, .ui-profile-header-stats a { display: inline-flex; align-items: baseline; gap: 5px; color: inherit; text-decoration: none; }.ui-profile-header-stats a:hover { text-decoration: underline; }.ui-profile-header-stats b { font-weight: 700; font-variant-numeric: tabular-nums; }.ui-profile-header-stats span { color: var(--ui-muted); }@media (max-width: 520px) { .ui-profile-header-cover { height: 100px; } .ui-profile-header-avatar { width: 72px; height: 72px; font-size: 22px; } .ui-profile-header-top { margin-top: -32px; } }@media (prefers-reduced-motion: reduce) { .ui-profile-header-follow { transition: none; } }`// Up to two initials of a name: "Ana Silva" is "AS".fn profile_header_initials(name: str) -> str =    upper(join(split(trim(name), " ") | filter((w) => w != "") | map((w) => slice(w, 0, 1)) | slice(0, 2), ""))// A count as it reads on a profile: 950 is "950", 2400 is "2.4K", 1200000 is "1.2M"; text stays as it is.fn profile_header_count(v) -> str {    if type_of(v) != "int" && type_of(v) != "float" { return str(v) }    if v < 1000 { return str(v) }    let unit = v < 1000000 ? 1000 : 1000000    let tenths = floor(v * 10 / unit)    `${floor(tenths / 10)}${tenths % 10 == 0 ? "" : `.${tenths % 10}`}${unit == 1000 ? "K" : "M"}`}view ProfileHeader(name: str, handle: str? = null, bio: str? = null, verified: bool = false, cover: str? = null,                   avatar: str? = null, stats: list = [], meta: list = [], following: bool = false, follow: bool = true,                   follow_href: str? = null, follow_label: str = "Follow", following_label: str = "Following",                   id: str? = null, on_follow = null, class: str? = null, children) {    style global profile_header_css    <header class={ui_cls(["ui-profile-header", class])} id={id}>        <div.ui-profile-header-cover>{cover != null && <img src={cover} alt=""/>}</div>        <div.ui-profile-header-body>            <div.ui-profile-header-top>                <span.ui-profile-header-avatar role="img" aria-label={name}>                    {avatar != null ? <img src={avatar} alt=""/> : <span aria-hidden="true">{profile_header_initials(name)}</span>}                </span>                <div.ui-profile-header-actions>                    {children}                    {follow && (follow_href != null                        ? <a.ui-profile-header-follow href={follow_href} aria-pressed={following ? "true" : "false"}>{following ? following_label : follow_label}</a>                        : <button.ui-profile-header-follow type="button" aria-pressed={following ? "true" : "false"}                                  @click={() => { on_follow && on_follow(!following) }}>                              {following && <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"                                                 stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>}                              {following ? following_label : follow_label}                          </button>)}                </div>            </div>            <h2.ui-profile-header-name>                {name}                {verified && <svg viewBox="0 0 24 24" fill="currentColor" role="img" aria-label="Verified"><path d="M12 2l2.4 2.1 3.2-.4 1 3 2.9 1.5-.6 3.2L22 14l-2.1 2.4.4 3.2-3 1L15.8 23.5l-3.2-.6L9.6 23.5l-1.5-2.9-3-1 .4-3.2L3.4 14l1.1-2.6-.6-3.2L6.8 6.7l1-3 3.2.4z"/><path d="M8.5 12.5l2.5 2.5 5-5" fill="none" stroke="var(--ui-surface)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>}            </h2>            {handle != null && <div.ui-profile-header-handle>{handle}</div>}            {bio != null && <p.ui-profile-header-bio>{bio}</p>}            {len(meta) > 0 && <ul.ui-profile-header-meta>                {for m in meta => <li>                    {match m.kind {                        "place" => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s7-6.5 7-12a7 7 0 0 0-14 0c0 5.5 7 12 7 12z"/><circle cx="12" cy="10" r="2.5"/></svg>                        "link" => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>                        "joined" => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>                        _ => null                    }}                    {m.href != null ? <a href={m.href}>{m.text}</a> : m.text}                </li>}            </ul>}            {len(stats) > 0 && <ul.ui-profile-header-stats>                {for s in stats => <li>                    {s.href != null                        ? <a href={s.href}><b>{profile_header_count(s.value)}</b><span>{s.label}</span></a>                        : [<b>{profile_header_count(s.value)}</b>, <span>{s.label}</span>]}                </li>}            </ul>}        </div>    </header>}
Thread
65Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.