import server from "/@server/server.js" css(` website- { font-family: 'Arial'; scrollbar-width: none; -ms-overflow-style: none; } website-::-webkit-scrollbar { display: none; } website- .stats-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } website- input::placeholder { color: var(--headertext); opacity: 0.35; } `) class Website extends Shadow { contact_form = [] join_form = [] activeTab = "overview" // "overview" | "contact" | "join" searchText = "" searchOpen = false selectedTx = null // ── helpers ──────────────────────────────────────────────────────────── formatTimeCustom(time) { if (!time) return "—" 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); } // ── computed ─────────────────────────────────────────────────────────── 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, thisMonth: thisMonth(this.contact_form).length + thisMonth(this.join_form).length, counties: counties.size, } } get currentList() { const source = this.activeTab === "contact" ? this.contact_form : this.join_form let list = source.map((e, i) => ({ ...e, _id: i })) .sort((a, b) => this.parseTime(b.time) - this.parseTime(a.time)) 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)) ) } return list } // ── render ───────────────────────────────────────────────────────────── render() { ZStack(() => { VStack(() => { this.renderHeader() this.renderTabs() if (this.activeTab === "overview") { this.renderOverview() } else { this.renderList() } }) .height(100, pct).width(100, pct) .boxSizing("border-box").overflow("hidden") if (this.selectedTx) { this.renderDetail(this.selectedTx) } }) .display("block") .height(100, pct).width(100, pct).overflow("hidden") .maxWidth(100, vw) .onAppear(() => this.getData()) } renderHeader() { if (this.searchOpen && this.activeTab !== "overview") { HStack(() => { input(this.searchText, "100%") .paddingHorizontal(0.75, em).paddingVertical(0.5, em) .background("var(--darkaccent)").border("1px solid var(--divider)") .borderRadius(0.65, em).fontSize(0.9, em) .color("var(--headertext)").outline("none") .attr({ autofocus: "true" }) .onInput(v => { this.searchText = v; this.rerender() }) }) .paddingHorizontal(1.1, em).paddingBottom(0.65, em) .width(100, pct).boxSizing("border-box").flexShrink(0) } } renderTabs() { HStack(() => { [["overview","Overview"],["contact","Contact"],["join","Join"]].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.9, em).paddingVertical(0.45, em) .borderRadius(100, px) .background(isActive ? "var(--darkaccent)" : "transparent") .border(`1px solid ${isActive ? "var(--divider)" : "transparent"}`) .onTouch((start) => { if (!start) { this.activeTab = key this.searchText = "" this.searchOpen = false this.selectedTx = null this.rerender() } }) }) }) .paddingHorizontal(1.1, em).paddingBottom(0.65, em) .gap(0.4, em).width(100, pct).boxSizing("border-box").flexShrink(0) } // ── overview ─────────────────────────────────────────────────────────── renderOverview() { VStack(() => { this.renderStats() // Divider div().height(1, px).background("var(--divider)") .marginHorizontal(1.1, em).marginBottom(0.85, em).flexShrink(0) // Recent Contact this.renderRecentSection("Recent Contact", this.contact_form, "contact") // Divider div().height(1, px).background("var(--divider)") .marginHorizontal(1.1, em).marginVertical(0.85, em).flexShrink(0) // Recent Join this.renderRecentSection("Recent Join Requests", this.join_form, "join") }) .flex(1).minHeight(0).overflowY("auto") .paddingBottom(1.5, em) } renderStats() { const s = this.stats const cards = [ { label: "Contact", value: String(s.totalContact), icon: "✉️" }, { label: "Join", value: String(s.totalJoin), icon: "🙋" }, { label: "This Month", value: String(s.thisMonth), icon: "📅" }, { label: "Counties", value: String(s.counties), icon: "📍" }, { label: "Page Visits", value: "—", icon: "👁️" }, { label: "Avg. Time", value: "—", icon: "⏱️" }, ] HStack(() => { div().width(1.1, em).flexShrink(0) cards.forEach(c => { VStack(() => { p(c.icon).margin(0).fontSize(1.1, em).lineHeight("1") p(c.value) .margin(0).marginTop(0.38, em) .fontSize(1.0, em).fontWeight("700") .color("var(--headertext)").lineHeight("1") .opacity(c.value === "—" ? 0.22 : 1) .whiteSpace("nowrap") p(c.label) .margin(0).marginTop(0.25, em) .fontSize(0.62, em).color("var(--headertext)").opacity(0.42) .whiteSpace("nowrap") }) .padding(0.85, em) .background("var(--darkaccent)").border("1px solid var(--divider)") .borderRadius(0.75, em).alignItems("center").flexShrink(0) }) div().width(1.1, em).flexShrink(0) }) .attr({ class: "stats-scroll" }) .paddingBottom(0.85, em).gap(0.6, em) .width(100, pct).boxSizing("border-box").flexShrink(0) } renderRecentSection(title, entries, tab) { const recent = [...entries] .sort((a, b) => this.parseTime(b.time) - this.parseTime(a.time)) .slice(0, 4) VStack(() => { HStack(() => { p(title) .margin(0).fontSize(0.72, em).fontWeight("700") .letterSpacing("0.04em").color("var(--headertext)").opacity(0.4) .flex(1) p("See all →") .margin(0).fontSize(0.72, em).color("var(--headertext)").opacity(0.38) .onTouch((start) => { if (!start) { this.activeTab = tab this.searchText = "" this.searchOpen = false this.rerender() } }) }) .alignItems("center").marginBottom(0.5, 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, i) => { HStack(() => { VStack(() => { p(`${e.fname || ""} ${e.lname || ""}`.trim() || e.email || "—") .margin(0).fontSize(0.88, 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.68, em) .color("var(--headertext)").opacity(0.32) .whiteSpace("nowrap").flexShrink(0) }) .paddingVertical(0.65, em) .borderBottom(i < recent.length - 1 ? "1px solid var(--divider)" : "none") .alignItems("center").gap(0.5, em) .onTouch((start, event) => { if(start) { this._touchStartY = event.touches[0].clientY; this._touchStartX = event.touches[0].clientX; } else { const dy = Math.abs(event.changedTouches[0].clientY - this._touchStartY); const dx = Math.abs(event.changedTouches[0].clientX - this._touchStartX); if (dy > 10 || dx > 10) return; // was a scroll, ignore this.selectedTx = { ...e, _tab: tab }; this.rerender(); } }) }) } }) .paddingHorizontal(1.1, em) .flexShrink(0) } // ── list ─────────────────────────────────────────────────────────────── renderList() { const rows = this.currentList const isContact = this.activeTab === "contact" VStack(() => { if (rows.length === 0) { VStack(() => { p(this.searchText ? "No results match your search" : "No submissions yet") .margin(0).fontSize(0.9, em) .color("var(--headertext)").opacity(0.35).textAlign("center") }).flex(1).justifyContent("center").alignItems("center") } else { rows.forEach(row => this.renderCard(row, isContact)) } }) .flex(1).minHeight(0).overflowY("auto") .paddingHorizontal(1.1, em).paddingBottom(1.5, em) .gap(0.55, em).width(100, pct).boxSizing("border-box") } renderCard(row, isContact) { HStack(() => { VStack(() => { p(`${row.fname || ""} ${row.lname || ""}`.trim() || row.email || "—") .margin(0).fontSize(0.9, em).fontWeight("600") .color("var(--headertext)") .whiteSpace("nowrap").overflow("hidden").textOverflow("ellipsis") p(row.county || "—") .margin(0).marginTop(0.28, em).fontSize(0.72, em) .color("var(--headertext)").opacity(0.45) .whiteSpace("nowrap") }) .flex(1).minWidth(0).alignItems("flex-start") VStack(() => { p(this.formatTimeCustom(row.time)) .margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.35) .whiteSpace("nowrap").textAlign("right") if (isContact && row.message) { p("Msg →") .margin(0).marginTop(0.28, em) .fontSize(0.68, em).fontWeight("600") .color("var(--quillred)") } }) .alignItems("flex-end").flexShrink(0) }) .padding(0.9, em) .background("var(--darkaccent)").border("1px solid var(--divider)") .borderRadius(0.75, em).alignItems("center") .width(100, pct).boxSizing("border-box") .onTouch((start) => { if (!start) { this.selectedTx = { ...row, _tab: this.activeTab } this.rerender() } }) } // ── detail sheet ──────────────────────────────────────────────────────── renderDetail(entry) { const name = `${entry.fname || ""} ${entry.lname || ""}`.trim() || entry.email || "Submission" VStack(() => { // Handle bar HStack(() => { div().width(2.5, em).height(4, px).borderRadius(100, px).background("var(--divider)") }) .justifyContent("center").paddingTop(0.75, em).paddingBottom(0.5, em).flexShrink(0) // Header HStack(() => { VStack(() => { p(name) .margin(0).fontSize(1.1, em).fontWeight("700") .color("var(--headertext)") p(entry._tab === "contact" ? "Contact Inquiry" : "Join Request") .margin(0).marginTop(0.18, em) .fontSize(0.72, em).fontWeight("600") .color(entry._tab === "contact" ? "var(--quillred)" : "#3b82f6") }) .flex(1).minWidth(0) div("✕") .fontSize(1, em).padding(0.4, em).borderRadius(50, pct) .background("var(--darkaccent)").border("1px solid var(--divider)") .color("var(--headertext)").opacity(0.6).flexShrink(0) .onTouch((start) => { if (!start) { this.selectedTx = null; this.rerender() } }) }) .paddingHorizontal(1.3, em).paddingBottom(1, em) .alignItems("center").gap(0.75, em).flexShrink(0) div().height(1, px).background("var(--divider)").marginHorizontal(1.3, em).flexShrink(0) // Detail rows VStack(() => { const fields = [ ["Date", this.formatTimeCustom(entry.time)], ["First", entry.fname], ["Last", entry.lname], ["Email", entry.email], ["Phone", this.formatPhone(entry.phone)], ["County", entry.county], ] fields.forEach(([label, value]) => this.detailRow(label, value)) if (entry.message) { VStack(() => { p("Message") .margin(0).fontSize(0.72, em).fontWeight("700") .letterSpacing("0.04em").color("var(--headertext)").opacity(0.38) p(entry.message) .margin(0).marginTop(0.4, em).fontSize(0.9, em) .color("var(--headertext)").opacity(0.82) .lineHeight("1.6").whiteSpace("pre-wrap") }) .paddingVertical(0.85, em) } }) .paddingHorizontal(1.3, em).paddingTop(0.5, em) .flex(1).overflowY("auto") }) .position("absolute") .bottom(0, px).left(0, px).right(0, px) .background("var(--main)") .borderTop("1px solid var(--divider)") .borderRadius("1.2em 1.2em 0 0") .boxShadow("0 -4px 24px rgba(0,0,0,0.12)") .zIndex(10).width(100, pct).boxSizing("border-box") .maxHeight(88, vh).overflowY("auto") } detailRow(label, value) { HStack(() => { p(label) .margin(0).fontSize(0.8, em) .color("var(--headertext)").opacity(0.42) .width(72, px).flexShrink(0) p(value || "—") .margin(0).fontSize(0.88, em).fontWeight("500") .color("var(--headertext)").opacity(value ? 0.88 : 0.28) .fontStyle(value ? "normal" : "italic") .flex(1).minWidth(0) .overflow("hidden").textOverflow("ellipsis").whiteSpace("nowrap") }) .paddingVertical(0.82, em) .borderBottom("1px solid var(--divider)") .alignItems("center") } 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 } 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)