SessionList
Signed-in devices with browser, system, place and time, this one marked, and Sign out for each or all the others.
Demo
- Chrome 130 on macOSThis device
- Safari on iOS 19
- Firefox 131 on Windows 11
- Safari on iPadOS 19
Sign one out, or all the others.
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/session_list.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/session_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/session_list.qwe
let session_list_demo_sessions = [ { id: "s1", browser: "Chrome 130", os: "macOS", device: "desktop", place: "Lisbon, Portugal", ip: "85.244.1.2", time: "2026-10-28T09:12:00Z", current: true }, { id: "s2", browser: "Safari", os: "iOS 19", device: "mobile", place: "Porto, Portugal", time: "2026-10-26T20:03:00Z" }, { id: "s3", browser: "Firefox 131", os: "Windows 11", place: "Madrid, Spain", ip: "81.33.4.5", time: "2026-10-14" }, { id: "s4", browser: "Safari", os: "iPadOS 19", device: "tablet", time: "2026-09-30T07:45:00Z" },]component SessionListDemoLive() { state ended = [] <div.demo-stack> <SessionList sessions={session_list_demo_sessions} on_sign_out={(id) => { ended = ended | push(id) }}/> <p.demo-note>{len(ended) == 0 ? "Sign one out, or all the others." : `Signed out: ${join(ended, ", ")}.`}</p> </div>}view SessionListDemo() = <div.demo-stack> <SessionListDemoLive/> <SessionList sign_out_action="#session_list" sessions={take(session_list_demo_sessions, 2)}/> </div>Source
ui/session_list.qwe
// SessionList: where someone is signed in: each device with its browser and// system, where and when, this one marked, and a Sign out on each of the// others, plus one for all of them at once.//// <SessionList sign_out_action="/settings/sessions/sign-out" sessions={[// { id: "s1", browser: "Chrome 130", os: "macOS", device: "desktop", place: "Lisbon, Portugal", ip: "85.244.1.2", time: "2026-10-28T09:12:00Z", current: true },// { id: "s2", browser: "Safari", os: "iOS 19", device: "mobile", place: "Porto, Portugal", time: "2026-10-26T20:03:00Z" },// ]}/>// <SessionList sessions={sessions} on_sign_out={(id) => { end(id) }}/>//// sessions: a list of { id, browser, os?, device?, place?, ip?, time?, current? }.// device: "desktop" (default), "mobile" or "tablet", for the icon. time: an ISO// date-time of the last activity, shown as "Oct 28, 2026, 09:12" (other text as// it is); the current session says "This device" instead. Signing out:// sign_out_action makes the list a <form method="post"> to that address; each// Sign out sends `sign_out` = the session's id, and "Sign out all other devices"// sends `sign_out` = "others". Inside one of your components, on_sign_out(id)// is called instead ("others" for all of them), and the rows go. label: the// list's name ("Sessions").import ui/theme.qwe use ui_clslet session_list_css = css`.ui-sessions { display: grid; gap: 12px; margin: 0; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-sessions-list { margin: 0; padding: 0; list-style: none; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); }.ui-sessions-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 2px 14px; align-items: center; padding: 12px 16px; }.ui-sessions-row + .ui-sessions-row { border-top: 1px solid var(--ui-border); }.ui-sessions-icon { display: grid; place-items: center; width: 40px; height: 40px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); color: var(--ui-muted); }.ui-sessions-icon svg { width: 20px; height: 20px; }.ui-sessions-current .ui-sessions-icon { background: var(--ui-success-soft); color: var(--ui-success); }.ui-sessions-what { display: grid; gap: 1px; min-width: 0; }.ui-sessions-name { font-weight: 600; }.ui-sessions-this { display: inline-block; margin-left: 8px; padding: 1px 7px; border-radius: 999px; background: var(--ui-success-soft); color: var(--ui-success); font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }.ui-sessions-meta { color: var(--ui-muted); font-size: 13px; }.ui-sessions-meta span + span::before { content: " · "; }.ui-sessions-ip { font-family: var(--ui-mono); font-size: 12px; }.ui-sessions-btn { height: 30px; padding: 0 11px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 13px/1 var(--ui-font); cursor: pointer; white-space: nowrap; }.ui-sessions-btn:hover { border-color: var(--ui-danger); background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-sessions-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-sessions-all { justify-self: start; }.ui-sessions-empty { padding: 24px 16px; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius-sm); color: var(--ui-muted); text-align: center; }@media (max-width: 480px) { .ui-sessions-row { grid-template-columns: auto minmax(0, 1fr); } .ui-sessions-row .ui-sessions-btn { grid-column: 2; justify-self: start; margin-top: 6px; } }`let session_list_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]// An ISO date-time as "Oct 28, 2026, 09:12" (a date alone without the time); other text as it is.fn session_list_time(v) -> str { let s = str(v) let p = split(slice(s, 0, 10), "-") if len(p) != 3 || len(p[0]) != 4 { return s } let day = `${session_list_months[(int(p[1]) ?? 1) - 1]} ${int(p[2]) ?? p[2]}, ${p[0]}` len(s) >= 16 && (s[10] == "T" || s[10] == " ") ? `${day}, ${slice(s, 11, 16)}` : day}// The device's icon: a laptop, a phone or a tablet.fn session_list_icon(device: str) { let body = match device { "mobile" => [<rect x="7" y="2" width="10" height="20" rx="2"/>, <path d="M11 18h2"/>] "tablet" => [<rect x="4" y="2" width="16" height="20" rx="2"/>, <path d="M11 18h2"/>] _ => [<rect x="3" y="4" width="18" height="12" rx="2"/>, <path d="M2 20h20"/>] } <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>}component SessionList(sessions: list = [], sign_out_action: str? = null, on_sign_out = null, label: str = "Sessions", class: str? = null) { style global session_list_css state gone = [] let rows = sessions | filter((s) => !contains(gone, s.id)) let others = rows | filter((s) => s.current != true) let end = (id) => { gone = id == "others" ? gone + (others | map((s) => s.id)) : gone | push(id) on_sign_out(id) } let button = (id: str, text: str, cls: str) => on_sign_out != null ? <button class={cls} type="button" @click={() => { end(id) }}>{text}</button> : <button class={cls} type="submit" name="sign_out" value={id}>{text}</button> let body = [ len(rows) == 0 ? <p.ui-sessions-empty>No sessions.</p> : <ul.ui-sessions-list aria-label={label}> {for s in rows => <li class={ui_cls(["ui-sessions-row", s.current == true && "ui-sessions-current"])} key={s.id}> <span.ui-sessions-icon>{session_list_icon(s.device ?? "desktop")}</span> <span.ui-sessions-what> <span.ui-sessions-name> {s.browser}{s.os != null && ` on ${s.os}`} {s.current == true && <span.ui-sessions-this>This device</span>} </span> <span.ui-sessions-meta> {s.place != null && <span>{s.place}</span>} {s.ip != null && <span.ui-sessions-ip>{s.ip}</span>} {s.current == true ? <span>Active now</span> : (s.time != null && <span>{session_list_time(s.time)}</span>)} </span> </span> {s.current != true && button(str(s.id), "Sign out", "ui-sessions-btn")} </li>} </ul>, len(others) > 0 && button("others", `Sign out all other devices (${len(others)})`, "ui-sessions-btn ui-sessions-all"), ] sign_out_action != null && on_sign_out == null ? <form class={ui_cls(["ui-sessions", class])} method="post" action={sign_out_action}>{body}</form> : <div class={ui_cls(["ui-sessions", class])}>{body}</div>}Related
ApiKeyFieldA secret shown masked in a read-only field, with Show and Copy buttons and a hidden input for forms.5 views · 6 downloadsDangerZoneA red-bordered block of destructive actions (DangerAction), each a form, link or callback that asks before it acts.6 views · 8 downloadsOnboardingChecklistSteps to finish setting up, with a progress bar, a tick on each done and a link to where the rest get done.7 views · 5 downloadsSettingsSectionA titled block of settings rows, each a label and hint with its control at the right (SettingsRow).5 views · 5 downloadsTokenListAPI tokens with name, prefix, created, last used, scopes and expiry, and a Revoke that asks first.6 views · 8 downloadsPermissionsMatrixRoles across, permissions down, a checkbox at each: a form sends the pairs ticked, a column box ticks a whole role.6 views · 6 downloads
Thread
Thread 0 posts
On SessionList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.