init
This commit is contained in:
414
politics/desktop/PoliticsElections.js
Normal file
414
politics/desktop/PoliticsElections.js
Normal file
@@ -0,0 +1,414 @@
|
||||
class PoliticsElections extends Shadow {
|
||||
constructor(elections, levelFilter, onVote, onThumbsUp) {
|
||||
super()
|
||||
this.elections = elections
|
||||
this.levelFilter = levelFilter
|
||||
this.onVote = onVote
|
||||
this.onThumbsUp = onThumbsUp
|
||||
// which election card is "expanded" for voting
|
||||
this.expandedId = null
|
||||
}
|
||||
|
||||
get visible() {
|
||||
let list = this.levelFilter === "all"
|
||||
? this.elections
|
||||
: this.elections.filter(e => e.level === this.levelFilter)
|
||||
return [...list].sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||
}
|
||||
|
||||
get upcoming() { return this.visible.filter(e => new Date(e.date) >= new Date()) }
|
||||
get past() { return this.visible.filter(e => new Date(e.date) < new Date()) }
|
||||
|
||||
render() {
|
||||
VStack(() => {
|
||||
// Header
|
||||
VStack(() => {
|
||||
h2("Elections")
|
||||
.margin(0).fontSize(1.1, em).fontWeight("700").color("var(--headertext)")
|
||||
p(`${this.upcoming.length} upcoming · ${this.past.length} recent`)
|
||||
.margin(0).marginTop(0.2, em).fontSize(0.78, em)
|
||||
.color("var(--headertext)").opacity(0.42)
|
||||
})
|
||||
.paddingHorizontal(1.75, em).paddingVertical(1.25, em)
|
||||
.borderBottom("1px solid var(--divider)").flexShrink(0)
|
||||
|
||||
VStack(() => {
|
||||
if (this.upcoming.length > 0) {
|
||||
this.sectionLabel("UPCOMING")
|
||||
this.upcoming.forEach(e => this.renderElectionCard(e, false))
|
||||
}
|
||||
|
||||
if (this.past.length > 0) {
|
||||
this.sectionLabel("RECENT")
|
||||
this.past.forEach(e => this.renderElectionCard(e, true))
|
||||
}
|
||||
|
||||
if (this.visible.length === 0) {
|
||||
VStack(() => {
|
||||
p("🗳")
|
||||
.margin(0).fontSize(2.2, em).opacity(0.18)
|
||||
p("No elections found for this level")
|
||||
.margin(0).marginTop(0.6, em).fontSize(0.88, em)
|
||||
.color("var(--headertext)").opacity(0.32)
|
||||
})
|
||||
.flex(1).justifyContent("center").alignItems("center").paddingTop(4, em)
|
||||
}
|
||||
})
|
||||
.overflowY("auto").flex(1)
|
||||
.paddingHorizontal(1.75, em).paddingBottom(2, em).paddingTop(0.5, em)
|
||||
})
|
||||
.height(100, pct).width(100, pct).overflow("hidden")
|
||||
}
|
||||
|
||||
renderElectionCard(election, isPast) {
|
||||
const isExpanded = this.expandedId === election.id
|
||||
const hasVoted = !!election.userVoteId
|
||||
const daysAway = Math.ceil((new Date(election.date) - new Date()) / 86400000)
|
||||
|
||||
VStack(() => {
|
||||
// ── Card top row ──────────────────────────────────────────
|
||||
HStack(() => {
|
||||
// Level badge
|
||||
VStack(() => {
|
||||
p(this.levelIcon(election.level))
|
||||
.margin(0).fontSize(0.8, em).lineHeight("1")
|
||||
})
|
||||
.width(2, em).height(2, em).borderRadius(0.38, em)
|
||||
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||
.justifyContent("center").alignItems("center").flexShrink(0)
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
p(election.title)
|
||||
.margin(0).fontSize(0.95, em).fontWeight("700")
|
||||
.color("var(--headertext)").flex(1).minWidth(0)
|
||||
|
||||
// Date chip
|
||||
HStack(() => {
|
||||
if (!isPast && daysAway <= 30) {
|
||||
p(`${daysAway}d away`)
|
||||
.margin(0).fontSize(0.68, em).fontWeight("700")
|
||||
.color(daysAway <= 7 ? "#ef4444" : "#f59e0b")
|
||||
} else {
|
||||
p(this.formatDate(election.date))
|
||||
.margin(0).fontSize(0.72, em)
|
||||
.color("var(--headertext)").opacity(0.4)
|
||||
}
|
||||
})
|
||||
.paddingHorizontal(0.55, em).paddingVertical(0.18, em)
|
||||
.background(
|
||||
!isPast && daysAway <= 7 ? "rgba(239,68,68,0.08)" :
|
||||
!isPast && daysAway <= 30 ? "rgba(245,158,11,0.08)" :
|
||||
"var(--darkaccent)"
|
||||
)
|
||||
.border(`1px solid ${
|
||||
!isPast && daysAway <= 7 ? "rgba(239,68,68,0.2)" :
|
||||
!isPast && daysAway <= 30 ? "rgba(245,158,11,0.2)" :
|
||||
"var(--divider)"
|
||||
}`)
|
||||
.borderRadius(100, px).flexShrink(0)
|
||||
})
|
||||
.alignItems("center").gap(0.5, em)
|
||||
|
||||
HStack(() => {
|
||||
p(this.capitalize(election.type))
|
||||
.margin(0).paddingHorizontal(0.45, em).paddingVertical(0.1, em)
|
||||
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||
.borderRadius(100, px).fontSize(0.68, em)
|
||||
.color("var(--headertext)").opacity(0.55)
|
||||
|
||||
p(this.capitalize(election.level))
|
||||
.margin(0).paddingHorizontal(0.45, em).paddingVertical(0.1, em)
|
||||
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||
.borderRadius(100, px).fontSize(0.68, em)
|
||||
.color("var(--headertext)").opacity(0.55)
|
||||
|
||||
if (election.description) {
|
||||
p(election.description)
|
||||
.margin(0).fontSize(0.75, em)
|
||||
.color("var(--headertext)").opacity(0.45)
|
||||
}
|
||||
})
|
||||
.gap(0.35, em).alignItems("center").marginTop(0.38, em)
|
||||
})
|
||||
.flex(1).minWidth(0).gap(0)
|
||||
})
|
||||
.gap(0.75, em).alignItems("flex-start")
|
||||
|
||||
// ── Candidates ────────────────────────────────────────────
|
||||
if (election.candidates.length > 0) {
|
||||
VStack(() => {
|
||||
// Candidate vs. row
|
||||
if (election.candidates.length >= 2) {
|
||||
this.renderCandidateMatchup(election, isPast)
|
||||
} else {
|
||||
// Single candidate / unopposed
|
||||
this.renderSingleCandidate(election.candidates[0], election, isPast)
|
||||
}
|
||||
})
|
||||
.marginTop(1, em)
|
||||
}
|
||||
|
||||
// ── Footer: thumbs up + weigh in ──────────────────────────
|
||||
HStack(() => {
|
||||
// Thumbs up / follow this race
|
||||
HStack(() => {
|
||||
button(election.userThumbsUp ? "👍" : "👍")
|
||||
.padding(0).border("none").background("transparent")
|
||||
.cursor("pointer").fontSize(0.95, em)
|
||||
.onClick((done) => {if(!done) return; this.onThumbsUp(election.id)})
|
||||
|
||||
p(election.thumbsUp > 0 ? `${election.thumbsUp}` : "Follow")
|
||||
.margin(0).fontSize(0.75, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(election.userThumbsUp ? 1 : 0.42)
|
||||
.fontWeight(election.userThumbsUp ? "600" : "400")
|
||||
})
|
||||
.gap(0.3, em).alignItems("center")
|
||||
.paddingHorizontal(0.65, em).paddingVertical(0.32, em)
|
||||
.background(election.userThumbsUp ? "rgba(59,130,246,0.08)" : "transparent")
|
||||
.border(`1px solid ${election.userThumbsUp ? "rgba(59,130,246,0.25)" : "var(--divider)"}`)
|
||||
.borderRadius(100, px).cursor("pointer")
|
||||
.onClick((done) => {if(!done) return; this.onThumbsUp(election.id)})
|
||||
|
||||
HStack(() => {}).flex(1)
|
||||
|
||||
if (!isPast && !hasVoted) {
|
||||
button(isExpanded ? "Close" : "Weigh In →")
|
||||
.paddingHorizontal(1, em).paddingVertical(0.38, em)
|
||||
.background(isExpanded ? "transparent" : "var(--quillred)")
|
||||
.border(isExpanded ? "1px solid var(--divider)" : "none")
|
||||
.borderRadius(0.45, em)
|
||||
.color(isExpanded ? "var(--headertext)" : "white")
|
||||
.fontSize(0.82, em).fontWeight("600").cursor("pointer")
|
||||
.opacity(isExpanded ? 0.6 : 1)
|
||||
.onClick((done) => {if(!done) return;
|
||||
this.expandedId = isExpanded ? null : election.id
|
||||
this.rerender()
|
||||
})
|
||||
} else if (hasVoted) {
|
||||
HStack(() => {
|
||||
p("✓ You weighed in")
|
||||
.margin(0).fontSize(0.75, em).fontWeight("600").color("#22c55e")
|
||||
})
|
||||
.paddingHorizontal(0.75, em).paddingVertical(0.32, em)
|
||||
.background("rgba(34,197,94,0.08)").border("1px solid rgba(34,197,94,0.2)")
|
||||
.borderRadius(100, px)
|
||||
}
|
||||
})
|
||||
.alignItems("center").marginTop(0.9, em)
|
||||
|
||||
// ── Expanded vote panel ───────────────────────────────────
|
||||
if (isExpanded && !hasVoted) {
|
||||
VStack(() => {
|
||||
p("Who do you support?")
|
||||
.margin(0).marginBottom(0.75, em).fontSize(0.8, em).fontWeight("600")
|
||||
.color("var(--headertext)").opacity(0.55)
|
||||
.textAlign("center")
|
||||
|
||||
HStack(() => {
|
||||
election.candidates.forEach((candidate, i) => {
|
||||
const color = this.partyColor(candidate.party)
|
||||
if (i > 0) {
|
||||
p("vs")
|
||||
.margin(0).paddingHorizontal(0.6, em)
|
||||
.fontSize(0.72, em).fontWeight("700")
|
||||
.color("var(--headertext)").opacity(0.3)
|
||||
.alignSelf("center")
|
||||
}
|
||||
button(() => {
|
||||
VStack(() => {
|
||||
p(candidate.initials || candidate.name[0])
|
||||
.margin(0).fontSize(0.9, em).fontWeight("800").color("white").lineHeight("1")
|
||||
})
|
||||
.width(2.2, em).height(2.2, em).borderRadius(50, pct)
|
||||
.background(color).justifyContent("center").alignItems("center")
|
||||
.marginBottom(0.5, em)
|
||||
|
||||
p(candidate.name)
|
||||
.margin(0).fontSize(0.82, em).fontWeight("700")
|
||||
.color("var(--headertext)").textAlign("center")
|
||||
|
||||
p(`${candidate.party}${candidate.incumbent ? " · Incumbent" : ""}`)
|
||||
.margin(0).marginTop(0.15, em).fontSize(0.68, em)
|
||||
.color(color).textAlign("center")
|
||||
})
|
||||
.flex(1)
|
||||
.paddingVertical(0.9, em)
|
||||
.paddingHorizontal(0.5, em)
|
||||
.background(`${color}0d`)
|
||||
.border(`2px solid ${color}33`)
|
||||
.borderRadius(0.65, em)
|
||||
.cursor("pointer")
|
||||
.display("flex").flexDirection("column").alignItems("center")
|
||||
.onClick((done) => {if(!done) return;
|
||||
this.expandedId = null
|
||||
this.onVote(election.id, candidate.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
.gap(0.5, em).width(100, pct)
|
||||
})
|
||||
.marginTop(0.85, em)
|
||||
.padding(1, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.65, em)
|
||||
.alignItems("center")
|
||||
}
|
||||
})
|
||||
.padding(1.2, em)
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.75, em)
|
||||
.marginBottom(0.85, em)
|
||||
.boxSizing("border-box")
|
||||
.opacity(isPast ? 0.72 : 1)
|
||||
}
|
||||
|
||||
renderCandidateMatchup(election, isPast) {
|
||||
const totalVotes = election.candidates.reduce((s, c) => s + (c.votes || 0), 0)
|
||||
|
||||
HStack(() => {
|
||||
election.candidates.slice(0, 4).forEach((candidate, i) => {
|
||||
const pct = totalVotes > 0 ? Math.round((candidate.votes / totalVotes) * 100) : null
|
||||
const color = this.partyColor(candidate.party)
|
||||
const isUserVote = election.userVoteId === candidate.id
|
||||
|
||||
if (i > 0) {
|
||||
VStack(() => {
|
||||
p("vs").margin(0).fontSize(0.68, em).fontWeight("700")
|
||||
.color("var(--headertext)").opacity(0.28)
|
||||
})
|
||||
.justifyContent("center").paddingHorizontal(0.5, em).flexShrink(0)
|
||||
}
|
||||
|
||||
VStack(() => {
|
||||
// Party color bar top
|
||||
VStack(() => {})
|
||||
.height(3, px).width(100, pct).background(color).flexShrink(0)
|
||||
|
||||
VStack(() => {
|
||||
// Name + incumbent
|
||||
HStack(() => {
|
||||
VStack(() => {
|
||||
p(candidate.initials || candidate.name.split(" ").map(w => w[0]).join(""))
|
||||
.margin(0).fontSize(0.72, em).fontWeight("800").color("white").lineHeight("1")
|
||||
})
|
||||
.width(1.9, em).height(1.9, em).borderRadius(50, pct)
|
||||
.background(color).justifyContent("center").alignItems("center").flexShrink(0)
|
||||
|
||||
VStack(() => {
|
||||
p(candidate.name)
|
||||
.margin(0).fontSize(0.82, em).fontWeight("700").color("var(--headertext)")
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
HStack(() => {
|
||||
p(candidate.party)
|
||||
.margin(0).paddingHorizontal(0.35, em).paddingVertical(0.08, em)
|
||||
.background(`${color}18`).border(`1px solid ${color}33`)
|
||||
.borderRadius(100, px).fontSize(0.62, em).fontWeight("700").color(color)
|
||||
if (candidate.incumbent) {
|
||||
p("Incumbent")
|
||||
.margin(0).paddingHorizontal(0.35, em).paddingVertical(0.08, em)
|
||||
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||||
.borderRadius(100, px).fontSize(0.62, em).color("var(--headertext)").opacity(0.45)
|
||||
}
|
||||
})
|
||||
.gap(0.25, em).alignItems("center").marginTop(0.18, em)
|
||||
})
|
||||
.flex(1).minWidth(0)
|
||||
})
|
||||
.gap(0.55, em).alignItems("center")
|
||||
|
||||
// Support bar
|
||||
if (pct !== null && totalVotes > 0) {
|
||||
VStack(() => {
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
VStack(() => {})
|
||||
.height(100, pct).width(pct, pct)
|
||||
.background(color).borderRadius(100, px)
|
||||
.transition("width 0.5s ease")
|
||||
})
|
||||
.height(100, pct).width(100, pct).alignItems("center")
|
||||
})
|
||||
.width(100, pct).height(6, px).background(`${color}22`).borderRadius(100, px).overflow("hidden")
|
||||
|
||||
HStack(() => {
|
||||
p(`${pct}% community support`)
|
||||
.margin(0).fontSize(0.68, em).color("var(--headertext)").opacity(0.42)
|
||||
if (isUserVote) {
|
||||
p("· Your pick ✓")
|
||||
.margin(0).fontSize(0.68, em).color(color).fontWeight("600")
|
||||
}
|
||||
})
|
||||
.gap(0.3, em).alignItems("center").marginTop(0.35, em)
|
||||
})
|
||||
.marginTop(0.65, em).width(100, pct)
|
||||
}
|
||||
})
|
||||
.padding(0.75, em).flex(1)
|
||||
})
|
||||
.flex(1).minWidth(0)
|
||||
.background("var(--darkaccent)")
|
||||
.border(`1px solid ${isUserVote ? color + "55" : "var(--divider)"}`)
|
||||
.borderRadius(0.55, em).overflow("hidden")
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
})
|
||||
.gap(0).alignItems("stretch").width(100, pct)
|
||||
}
|
||||
|
||||
renderSingleCandidate(candidate, election, isPast) {
|
||||
const color = this.partyColor(candidate.party)
|
||||
HStack(() => {
|
||||
VStack(() => {
|
||||
p(candidate.initials || candidate.name[0])
|
||||
.margin(0).fontSize(0.85, em).fontWeight("800").color("white").lineHeight("1")
|
||||
})
|
||||
.width(2.4, em).height(2.4, em).borderRadius(50, pct)
|
||||
.background(color).justifyContent("center").alignItems("center").flexShrink(0)
|
||||
|
||||
VStack(() => {
|
||||
p(candidate.name)
|
||||
.margin(0).fontSize(0.9, em).fontWeight("700").color("var(--headertext)")
|
||||
HStack(() => {
|
||||
p(candidate.party).margin(0).fontSize(0.7, em).fontWeight("600").color(color)
|
||||
if (candidate.incumbent) {
|
||||
p("· Incumbent").margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.42)
|
||||
}
|
||||
p("· Unopposed").margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.35)
|
||||
})
|
||||
.gap(0.35, em).alignItems("center").marginTop(0.2, em)
|
||||
})
|
||||
.flex(1)
|
||||
})
|
||||
.gap(0.75, em).alignItems("center")
|
||||
.padding(0.85, em)
|
||||
.background("var(--darkaccent)").border("1px solid var(--divider)").borderRadius(0.55, em)
|
||||
}
|
||||
|
||||
sectionLabel(text) {
|
||||
p(text)
|
||||
.margin(0).marginTop(0.85, em).marginBottom(0.65, em)
|
||||
.fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em")
|
||||
.color("var(--headertext)").opacity(0.35)
|
||||
}
|
||||
|
||||
levelIcon(level) {
|
||||
return { federal: "🇺🇸", state: "🏠", local: "📍" }[level] || "🗳"
|
||||
}
|
||||
|
||||
formatDate(raw) {
|
||||
return new Date(raw).toLocaleDateString([], { month: "long", day: "numeric", year: "numeric" })
|
||||
}
|
||||
|
||||
capitalize(s) {
|
||||
return s ? s[0].toUpperCase() + s.slice(1) : s
|
||||
}
|
||||
|
||||
partyColor(party) {
|
||||
return { R: "#ef4444", D: "#3b82f6", I: "#8b5cf6", L: "#f59e0b", G: "#22c55e" }[party] || "#6b7280"
|
||||
}
|
||||
}
|
||||
|
||||
register(PoliticsElections)
|
||||
Reference in New Issue
Block a user