init
This commit is contained in:
465
website/website.js
Normal file
465
website/website.js
Normal file
@@ -0,0 +1,465 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user