init
This commit is contained in:
310
people/desktop/DesktopPeopleDetail.js
Normal file
310
people/desktop/DesktopPeopleDetail.js
Normal file
@@ -0,0 +1,310 @@
|
||||
import server from "/people/@server/index.js"
|
||||
|
||||
class DesktopPeopleDetail extends Shadow {
|
||||
constructor(person, onSaved) {
|
||||
super()
|
||||
this.person = person
|
||||
this.onSaved = onSaved
|
||||
this.editingNotes = false
|
||||
this.notesDraft = ""
|
||||
this.notesSaving = false
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.person) {
|
||||
VStack(() => {
|
||||
p("👤")
|
||||
.margin(0).fontSize(2.8, em).opacity(0.15)
|
||||
p("Select a member to view their profile")
|
||||
.margin(0).marginTop(0.75, em).fontSize(0.9, em)
|
||||
.color("var(--headertext)").opacity(0.32).textAlign("center")
|
||||
})
|
||||
.flex(1).height(100, pct).justifyContent("center").alignItems("center")
|
||||
return;
|
||||
}
|
||||
|
||||
const p_ = this.person;
|
||||
const isOnline = p_._online;
|
||||
const isAdmin = (p_.roles || []).includes("admin");
|
||||
|
||||
VStack(() => {
|
||||
// ── Profile header ────────────────────────────────────────
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
// Large avatar
|
||||
ZStack(() => {
|
||||
this.renderAvatar(p_, 5)
|
||||
VStack(() => {})
|
||||
.width(0.9, em).height(0.9, em).borderRadius(50, pct)
|
||||
.background(isOnline ? "#22c55e" : "var(--divider)")
|
||||
.boxSizing("border-box")
|
||||
.position("absolute").bottom(0.1, em).right(0.1, em)
|
||||
})
|
||||
.position("relative").flexShrink(0)
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
h2(`${p_.first_name} ${p_.last_name}`)
|
||||
.margin(0).fontSize(1.3, em).fontWeight("700")
|
||||
.color("var(--headertext)").lineHeight("1.2")
|
||||
|
||||
HStack(() => {
|
||||
if (isOnline) {
|
||||
p("● Online")
|
||||
.margin(0).fontSize(0.72, em).fontWeight("600").color("#22c55e")
|
||||
} else {
|
||||
p("○ Offline")
|
||||
.margin(0).fontSize(0.72, em).color("var(--headertext)").opacity(0.35)
|
||||
}
|
||||
})
|
||||
})
|
||||
.gap(0.75, em).alignItems("center")
|
||||
|
||||
if (p_.title) {
|
||||
p(p_.title)
|
||||
.margin(0).marginTop(0.22, em).fontSize(0.9, em)
|
||||
.color("var(--headertext)").opacity(0.55).fontWeight("400")
|
||||
}
|
||||
|
||||
// Role + tier badges
|
||||
HStack(() => {
|
||||
(p_.roles || []).forEach(role => {
|
||||
p(this.capitalize(role))
|
||||
.margin(0).paddingHorizontal(0.6, em).paddingVertical(0.2, em)
|
||||
.background(role === "admin" ? "rgba(239,68,68,0.12)" : "var(--darkaccent)")
|
||||
.border(`1px solid ${role === "admin" ? "rgba(239,68,68,0.25)" : "var(--divider)"}`)
|
||||
.borderRadius(100, px)
|
||||
.fontSize(0.72, em).fontWeight("600")
|
||||
.color(role === "admin" ? "#ef4444" : "var(--headertext)")
|
||||
.opacity(role === "admin" ? 1 : 0.65)
|
||||
})
|
||||
if (p_.plan_name?.includes("Patron")) {
|
||||
p("⭐ Patron")
|
||||
.margin(0).paddingHorizontal(0.6, em).paddingVertical(0.2, em)
|
||||
.background("rgba(245,158,11,0.1)")
|
||||
.border("1px solid rgba(245,158,11,0.25)")
|
||||
.borderRadius(100, px)
|
||||
.fontSize(0.72, em).fontWeight("600").color("#d97706")
|
||||
} else if (p_.plan_name?.includes("Annual")) {
|
||||
p("Regular")
|
||||
.margin(0).paddingHorizontal(0.6, em).paddingVertical(0.2, em)
|
||||
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||
.borderRadius(100, px)
|
||||
.fontSize(0.72, em).fontWeight("500")
|
||||
.color("var(--headertext)").opacity(0.55)
|
||||
}
|
||||
})
|
||||
.gap(0.4, em).flexWrap("wrap").marginTop(0.65, em)
|
||||
})
|
||||
.flex(1).minWidth(0)
|
||||
})
|
||||
.gap(1.25, em).alignItems("flex-start")
|
||||
})
|
||||
.paddingHorizontal(1.75, em).paddingTop(1.6, em).paddingBottom(1.35, em)
|
||||
.borderBottom("1px solid var(--divider)").flexShrink(0)
|
||||
|
||||
// ── Body ──────────────────────────────────────────────────
|
||||
VStack(() => {
|
||||
// Contact
|
||||
this.section("Contact", () => {
|
||||
this.infoRow("✉", "Email", p_.email)
|
||||
if (p_.phone) this.infoRow("📞", "Phone", this.formatPhone(p_.phone))
|
||||
})
|
||||
|
||||
// Location
|
||||
if (p_.city || p_.state || p_.county) {
|
||||
this.section("Location", () => {
|
||||
if (p_.city || p_.state) this.infoRow("📍", "City / State", [p_.city, p_.state].filter(Boolean).join(", "))
|
||||
if (p_.county) this.infoRow("🗺", "County", p_.county)
|
||||
})
|
||||
}
|
||||
|
||||
// Bio
|
||||
if (p_.bio) {
|
||||
this.section("Bio", () => {
|
||||
p(p_.bio)
|
||||
.margin(0).fontSize(0.88, em)
|
||||
.color("var(--headertext)").opacity(0.75)
|
||||
.lineHeight("1.6").whiteSpace("pre-wrap")
|
||||
})
|
||||
}
|
||||
|
||||
// Membership
|
||||
this.section("Membership", () => {
|
||||
this.infoRow("📅", "Joined", this.formatDate(p_.joined_network || p_.created))
|
||||
if (p_.subscription_status) this.infoRow("💳", "Status", this.capitalize(p_.subscription_status))
|
||||
})
|
||||
|
||||
// Notes (editable)
|
||||
this.section("Notes", () => {
|
||||
if (this.editingNotes) {
|
||||
VStack(() => {
|
||||
textarea(p_.notes || "")
|
||||
.attr({ rows: 5, id: "notes-textarea" })
|
||||
.width(100, pct)
|
||||
.padding(0.65, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.45, em)
|
||||
.color("var(--headertext)")
|
||||
.fontSize(0.88, em)
|
||||
.lineHeight("1.6")
|
||||
.outline("none")
|
||||
.resize("vertical")
|
||||
.boxSizing("border-box")
|
||||
.onInput((e) => { this.notesDraft = e.target.value; })
|
||||
.onAppear(function() { this.value = this.placeholder; } )
|
||||
|
||||
HStack(() => {
|
||||
button("Cancel")
|
||||
.paddingHorizontal(0.9, em).paddingVertical(0.4, em)
|
||||
.background("transparent")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.4, em)
|
||||
.color("var(--headertext)").fontSize(0.82, em)
|
||||
.cursor("pointer").opacity(0.65)
|
||||
.onClick((done) => {if(!done) return; this.editingNotes = false; this.notesDraft = ""; this.rerender(); })
|
||||
|
||||
button(this.notesSaving ? "Saving…" : "Save")
|
||||
.attr({ type: "button" })
|
||||
.paddingHorizontal(0.9, em).paddingVertical(0.4, em)
|
||||
.background("var(--quillred)").border("none")
|
||||
.borderRadius(0.4, em)
|
||||
.color("white").fontSize(0.82, em).fontWeight("600")
|
||||
.cursor("pointer")
|
||||
.onClick((done) => { if (!done) return; this.saveNotes() })
|
||||
})
|
||||
.gap(0.5, em).marginTop(0.65, em).justifyContent("flex-end")
|
||||
})
|
||||
.width(100, pct)
|
||||
} else {
|
||||
VStack(() => {
|
||||
if (p_.notes) {
|
||||
p(p_.notes)
|
||||
.margin(0).fontSize(0.88, em)
|
||||
.color("var(--headertext)").opacity(0.75)
|
||||
.lineHeight("1.6").whiteSpace("pre-wrap")
|
||||
} else {
|
||||
p("No notes yet. Click Edit to add one.")
|
||||
.margin(0).fontSize(0.85, em)
|
||||
.color("var(--headertext)").opacity(0.3)
|
||||
.fontStyle("italic")
|
||||
}
|
||||
|
||||
button("Edit notes")
|
||||
.marginTop(0.65, em)
|
||||
.paddingHorizontal(0.9, em).paddingVertical(0.38, em)
|
||||
.background("transparent").border("1px solid var(--divider)")
|
||||
.borderRadius(0.4, em).color("var(--headertext)")
|
||||
.fontSize(0.8, em).cursor("pointer").opacity(0.6)
|
||||
.onClick((done) => {if(!done) return;
|
||||
this.notesDraft = p_.notes || "";
|
||||
this.editingNotes = true;
|
||||
this.rerender();
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
}
|
||||
})
|
||||
})
|
||||
.paddingHorizontal(1.75, em).paddingTop(0).paddingBottom(2, em)
|
||||
.overflowY("auto").flex(1).gap(0)
|
||||
})
|
||||
.height(100, pct).width(100, pct).overflow("hidden").boxSizing("border-box")
|
||||
}
|
||||
|
||||
section(title, contentFn) {
|
||||
VStack(() => {
|
||||
p(title.toUpperCase())
|
||||
.margin(0).marginBottom(0.7, em)
|
||||
.fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em")
|
||||
.color("var(--headertext)").opacity(0.35)
|
||||
contentFn()
|
||||
})
|
||||
.paddingTop(1.25, em).paddingBottom(0.5, em)
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.width(100, pct).boxSizing("border-box")
|
||||
}
|
||||
|
||||
infoRow(icon, label, value) {
|
||||
HStack(() => {
|
||||
p(icon)
|
||||
.margin(0).width(1.1, em).fontSize(0.85, em)
|
||||
.textAlign("center").flexShrink(0).opacity(0.6)
|
||||
p(label)
|
||||
.margin(0).fontSize(0.82, em)
|
||||
.color("var(--headertext)").opacity(0.42)
|
||||
.width(6.5, em).flexShrink(0)
|
||||
p(value)
|
||||
.margin(0).fontSize(0.85, em)
|
||||
.color("var(--headertext)").fontWeight("500")
|
||||
.flex(1).minWidth(0)
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
})
|
||||
.gap(0.55, em).paddingVertical(0.42, em)
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.alignItems("center").width(100, pct)
|
||||
}
|
||||
|
||||
renderAvatar(person, size) {
|
||||
if (person.image_path) {
|
||||
img(`${config.UI}${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.32, 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)
|
||||
}
|
||||
}
|
||||
|
||||
async saveNotes() {
|
||||
if (this.notesSaving) return;
|
||||
this.notesSaving = true;
|
||||
|
||||
const result = await server.saveMemberNote(this.person.email, this.notesDraft);
|
||||
if (result?.success) {
|
||||
this.person.notes = this.notesDraft;
|
||||
this.notesSaving = false;
|
||||
this.editingNotes = false;
|
||||
this.notesDraft = "";
|
||||
this.onSaved(this.person);
|
||||
} else {
|
||||
this.notesSaving = false;
|
||||
this.editingNotes = false;
|
||||
this.notesDraft = "";
|
||||
this.rerender();
|
||||
}
|
||||
}
|
||||
|
||||
formatPhone(phone) {
|
||||
const d = phone.replace(/\D/g, "");
|
||||
if (d.length === 10) return `${d.slice(0,3)}-${d.slice(3,6)}-${d.slice(6)}`;
|
||||
if (d.length === 11) return `${d.slice(0,1)}-${d.slice(1,4)}-${d.slice(4,7)}-${d.slice(7)}`;
|
||||
return phone;
|
||||
}
|
||||
|
||||
formatDate(raw) {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
return d.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
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(DesktopPeopleDetail)
|
||||
101
people/desktop/DesktopPeopleList.js
Normal file
101
people/desktop/DesktopPeopleList.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import "../../components/Avatar.js"
|
||||
|
||||
class DesktopPeopleList extends Shadow {
|
||||
constructor(people, selectedId, onSelect) {
|
||||
super()
|
||||
this.people = people
|
||||
this.selectedId = selectedId
|
||||
this.onSelect = onSelect
|
||||
}
|
||||
|
||||
render() {
|
||||
VStack(() => {
|
||||
if (this.people.length === 0) {
|
||||
VStack(() => {
|
||||
p("No members match your search")
|
||||
.margin(0).fontSize(0.88, em)
|
||||
.color("var(--headertext)").opacity(0.35).textAlign("center")
|
||||
})
|
||||
.flex(1).justifyContent("center").alignItems("center")
|
||||
return;
|
||||
}
|
||||
|
||||
this.people.forEach(person => this.renderRow(person))
|
||||
})
|
||||
.gap(0).overflowY("auto").height(100, pct)
|
||||
.paddingVertical(0.5, em).boxSizing("border-box")
|
||||
}
|
||||
|
||||
renderRow(person) {
|
||||
const isSelected = person.id === this.selectedId;
|
||||
const isOnline = person._online;
|
||||
const isAdmin = (person.roles || []).includes("admin");
|
||||
|
||||
HStack(() => {
|
||||
// Avatar
|
||||
ZStack(() => {
|
||||
Avatar(person, 2.6)
|
||||
|
||||
// Online indicator
|
||||
VStack(() => {})
|
||||
.width(0.62, em).height(0.62, em)
|
||||
.borderRadius(50, pct)
|
||||
.background(isOnline ? "#22c55e" : "var(--divider)")
|
||||
.boxSizing("border-box")
|
||||
.position("absolute").bottom(0).right(0)
|
||||
})
|
||||
.position("relative")
|
||||
.width(2.6, em).height(2.6, em).flexShrink(0)
|
||||
|
||||
// Info
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
p(`${person.first_name} ${person.last_name}`)
|
||||
.margin(0).fontSize(0.88, em).fontWeight("600")
|
||||
.color("var(--headertext)").flex(1).minWidth(0)
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
|
||||
if (isAdmin) {
|
||||
p("Admin")
|
||||
.margin(0).paddingHorizontal(0.42, em).paddingVertical(0.1, em)
|
||||
.background("rgba(239,68,68,0.12)").borderRadius(100, px)
|
||||
.fontSize(0.62, em).fontWeight("700").color("#ef4444")
|
||||
.flexShrink(0)
|
||||
}
|
||||
})
|
||||
.alignItems("center").gap(0.4, em).width(100, pct)
|
||||
|
||||
HStack(() => {
|
||||
p(person.title || person.email)
|
||||
.margin(0).fontSize(0.75, em)
|
||||
.color("var(--headertext)").opacity(0.45)
|
||||
.flex(1).minWidth(0)
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
|
||||
if (person.plan_name?.includes("Patron")) {
|
||||
p("Patron")
|
||||
.margin(0).paddingHorizontal(0.42, em).paddingVertical(0.1, em)
|
||||
.background("rgba(245,158,11,0.12)").borderRadius(100, px)
|
||||
.fontSize(0.62, em).fontWeight("700").color("#d97706")
|
||||
.flexShrink(0)
|
||||
}
|
||||
})
|
||||
.alignItems("center").gap(0.4, em).width(100, pct).marginTop(0.15, em)
|
||||
})
|
||||
.flex(1).minWidth(0)
|
||||
})
|
||||
.gap(0.7, em).paddingHorizontal(0.9, em).paddingVertical(0.62, em)
|
||||
.marginHorizontal(0.4, em)
|
||||
.borderRadius(0.55, em)
|
||||
.background(isSelected ? "var(--accent)" : "transparent")
|
||||
.cursor("pointer").alignItems("center")
|
||||
.width("calc(100% - 0.8em)").boxSizing("border-box")
|
||||
.onClick((done) => {
|
||||
if(done)
|
||||
this.onSelect(person.id)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
register(DesktopPeopleList)
|
||||
263
people/desktop/DesktopPeopleTable.js
Normal file
263
people/desktop/DesktopPeopleTable.js
Normal file
@@ -0,0 +1,263 @@
|
||||
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)
|
||||
100
people/desktop/DesktopPeopleToolbar.js
Normal file
100
people/desktop/DesktopPeopleToolbar.js
Normal file
@@ -0,0 +1,100 @@
|
||||
class DesktopPeopleToolbar extends Shadow {
|
||||
constructor(people, searchText, filterRole, filterTier, onSearch, onFilterRole, onFilterTier) {
|
||||
super()
|
||||
this.people = people
|
||||
this.searchText = searchText
|
||||
this.filterRole = filterRole
|
||||
this.filterTier = filterTier
|
||||
this.onSearch = onSearch
|
||||
this.onFilterRole = onFilterRole
|
||||
this.onFilterTier = onFilterTier
|
||||
}
|
||||
|
||||
get allRoles() {
|
||||
const roles = new Set();
|
||||
this.people.forEach(p => (p.roles || []).forEach(r => roles.add(r)));
|
||||
return [...roles].sort();
|
||||
}
|
||||
|
||||
render() {
|
||||
const online = this.people.filter(p => p._online).length;
|
||||
const total = this.people.length;
|
||||
|
||||
HStack(() => {
|
||||
// Counts
|
||||
HStack(() => {
|
||||
p(`${total} member${total !== 1 ? "s" : ""}`)
|
||||
.margin(0)
|
||||
.fontSize(0.88, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
|
||||
if (online > 0) {
|
||||
HStack(() => {
|
||||
VStack(() => {})
|
||||
.width(0.42, em).height(0.42, em).borderRadius(50, pct)
|
||||
.background("#22c55e").flexShrink(0)
|
||||
p(`${online} online`)
|
||||
.margin(0).fontSize(0.78, em).color("#22c55e").fontWeight("500")
|
||||
})
|
||||
.gap(0.3, em).alignItems("center")
|
||||
.paddingHorizontal(0.6, em).paddingVertical(0.18, em)
|
||||
.background("rgba(34,197,94,0.08)")
|
||||
.border("1px solid rgba(34,197,94,0.2)")
|
||||
.borderRadius(100, px)
|
||||
}
|
||||
})
|
||||
.marginTop(20, px)
|
||||
.gap(0.75, em).alignItems("center").flex(1)
|
||||
|
||||
// Search
|
||||
HStack(() => {
|
||||
p("🔍")
|
||||
.margin(0).fontSize(0.8, em).opacity(0.38).flexShrink(0)
|
||||
input("", "200px")
|
||||
.attr({ type: "text", placeholder: "Search members…", value: this.searchText })
|
||||
.border("none").outline("none").background("transparent")
|
||||
.color("var(--headertext)").fontSize(0.85, em)
|
||||
.onInput((e) => this.onSearch(e.target.value))
|
||||
})
|
||||
.gap(0.5, em).paddingHorizontal(0.8, em)
|
||||
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||
.borderRadius(0.5, em).alignItems("center")
|
||||
|
||||
// Role filter
|
||||
this.filterSelect(
|
||||
[{ label: "All Roles", value: "" }, ...this.allRoles.map(r => ({ label: this.capitalize(r), value: r }))],
|
||||
this.filterRole, this.onFilterRole
|
||||
)
|
||||
|
||||
// Tier filter
|
||||
this.filterSelect(
|
||||
[{ label: "All Tiers", value: "" }, { label: "Regular", value: "1" }, { label: "Patron", value: "2" }],
|
||||
this.filterTier, this.onFilterTier
|
||||
)
|
||||
})
|
||||
.gap(0.65, em).paddingHorizontal(1.5, em).paddingVertical(0.85, em)
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.alignItems("center").width(100, pct).boxSizing("border-box").flexShrink(0)
|
||||
}
|
||||
|
||||
filterSelect(options, value, onChange) {
|
||||
select(() => {
|
||||
options.forEach(opt => {
|
||||
option(opt.label)
|
||||
.attr({ value: opt.value, ...(opt.value === value ? { selected: "" } : {}) })
|
||||
})
|
||||
})
|
||||
.paddingVertical(0.52, em).paddingHorizontal(0.75, em)
|
||||
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||
.borderRadius(0.5, em).color("var(--headertext)").fontSize(0.85, em)
|
||||
.outline("none").cursor("pointer").flexShrink(0)
|
||||
.onChange((e) => onChange(e.target.value))
|
||||
}
|
||||
|
||||
capitalize(s) {
|
||||
return s ? s[0].toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
}
|
||||
|
||||
register(DesktopPeopleToolbar)
|
||||
203
people/desktop/people.js
Normal file
203
people/desktop/people.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import "./DesktopPeopleToolbar.js"
|
||||
import "./DesktopPeopleTable.js"
|
||||
import "./DesktopPeopleDetail.js"
|
||||
import server from "/people/@server/index.js"
|
||||
|
||||
css(`
|
||||
people- {
|
||||
font-family: 'Arial';
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
people- select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.65em center;
|
||||
padding-right: 1.8em !important;
|
||||
}
|
||||
people- textarea {
|
||||
font-family: 'Arial';
|
||||
}
|
||||
people- input::placeholder {
|
||||
color: var(--headertext);
|
||||
opacity: 0.35;
|
||||
}
|
||||
`)
|
||||
|
||||
class People extends Shadow {
|
||||
|
||||
_people = (global.currentNetwork.data.members || []).map(p => ({ ...p, _online: this.isOnline(p) }))
|
||||
#lastUpdated;
|
||||
|
||||
selectedMemberId = null
|
||||
searchText = ""
|
||||
filterRole = ""
|
||||
filterTier = ""
|
||||
sortKey = "joined"
|
||||
sortDir = "asc"
|
||||
tableEl = null
|
||||
|
||||
get people() { return this._people }
|
||||
set people(val) {
|
||||
this._people = val
|
||||
this.#lastUpdated = Date.now()
|
||||
global.currentNetwork.data.members = val
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
isOnline(member) {
|
||||
return global.socket.connectedUsers.includes(member.email)
|
||||
}
|
||||
|
||||
get filteredSortedPeople() {
|
||||
let list = this.people;
|
||||
|
||||
if (this.searchText) {
|
||||
const q = this.searchText.toLowerCase();
|
||||
list = list.filter(p =>
|
||||
[p.first_name, p.last_name, p.email, p.title, p.county, p.city, p.state]
|
||||
.some(v => v?.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
if (this.filterRole) {
|
||||
list = list.filter(p => (p.roles || []).includes(this.filterRole));
|
||||
}
|
||||
|
||||
if (this.filterTier) {
|
||||
list = list.filter(p => String(p.plan_name) === this.filterTier);
|
||||
}
|
||||
|
||||
// Sort
|
||||
const key = this.sortKey;
|
||||
const dir = this.sortDir === "asc" ? 1 : -1;
|
||||
list = [...list].sort((a, b) => {
|
||||
let av, bv;
|
||||
if (key === "name") { av = `${a.first_name} ${a.last_name}`; bv = `${b.first_name} ${b.last_name}`; }
|
||||
else if (key === "email") { av = a.email || ""; bv = b.email || ""; }
|
||||
else if (key === "title") { av = a.title || ""; bv = b.title || ""; }
|
||||
else if (key === "county") { av = a.county || ""; bv = b.county || ""; }
|
||||
else if (key === "roles") { av = (a.roles || [])[0] || ""; bv = (b.roles || [])[0] || ""; }
|
||||
else if (key === "tier") { av = a.plan_name || 0; bv = b.plan_name || 0; }
|
||||
else if (key === "joined") {
|
||||
av = new Date(a.joined_network || a.created || 0).getTime();
|
||||
bv = new Date(b.joined_network || b.created || 0).getTime();
|
||||
}
|
||||
if (typeof av === "string") return av.localeCompare(bv) * dir;
|
||||
return (av - bv) * dir;
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
get selectedMember() {
|
||||
return this.people.find(p => p.id === this.selectedMemberId) || null;
|
||||
}
|
||||
|
||||
handleSort(key) {
|
||||
if (this.sortKey === key) {
|
||||
this.sortDir = this.sortDir === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
this.sortKey = key;
|
||||
this.sortDir = "asc";
|
||||
}
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
render() {
|
||||
const filtered = this.filteredSortedPeople;
|
||||
const detailOpen = !!this.selectedMember;
|
||||
|
||||
VStack(() => {
|
||||
DesktopPeopleToolbar(
|
||||
filtered,
|
||||
this.searchText,
|
||||
this.filterRole,
|
||||
this.filterTier,
|
||||
(text) => {
|
||||
this.searchText = text
|
||||
this.tableEl.people = this.filteredSortedPeople
|
||||
this.tableEl.rerender()
|
||||
},
|
||||
(role) => { this.filterRole = role; this.rerender(); },
|
||||
(tier) => { this.filterTier = tier; this.rerender(); }
|
||||
)
|
||||
|
||||
HStack(() => {
|
||||
// Table — shrinks when drawer open
|
||||
VStack(() => {
|
||||
this.tableEl = DesktopPeopleTable(
|
||||
filtered,
|
||||
this.selectedMemberId,
|
||||
this.sortKey,
|
||||
this.sortDir,
|
||||
(id) => {
|
||||
this.selectedMemberId = this.selectedMemberId === id ? null : id;
|
||||
this.rerender();
|
||||
},
|
||||
(key) => this.handleSort(key)
|
||||
)
|
||||
})
|
||||
.flex(1).height(100, pct).overflow("hidden")
|
||||
|
||||
// Slide-out detail drawer
|
||||
if (detailOpen) {
|
||||
VStack(() => {
|
||||
// Drawer header with close button
|
||||
HStack(() => {
|
||||
p(`${this.selectedMember.first_name} ${this.selectedMember.last_name}`)
|
||||
.margin(0).fontSize(0.88, em).fontWeight("600")
|
||||
.color("var(--headertext)").flex(1).minWidth(0)
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
|
||||
button("✕")
|
||||
.border("none").background("transparent")
|
||||
.color("var(--headertext)").opacity(0.4)
|
||||
.fontSize(0.82, em).cursor("pointer").padding(0.25, em)
|
||||
.borderRadius(0.3, em)
|
||||
.onClick((done) => {if(!done) return; this.selectedMemberId = null; this.rerender(); })
|
||||
})
|
||||
.paddingHorizontal(1.25, em).paddingVertical(0.78, em)
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.alignItems("center").flexShrink(0)
|
||||
|
||||
VStack(() => {
|
||||
DesktopPeopleDetail(this.selectedMember,
|
||||
(updatedPerson) => this.updatePeople(updatedPerson)
|
||||
)
|
||||
})
|
||||
.flex(1).overflow("hidden")
|
||||
})
|
||||
.width(360, px)
|
||||
.height(100, pct)
|
||||
.borderLeft("1px solid var(--divider)")
|
||||
.flexShrink(0)
|
||||
.overflow("hidden")
|
||||
}
|
||||
})
|
||||
.flex(1).minHeight(0).width(100, pct).overflow("hidden")
|
||||
})
|
||||
.height(100, pct).width(100, pct).overflow("hidden")
|
||||
.onAppear(async () => {
|
||||
const res = await server.getPeople(global.currentNetwork.id);
|
||||
if (!res.error && res.length > 0) {
|
||||
if((this.people.length !== res.length) || !this.#lastUpdated) {
|
||||
this.people = res.map(p => ({ ...p, _online: this.isOnline(p) }));
|
||||
this.rerender();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
updatePeople(person) {
|
||||
this.people = this.people.map(p => p.id === person.id ? person : p)
|
||||
this.rerender()
|
||||
}
|
||||
}
|
||||
|
||||
register(People)
|
||||
Reference in New Issue
Block a user