class DesktopPeopleTable extends Shadow { constructor(people, selectedId, sortKey, sortDir, onSelect, onSort) { super() this.people = people this.selectedId = selectedId this.sortKey = sortKey this.sortDir = sortDir this.onSelect = onSelect this.onSort = onSort } get columns() { return [ { key: "status", label: "", width: "44px", sortable: false }, { key: "name", label: "Name", width: "200px", sortable: true }, { key: "email", label: "Email", width: "220px", sortable: true }, { key: "phone", label: "Phone", width: "140px", sortable: false }, { key: "title", label: "Title", width: "250px", sortable: true }, { key: "county", label: "County", width: "120px", sortable: true }, { key: "roles", label: "Role", width: "120px", sortable: true }, { key: "tier", label: "Tier", width: "110px", sortable: true }, { key: "joined", label: "Joined", width: "120px", sortable: true }, { key: "notes", label: "Notes", width: "1fr", sortable: false }, ]; } get gridTemplate() { return this.columns.map(c => c.width).join(" "); } render() { VStack(() => { VStack(() => { // ── Sticky header ───────────────────────────────────────── HStack(() => { this.columns.forEach(col => this.renderHeaderCell(col)) }) .attr({ style: `display: grid; grid-template-columns: ${this.gridTemplate};` }) .paddingHorizontal(0.75, em) .paddingVertical(0.52, em) .borderBottom("1px solid var(--divider)") .flexShrink(0) .boxSizing("border-box") // ── Rows ────────────────────────────────────────────────── VStack(() => { if (this.people.length === 0) { VStack(() => { p("No members match your filters") .margin(0).fontSize(0.88, em) .color("var(--headertext)").opacity(0.35) }) .position("absolute") .top(0).right(0).bottom(0).left(0) .textAlign("center") .justifyContent("center") } else { this.people.forEach((person, i) => this.renderRow(person, i)) } }) .overflowY("auto") .overflowX("hidden") .flex(1) .minHeight(0) }) .width("max-content") .minWidth(100, pct) .height(100, pct) }) .height(100, pct).width(100, pct).overflowX("auto").overflowY("hidden") .position("relative") } renderHeaderCell(col) { const isActive = this.sortKey === col.key; const arrow = isActive ? (this.sortDir === "asc" ? " ↑" : " ↓") : ""; HStack(() => { p(col.label + arrow) .margin(0) .fontSize(0.7, em) .fontWeight("700") .letterSpacing("0.04em") .color("var(--headertext)") .opacity(isActive ? 0.75 : 0.38) .userSelect("none") .whiteSpace("nowrap") }) .alignItems("center") .paddingHorizontal(col.key === "status" ? 0 : 0.5, em) .justifyContent(col.key === "status" ? "center" : "flex-start") .cursor(col.sortable ? "pointer" : "default") .onClick((done) => { if(!done) return; if (col.sortable) this.onSort(col.key) }) } renderRow(person, index) { const isSelected = person.id === this.selectedId; const isOnline = person._online; HStack(() => { // Status dot HStack(() => { VStack(() => {}) .width(0.5, em).height(0.5, em) .borderRadius(50, pct) .background(isOnline ? "#22c55e" : "var(--divider)") .flexShrink(0) }) .justifyContent("center").alignItems("center") // Name + avatar HStack(() => { this.renderAvatar(person, 1.85) VStack(() => { p(`${person.first_name || ""} ${person.last_name || ""}`.trim()) .margin(0).fontSize(0.85, em).fontWeight("600") .color("var(--headertext)") .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") }) .flex(1).minWidth(0) }) .gap(0.55, em).alignItems("center").overflow("hidden").paddingHorizontal(0.5, em) // Email this.cell(person.email, true) // Phone this.cell(this.formatPhone(person.phone)) // Title this.cell(person.title) // County this.cell(person.county || [person.city, person.state].filter(Boolean).join(", ")) // Role(s) HStack(() => { const roles = person.roles || []; if (roles.length === 0) { p("—").margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.22) } else { roles.slice(0, 2).forEach(role => { p(this.capitalize(role)) .margin(0) .paddingHorizontal(0.45, em).paddingVertical(0.12, em) .background((role === "admin" || role === "executive") ? "rgba(239,68,68,0.1)" : "var(--darkaccent)") .border(`1px solid ${(role === "admin" || role === "executive") ? "rgba(239,68,68,0.22)" : "var(--divider)"}`) .borderRadius(100, px) .fontSize(0.68, em).fontWeight("600") .color((role === "admin" || role === "executive") ? "#ef4444" : "var(--headertext)") .opacity((role === "admin" || role === "executive") ? 1 : 0.6) .whiteSpace("nowrap") }) } }) .gap(0.3, em).alignItems("center").paddingHorizontal(0.5, em).overflow("hidden") // Tier HStack(() => { if (person.plan_name?.toLowerCase().includes("patron")) { p("⭐ Patron") .margin(0).paddingHorizontal(0.48, em).paddingVertical(0.12, em) .background("rgba(245,158,11,0.1)") .border("1px solid rgba(245,158,11,0.22)") .borderRadius(100, px) .fontSize(0.68, em).fontWeight("600").color("#d97706") } else if (person.plan_name?.toLowerCase().includes("annual")) { p("Regular") .margin(0).paddingHorizontal(0.48, em).paddingVertical(0.12, em) .background("var(--darkaccent)").border("1px solid var(--divider)") .borderRadius(100, px) .fontSize(0.68, em).color("var(--headertext)").opacity(0.5) } else { p("—").margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.22) } }) .alignItems("center").paddingHorizontal(0.5, em) // Joined HStack(() => { p(this.formatDateShort(person.joined_network || person.created)) .margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.45) .whiteSpace("nowrap") }) .alignItems("center").paddingHorizontal(0.5, em) // Notes (truncated) HStack(() => { p(person.notes || "") .margin(0).fontSize(0.78, em) .color("var(--headertext)").opacity(person.notes ? 0.5 : 0.2) .fontStyle(person.notes ? "normal" : "italic") .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") .flex(1).minWidth(0) }) .alignItems("center").paddingHorizontal(0.5, em).overflow("hidden").flex(1) }) .attr({ style: `display: grid; grid-template-columns: ${this.gridTemplate};` }) .paddingHorizontal(0.75, em) .paddingVertical(0.58, em) .background(isSelected ? "var(--app)" : index % 2 !== 0 ? "var(--darkaccent)" : "transparent") .borderBottom("1px solid var(--divider)") .boxSizing("border-box") .alignItems("center") .onClick((done) => {if(!done) return; this.onSelect(person.id)}) } cell(value, isEmail = false) { HStack(() => { p(value || "—") .margin(0).fontSize(0.82, em) .color("var(--headertext)") .opacity(value ? (isEmail ? 0.6 : 0.75) : 0.22) .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") .flex(1).minWidth(0) }) .alignItems("center").paddingHorizontal(0.5, em).overflow("hidden") } renderAvatar(person, size) { if (person.image_path) { img(`${config.SERVER}${person.image_path}`, `${size}em`, `${size}em`) .borderRadius(50, pct).objectFit("cover").flexShrink(0) } else { const initials = [person.first_name?.[0], person.last_name?.[0]].filter(Boolean).join("").toUpperCase() || "?"; VStack(() => { p(initials) .margin(0).fontSize(size * 0.38, em).fontWeight("700") .color("white").lineHeight("1") }) .width(size, em).height(size, em).borderRadius(50, pct) .background(this.avatarColor(`${person.first_name} ${person.last_name}`)) .justifyContent("center").alignItems("center").flexShrink(0) } } formatPhone(phone) { if (!phone) return null; const d = phone.replace(/\D/g, ""); if (d.length === 10) return `${d.slice(0,3)}-${d.slice(3,6)}-${d.slice(6)}`; return phone; } formatDateShort(raw) { if (!raw) return "—"; return new Date(raw).toLocaleDateString([], { month: "short", day: "numeric", year: "2-digit" }); } capitalize(s) { return s ? s[0].toUpperCase() + s.slice(1) : s; } avatarColor(name) { const colors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"]; let hash = 0; for (let i = 0; i < (name || "").length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash); return colors[Math.abs(hash) % colors.length]; } } register(DesktopPeopleTable)