import server from "/@server/server.js" css(` website- { font-family: 'Arial'; scrollbar-width: none; -ms-overflow-style: none; } website- input::placeholder { color: var(--headertext); opacity: 0.35; } website- textarea { font-family: 'Arial'; } `) class Website extends Shadow { contact_form = [] join_form = [] activeTab = "overview" // "overview" | "contact" | "join" searchText = "" sortKey = "time" sortDir = "desc" selectedId = null // selected row id for detail drawer // ── helpers ──────────────────────────────────────────────────────────── formatTimeCustom(time) { if (!time) return "—" // input: 01.28.2026-10:26:550910pm const match = time.match(/(\d{2})\.(\d{2})\.(\d{4})-(\d{1,2}):(\d{2}):\d+(am|pm)/i) if (!match) return time const [, month, day, year, hour, minute, ampm] = match return `${month}/${day}/${year.slice(2)} ${hour}:${minute}${ampm.toLowerCase()}` } parseTime(str) { const match = str.match(/(\d+)\.(\d+)\.(\d+)-(\d+):(\d+):(\d+)(\d{3})(am|pm)/i); const [, month, day, year, hours, minutes, seconds, ms, ampm] = match; let hrs = parseInt(hours); if (ampm.toLowerCase() === 'pm' && hrs !== 12) hrs += 12; if (ampm.toLowerCase() === 'am' && hrs === 12) hrs = 0; return new Date(year, month - 1, day, hrs, minutes, seconds, ms); } fmt(n) { return Number(n).toLocaleString("en-US") } // ── stats ────────────────────────────────────────────────────────────── get stats() { const now = new Date() const thisMonth = (entries) => entries.filter(e => { const d = this.parseTime(e.time) return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear() }) const counties = new Set([ ...this.contact_form.map(e => e.county).filter(Boolean), ...this.join_form.map(e => e.county).filter(Boolean) ]) return { totalContact: this.contact_form.length, totalJoin: this.join_form.length, monthContact: thisMonth(this.contact_form).length, monthJoin: thisMonth(this.join_form).length, counties: counties.size, } } get monthlyData() { const now = new Date() const months = [] for (let i = 5; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1) months.push({ label: d.toLocaleString("en-US", { month: "short" }), year: d.getFullYear(), month: d.getMonth(), contact: 0, join: 0 }) } const bucket = (entries, key) => entries.forEach(e => { const d = this.parseTime(e.time) const m = months.find(m => m.year === d.getFullYear() && m.month === d.getMonth()) if (m) m[key]++ }) bucket(this.contact_form, "contact") bucket(this.join_form, "join") return months } // ── sorted/filtered rows ─────────────────────────────────────────────── get rows() { const source = this.activeTab === "contact" ? this.contact_form : this.join_form let list = source.map((e, i) => ({ ...e, _id: i })) if (this.searchText) { const q = this.searchText.toLowerCase() list = list.filter(e => [e.fname, e.lname, e.email, e.phone, e.county, e.message] .some(v => v?.toLowerCase().includes(q)) ) } list.sort((a, b) => { let av, bv if (this.sortKey === "time") { av = this.parseTime(a.time); bv = this.parseTime(b.time) } else if (this.sortKey === "first") { av = a.fname || ""; bv = b.fname || "" } else if (this.sortKey === "last") { av = a.lname || ""; bv = b.lname || "" } else if (this.sortKey === "email") { av = a.email || ""; bv = b.email || "" } else if (this.sortKey === "county") { av = a.county || ""; bv = b.county || "" } else { av = a[this.sortKey] || ""; bv = b[this.sortKey] || "" } const cmp = typeof av === "string" ? av.localeCompare(bv) : av - bv return this.sortDir === "asc" ? cmp : -cmp }) return list } get selectedRow() { const source = this.activeTab === "contact" ? this.contact_form : this.join_form return this.selectedId !== null ? source[this.selectedId] : null } // ── render ───────────────────────────────────────────────────────────── render() { VStack(() => { this.renderToolbar() HStack(() => { // main content VStack(() => { if (this.activeTab === "overview") { this.renderOverview() } else { this.renderTableView() } }) .flex(1).minWidth(0).height(100, pct).overflow("hidden") // detail drawer (contact tab only) if (this.activeTab === "contact" && this.selectedRow) { this.renderDetailDrawer(this.selectedRow) } }) .flex(1).minHeight(0).overflow("hidden") }) .height(100, pct).width(100, pct).overflow("hidden") .onAppear(() => this.getData()) } renderToolbar() { HStack(() => { // Tab strip HStack(() => { [["overview","Overview"], ["contact","Contact Inquiries"], ["join","Join Requests"]] .forEach(([key, label]) => { const isActive = this.activeTab === key p(label) .margin(0).fontSize(0.8, em) .fontWeight(isActive ? "600" : "400") .color("var(--headertext)").opacity(isActive ? 1 : 0.5) .paddingHorizontal(0.75, em).paddingVertical(0.35, em) .borderRadius(0.35, em) .background(isActive ? "var(--app)" : "transparent") .cursor("pointer") .onClick((done) => { if (!done) return this.activeTab = key this.searchText = "" this.selectedId = null this.sortKey = "time" this.sortDir = "desc" this.rerender() }) }) }) .background("var(--darkaccent)").border("1px solid var(--divider)") .borderRadius(0.5, em).padding(0.2, em).gap(0.15, em) }) .marginTop(20, px) .paddingHorizontal(1.25, em) .paddingTop(1.1, em) .paddingBottom(0.85, em) .alignItems("center") .gap(0.75, em) .flexShrink(0) } // ── overview ─────────────────────────────────────────────────────────── renderOverview() { VStack(() => { // Stats row HStack(() => { const s = this.stats this.statCard("Contact Submissions", String(s.totalContact), "✉️") this.statCard("Join Requests", String(s.totalJoin), "🙋") this.statCard("This Month", String(s.monthContact + s.monthJoin), "📅") this.statCard("Counties Reached", String(s.counties), "📍") this.statCard("Page Visits", "—", "👁️") this.statCard("Avg. Time on Site", "—", "⏱️") }) .paddingHorizontal(1.25, em).paddingBottom(1, em) .gap(0.65, em).alignItems("stretch").flexShrink(0) VStack(() => {}).height(1, px).background("var(--divider)") .marginHorizontal(1.25, em).flexShrink(0) // Chart + recent columns HStack(() => { this.renderChart() VStack(() => {}).width(1, px).background("var(--divider)").flexShrink(0) // Recent submissions split HStack(() => { this.renderRecentColumn("Recent Contact", this.contact_form, "contact") VStack(() => {}).width(1, px).background("var(--divider)") this.renderRecentColumn("Recent Join Requests", this.join_form, "join") }) .flex(1).minWidth(0).height(100, pct) }) .flex(1).minHeight(0).gap(0).overflow("hidden") }) .height(100, pct).width(100, pct).overflow("hidden") .paddingTop(1, em) } statCard(label, value, icon) { VStack(() => { p(icon).margin(0).fontSize(1.05, em).lineHeight("1") p(value) .margin(0).marginTop(0.5, em) .fontSize(value === "—" ? 1.3 : 1.2, em).fontWeight("700") .color(value === "—" ? "var(--headertext)" : "var(--headertext)") .opacity(value === "—" ? 0.25 : 1) .lineHeight("1") p(label) .margin(0).marginTop(0.28, em) .fontSize(0.68, em).color("var(--headertext)").opacity(0.42) }) .padding(0.9, em) .background("var(--darkaccent)").border("1px solid var(--divider)") .borderRadius(0.6, em).minWidth(0).flex(1) } renderChart() { const months = this.monthlyData const max = Math.max(...months.map(m => m.contact + m.join), 1) VStack(() => { p("Monthly Submissions") .margin(0).marginBottom(0.6, em) .fontSize(0.68, em).fontWeight("700").letterSpacing("0.06em") .color("var(--headertext)").opacity(0.4) HStack(() => { months.forEach(m => { const total = m.contact + m.join const barPct = Math.max((total / max) * 100, total > 0 ? 3 : 0) VStack(() => { VStack(() => { if (m.join > 0) VStack(() => {}).flex(m.join).background("#3b82f6") if (m.contact > 0) VStack(() => {}).flex(m.contact).background("var(--quillred)") if (total === 0) VStack(() => {}).flex(1).background("var(--divider)") }) .height(barPct, pct).width(60, pct).borderRadius(3, px) .overflow("hidden").alignSelf("center") .minHeight(total === 0 ? 2 : 3, px) p(m.label) .margin(0).marginTop(0.4, em).fontSize(0.65, em) .color("var(--headertext)").opacity(0.4).textAlign("center") }) .flex(1).height(100, pct).alignItems("stretch") .justifyContent("flex-end").boxSizing("border-box") }) }) .flex(1).alignItems("flex-end").gap(0.3, em) HStack(() => { HStack(() => { VStack(() => {}).width(8, px).height(8, px).borderRadius(50, pct).background("var(--quillred)").flexShrink(0) p("Contact").margin(0).fontSize(0.65, em).color("var(--headertext)").opacity(0.45) }).gap(0.3, em).alignItems("center") HStack(() => { VStack(() => {}).width(8, px).height(8, px).borderRadius(50, pct).background("#3b82f6").flexShrink(0) p("Join").margin(0).fontSize(0.65, em).color("var(--headertext)").opacity(0.45) }).gap(0.3, em).alignItems("center") }).gap(0.85, em).marginTop(0.55, em).flexShrink(0) }) .padding(1, em).paddingLeft(1.25, em) .background("var(--darkaccent)").border("1px solid var(--divider)") .borderRadius(0.6, em).width(320, px).height(180, px) .boxSizing("border-box").flexShrink(0).margin(1.25, em) .marginRight(0) } renderRecentColumn(title, entries, tab) { const recent = [...entries] .sort((a, b) => this.parseTime(b.time) - this.parseTime(a.time)) .slice(0, 6) VStack(() => { HStack(() => { p(title) .margin(0).fontSize(0.7, em).fontWeight("700") .letterSpacing("0.05em").color("var(--headertext)").opacity(0.38) .flex(1) if (entries.length > 0) { p(`View all →`) .margin(0).fontSize(0.72, em).color("var(--headertext)").opacity(0.4) .cursor("pointer") .onClick((done) => { if (!done) return this.activeTab = tab this.searchText = "" this.selectedId = null this.rerender() }) } }) .alignItems("center").marginBottom(0.55, em) if (recent.length === 0) { p("No submissions yet") .margin(0).fontSize(0.82, em).color("var(--headertext)").opacity(0.28) .fontStyle("italic") } else { recent.forEach(e => { HStack(() => { VStack(() => { p(`${e.fname || ""} ${e.lname || ""}`.trim() || e.email || "—") .margin(0).fontSize(0.82, em).fontWeight("600") .color("var(--headertext)") .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") p(e.county || e.email || "—") .margin(0).marginTop(0.12, em).fontSize(0.72, em) .color("var(--headertext)").opacity(0.42) .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") }).flex(1).minWidth(0) p(this.formatTimeCustom(e.time)) .margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.35) .whiteSpace("nowrap").flexShrink(0) }) .paddingVertical(0.55, em) .borderBottom("1px solid var(--divider)") .alignItems("center").gap(0.5, em) }) } }) .padding(1.25, em).flex(1).minWidth(0).overflow("hidden") } // ── table view ───────────────────────────────────────────────────────── renderTableView() { const isContact = this.activeTab === "contact" const COLS = isContact ? [ { label: "Date", key: "time", width: "150px" }, { label: "First", key: "first", width: "130px" }, { label: "Last", key: "last", width: "130px" }, { label: "Email", key: "email", width: "1fr" }, { label: "Phone", key: "phone", width: "140px" }, { label: "County", key: "county", width: "130px" }, { label: "Message", key: "msg", width: "90px", sortable: false }, ] : [ { label: "Date", key: "time", width: "150px" }, { label: "First", key: "first", width: "130px" }, { label: "Last", key: "last", width: "130px" }, { label: "Email", key: "email", width: "1fr" }, { label: "Phone", key: "phone", width: "140px" }, { label: "County", key: "county", width: "130px" }, ] const gridCols = COLS.map(c => c.width).join(" ") const rows = this.rows VStack(() => { // Search bar HStack(() => { p("🔍").margin(0).fontSize(0.82, em).opacity(0.35).flexShrink(0) input("", "240px") .attr({ type: "text", placeholder: `Search ${isContact ? "contact inquiries" : "join requests"}…`, value: this.searchText }) .border("none").outline("none").background("transparent") .color("var(--headertext)").fontSize(0.85, em) .onInput((e) => { this.searchText = e.target.value; this.selectedId = null; this.rerender() }) }) .gap(0.5, em).paddingHorizontal(0.8, em).paddingVertical(0.52, em) .background("var(--darkaccent)").border("1px solid var(--divider)") .borderRadius(0.5, em).alignItems("center") .marginHorizontal(1.25, em).marginBottom(0.75, em).flexShrink(0) // Count p(`${rows.length} result${rows.length !== 1 ? "s" : ""}`) .margin(0).marginBottom(0.5, em) .paddingHorizontal(1.25, em) .fontSize(0.78, em).color("var(--headertext)").opacity(0.38) .flexShrink(0) // Header HStack(() => { COLS.forEach(col => { const isSort = this.sortKey === col.key HStack(() => { p(col.label + (isSort ? (this.sortDir === "asc" ? " ↑" : " ↓") : "")) .margin(0).fontSize(0.7, em).fontWeight("700").letterSpacing("0.04em") .color("var(--headertext)").opacity(isSort ? 0.85 : 0.38) .userSelect("none").whiteSpace("nowrap") }) .alignItems("center") .cursor(col.sortable === false ? "default" : "pointer") .onClick((done) => { if (!done || col.sortable === false) return if (this.sortKey === col.key) this.sortDir = this.sortDir === "asc" ? "desc" : "asc" else { this.sortKey = col.key; this.sortDir = "asc" } this.rerender() }) }) }) .attr({ style: `display: grid; grid-template-columns: ${gridCols};` }) .paddingHorizontal(1.25, em).paddingVertical(0.6, em) .borderTop("1px solid var(--divider)").borderBottom("1px solid var(--divider)") .flexShrink(0) // Rows VStack(() => { if (rows.length === 0) { VStack(() => { p("No results match your search") .margin(0).fontSize(0.9, em) .color("var(--headertext)").opacity(0.35).textAlign("center") }).flex(1).justifyContent("center").alignItems("center") } else { rows.forEach((row, i) => { const isSelected = this.selectedId === row._id HStack(() => { p(this.formatTimeCustom(row.time)) .margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.6) .whiteSpace("nowrap") p(row.fname || "—") .margin(0).fontSize(0.82, em).color("var(--headertext)") .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") p(row.lname || "—") .margin(0).fontSize(0.82, em).color("var(--headertext)") .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") p(row.email || "—") .margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.6) .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") .minWidth(0) p(this.formatPhone(row.phone)) .margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.55) .whiteSpace("nowrap") p(row.county || "—") .margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.55) .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") if (isContact) { p(row.message ? "View →" : "—") .margin(0).fontSize(0.78, em) .color(row.message ? "var(--quillred)" : "var(--headertext)") .opacity(row.message ? 1 : 0.22) .fontWeight(row.message ? "600" : "400") .cursor(row.message ? "pointer" : "default") } }) .attr({ style: `display: grid; grid-template-columns: ${gridCols}; align-items: center;` }) .paddingHorizontal(1.25, em).paddingVertical(0.6, em) .background(isSelected ? "var(--app)" : i % 2 !== 0 ? "var(--darkaccent)" : "transparent") .borderBottom("1px solid var(--divider)") .cursor("pointer") .onClick((done) => { if (!done) return this.selectedId = isSelected ? null : row._id this.rerender() }) }) } }) .flex(1).overflowY("auto") }) .height(100, pct).width(100, pct).overflow("hidden") .paddingTop(1, em) } renderDetailDrawer(row) { VStack(() => { // Header HStack(() => { p(`${row.fname || ""} ${row.lname || ""}`.trim() || row.email || "Contact") .margin(0).fontSize(0.92, 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.selectedId = null; this.rerender() }) }) .paddingHorizontal(1.25, em).paddingVertical(0.82, em) .borderBottom("1px solid var(--divider)") .alignItems("center").flexShrink(0) // Fields VStack(() => { const fields = [ ["Date", this.formatTimeCustom(row.time)], ["First", row.fname], ["Last", row.lname], ["Email", row.email], ["Phone", this.formatPhone(row.phone)], ["County", row.county], ] fields.forEach(([label, value]) => { VStack(() => { p(label) .margin(0).fontSize(0.68, em).fontWeight("700") .letterSpacing("0.04em").color("var(--headertext)").opacity(0.38) p(value || "—") .margin(0).marginTop(0.2, em).fontSize(0.88, em) .color("var(--headertext)").opacity(value ? 0.85 : 0.28) .fontStyle(value ? "normal" : "italic") }) .paddingVertical(0.72, em) .borderBottom("1px solid var(--divider)") }) if (row.message) { VStack(() => { p("Message") .margin(0).fontSize(0.68, em).fontWeight("700") .letterSpacing("0.04em").color("var(--headertext)").opacity(0.38) p(row.message) .margin(0).marginTop(0.35, em).fontSize(0.88, em) .color("var(--headertext)").opacity(0.82) .lineHeight("1.55") .whiteSpace("pre-wrap") }) .paddingVertical(0.72, em) } }) .paddingHorizontal(1.25, em) .flex(1).overflowY("auto") }) .width(320, px).height(100, pct) .borderLeft("1px solid var(--divider)") .flexShrink(0).overflow("hidden") } // ── data ─────────────────────────────────────────────────────────────── formatPhone(phone) { if (!phone) return "—" const d = phone.replace(/\D/g, "") if (d.length === 10) return `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}` return phone } async getData() { const data = await server.getSiteInfo(global.currentNetwork.abbreviation) if ( data.contact_form.length !== this.contact_form.length || data.join_form.length !== this.join_form.length ) { this.contact_form = data.contact_form || [] this.join_form = data.join_form || [] this.rerender() } } } register(Website)