ProfileHeader
The top of a profile: cover, avatar, name, handle, bio, facts, counts and a Follow button that becomes Following.
Demo
Ana Silva
@ana
Builds things with Quillo. Runs by the river. Opinions my own.
- 128Posts
- 2.3KFollowers
- 312Following
You do not follow Ana.
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.qwemain.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>}Related
UserBadgeA small chip with a person's avatar and name, plus a role label or a verified tick; a link when given an href.6 views · 5 downloadsProfileCardA person at a glance: picture, name, title, bio, a row of stats and action buttons, stacked or in a row.7 views · 4 downloadsCommentOne comment: avatar, name, role, time, text, Like and Reply, with its replies nested inside it.6 views · 8 downloadsPostCardA post in a feed: author, time, text, picture, like, comment and share counts, and the three buttons.5 views · 5 downloadsStoryRingA row of avatars in rings, bright for stories not yet seen and grey once seen, with your own first to add one.6 views · 6 downloadsFollowButtonFollow becomes Following once pressed, and says Unfollow on hover, as the social sites do.5 views · 7 downloads
Thread
Thread 0 posts
On ProfileHeader: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.