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)
|
||||
214
politics/desktop/PoliticsRepresentatives.js
Normal file
214
politics/desktop/PoliticsRepresentatives.js
Normal file
@@ -0,0 +1,214 @@
|
||||
class PoliticsRepresentatives extends Shadow {
|
||||
constructor(reps, levelFilter) {
|
||||
super()
|
||||
this.reps = reps
|
||||
this.levelFilter = levelFilter
|
||||
}
|
||||
|
||||
get visible() {
|
||||
if (this.levelFilter === "all") return this.reps
|
||||
return this.reps.filter(r => r.level === this.levelFilter)
|
||||
}
|
||||
|
||||
get grouped() {
|
||||
const order = ["federal", "state", "local"]
|
||||
const map = {}
|
||||
order.forEach(l => { map[l] = [] })
|
||||
this.visible.forEach(r => {
|
||||
if (map[r.level]) map[r.level].push(r)
|
||||
})
|
||||
return order.map(l => ({ level: l, reps: map[l] })).filter(g => g.reps.length)
|
||||
}
|
||||
|
||||
render() {
|
||||
VStack(() => {
|
||||
// Header
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
VStack(() => {
|
||||
h2("Your Representatives")
|
||||
.margin(0).fontSize(1.1, em).fontWeight("700").color("var(--headertext)")
|
||||
p(`${this.visible.length} officials across your districts`)
|
||||
.margin(0).marginTop(0.2, em).fontSize(0.78, em)
|
||||
.color("var(--headertext)").opacity(0.42)
|
||||
})
|
||||
|
||||
// Party breakdown pill
|
||||
HStack(() => {
|
||||
const counts = { R: 0, D: 0, I: 0 }
|
||||
this.visible.forEach(r => { counts[r.party] = (counts[r.party] || 0) + 1 })
|
||||
if (counts.R) this.partyCount(counts.R, "R")
|
||||
if (counts.D) this.partyCount(counts.D, "D")
|
||||
if (counts.I) this.partyCount(counts.I, "I")
|
||||
})
|
||||
.gap(0.5, em).alignItems("center")
|
||||
})
|
||||
.justifyContent("space-between").alignItems("flex-start")
|
||||
})
|
||||
.paddingHorizontal(1.75, em).paddingVertical(1.25, em)
|
||||
.borderBottom("1px solid var(--divider)").flexShrink(0)
|
||||
|
||||
// Groups
|
||||
VStack(() => {
|
||||
this.grouped.forEach(group => {
|
||||
VStack(() => {
|
||||
// Level header
|
||||
HStack(() => {
|
||||
HStack(() => {})
|
||||
.flex(1).height(1, px).background("var(--divider)")
|
||||
p(this.levelLabel(group.level))
|
||||
.margin(0).marginHorizontal(0.9, em)
|
||||
.fontSize(0.68, em).fontWeight("700").letterSpacing("0.08em")
|
||||
.color("var(--headertext)").opacity(0.35)
|
||||
.whiteSpace("nowrap")
|
||||
HStack(() => {})
|
||||
.flex(1).height(1, px).background("var(--divider)")
|
||||
})
|
||||
.alignItems("center").marginBottom(1, em)
|
||||
|
||||
// Cards
|
||||
HStack(() => {
|
||||
group.reps.forEach(rep => this.renderCard(rep))
|
||||
})
|
||||
.gap(0.85, em).flexWrap("wrap")
|
||||
})
|
||||
.paddingTop(1.35, em).paddingBottom(0.5, em)
|
||||
})
|
||||
})
|
||||
.paddingHorizontal(1.75, em)
|
||||
.overflowY("auto").flex(1)
|
||||
})
|
||||
.height(100, pct).width(100, pct).overflow("hidden")
|
||||
}
|
||||
|
||||
renderCard(rep) {
|
||||
const partyColor = this.partyColor(rep.party)
|
||||
const partyBg = this.partyBg(rep.party)
|
||||
|
||||
VStack(() => {
|
||||
// Party accent bar
|
||||
VStack(() => {})
|
||||
.height(4, px).width(100, pct)
|
||||
.background(partyColor).flexShrink(0)
|
||||
|
||||
VStack(() => {
|
||||
// Avatar + status
|
||||
ZStack(() => {
|
||||
// Avatar circle
|
||||
VStack(() => {
|
||||
p(rep.initials)
|
||||
.margin(0).fontSize(1.15, em).fontWeight("800")
|
||||
.color("white").lineHeight("1")
|
||||
})
|
||||
.width(3.8, em).height(3.8, em).borderRadius(50, pct)
|
||||
.background(partyColor)
|
||||
.justifyContent("center").alignItems("center")
|
||||
.flexShrink(0)
|
||||
|
||||
// Party badge bottom-right
|
||||
VStack(() => {
|
||||
p(rep.party)
|
||||
.margin(0).fontSize(0.52, em).fontWeight("800")
|
||||
.color("white").lineHeight("1")
|
||||
})
|
||||
.position("absolute").bottom(0).right(0)
|
||||
.width(1.3, em).height(1.3, em).borderRadius(50, pct)
|
||||
.background(partyColor)
|
||||
.boxSizing("border-box")
|
||||
.justifyContent("center").alignItems("center")
|
||||
})
|
||||
.position("relative")
|
||||
.width(3.8, em).height(3.8, em).marginBottom(0.85, em)
|
||||
|
||||
// Name
|
||||
p(rep.name)
|
||||
.margin(0).fontSize(0.92, em).fontWeight("700")
|
||||
.color("var(--headertext)").lineHeight("1.25").textAlign("center")
|
||||
|
||||
// Title
|
||||
p(rep.title)
|
||||
.margin(0).marginTop(0.22, em).fontSize(0.72, em)
|
||||
.color("var(--headertext)").opacity(0.55)
|
||||
.textAlign("center").lineHeight("1.35")
|
||||
|
||||
// District chip
|
||||
if (rep.district) {
|
||||
p(rep.district)
|
||||
.margin(0).marginTop(0.55, em)
|
||||
.paddingHorizontal(0.6, em).paddingVertical(0.18, em)
|
||||
.background(partyBg).border(`1px solid ${partyColor}33`)
|
||||
.borderRadius(100, px)
|
||||
.fontSize(0.68, em).fontWeight("600")
|
||||
.color(partyColor)
|
||||
}
|
||||
|
||||
// Since
|
||||
if (rep.since) {
|
||||
p(`Since ${rep.since}`)
|
||||
.margin(0).marginTop(0.42, em)
|
||||
.fontSize(0.68, em).color("var(--headertext)").opacity(0.32)
|
||||
}
|
||||
|
||||
// Contact row
|
||||
HStack(() => {
|
||||
if (rep.phone) {
|
||||
this.contactBtn("📞", rep.phone)
|
||||
}
|
||||
if (rep.website) {
|
||||
this.contactBtn("🌐", rep.website)
|
||||
}
|
||||
})
|
||||
.gap(0.4, em).marginTop(0.75, em)
|
||||
})
|
||||
.padding(1.1, em).alignItems("center")
|
||||
})
|
||||
.width(200, px).minWidth(180, px)
|
||||
.background("var(--main)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.65, em)
|
||||
.overflow("hidden")
|
||||
.boxSizing("border-box")
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
contactBtn(icon, value) {
|
||||
button(icon)
|
||||
.padding(0.35, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.4, em)
|
||||
.fontSize(0.8, em)
|
||||
.cursor("pointer")
|
||||
.attr({ title: value })
|
||||
}
|
||||
|
||||
partyCount(count, party) {
|
||||
HStack(() => {
|
||||
VStack(() => {})
|
||||
.width(0.5, em).height(0.5, em).borderRadius(50, pct)
|
||||
.background(this.partyColor(party)).flexShrink(0)
|
||||
p(`${count} ${party}`)
|
||||
.margin(0).fontSize(0.75, em).fontWeight("600")
|
||||
.color(this.partyColor(party))
|
||||
})
|
||||
.gap(0.3, em).alignItems("center")
|
||||
.paddingHorizontal(0.6, em).paddingVertical(0.22, em)
|
||||
.background(this.partyBg(party))
|
||||
.border(`1px solid ${this.partyColor(party)}33`)
|
||||
.borderRadius(100, px)
|
||||
}
|
||||
|
||||
levelLabel(level) {
|
||||
return { federal: "FEDERAL DELEGATION", state: "STATE DELEGATION", local: "LOCAL OFFICIALS" }[level] || level.toUpperCase()
|
||||
}
|
||||
|
||||
partyColor(party) {
|
||||
return { R: "#ef4444", D: "#3b82f6", I: "#8b5cf6", L: "#f59e0b", G: "#22c55e" }[party] || "#6b7280"
|
||||
}
|
||||
|
||||
partyBg(party) {
|
||||
return { R: "rgba(239,68,68,0.08)", D: "rgba(59,130,246,0.08)", I: "rgba(139,92,246,0.08)", L: "rgba(245,158,11,0.08)", G: "rgba(34,197,94,0.08)" }[party] || "var(--darkaccent)"
|
||||
}
|
||||
}
|
||||
|
||||
register(PoliticsRepresentatives)
|
||||
163
politics/desktop/PoliticsSidebar.js
Normal file
163
politics/desktop/PoliticsSidebar.js
Normal file
@@ -0,0 +1,163 @@
|
||||
class PoliticsSidebar extends Shadow {
|
||||
constructor(view, levelFilter, onViewChange, onLevelChange, jurisdiction, elections) {
|
||||
super()
|
||||
this.view = view
|
||||
this.levelFilter = levelFilter
|
||||
this.onViewChange = onViewChange
|
||||
this.onLevelChange = onLevelChange
|
||||
this.jurisdiction = jurisdiction
|
||||
this.elections = elections
|
||||
}
|
||||
|
||||
render() {
|
||||
VStack(() => {
|
||||
// ── App title ─────────────────────────────────────────────
|
||||
VStack(() => {
|
||||
p("⚖️")
|
||||
.margin(0).fontSize(1.6, em).lineHeight("1")
|
||||
h2("Civics")
|
||||
.margin(0).marginTop(0.35, em).fontSize(1.05, em).fontWeight("700")
|
||||
.color("var(--headertext)")
|
||||
p("Your Political Dashboard")
|
||||
.margin(0).marginTop(0.12, em).fontSize(0.72, em)
|
||||
.color("var(--headertext)").opacity(0.38)
|
||||
})
|
||||
.paddingHorizontal(1.1, em).paddingTop(1.2, em).paddingBottom(0.9, em)
|
||||
|
||||
// ── Divider ───────────────────────────────────────────────
|
||||
VStack(() => {}).height(1, px).background("var(--divider)").marginHorizontal(1, em)
|
||||
|
||||
// ── Main nav ──────────────────────────────────────────────
|
||||
VStack(() => {
|
||||
this.sectionLabel("VIEWS")
|
||||
this.navItem("🏛", "Representatives", "representatives")
|
||||
this.navItem("🗳", "Elections", "elections")
|
||||
})
|
||||
.paddingTop(0.75, em)
|
||||
|
||||
VStack(() => {}).height(1, px).background("var(--divider)").marginHorizontal(1, em).marginVertical(0.5, em)
|
||||
|
||||
// ── Level filter ──────────────────────────────────────────
|
||||
VStack(() => {
|
||||
this.sectionLabel("GOVERNMENT LEVEL")
|
||||
this.levelItem("all", "🇺🇸 + 🏠 All")
|
||||
this.levelItem("federal", "🇺🇸 Federal")
|
||||
this.levelItem("state", "🏠 State")
|
||||
this.levelItem("local", "📍 Local")
|
||||
})
|
||||
|
||||
VStack(() => {}).height(1, px).background("var(--divider)").marginHorizontal(1, em).marginVertical(0.5, em)
|
||||
|
||||
// ── Jurisdiction card ─────────────────────────────────────
|
||||
VStack(() => {
|
||||
this.sectionLabel("YOUR DISTRICT")
|
||||
VStack(() => {
|
||||
this.jurisdictionRow("Federal", this.jurisdiction.federal)
|
||||
this.jurisdictionRow("State Senate", this.jurisdiction.stateSenate)
|
||||
this.jurisdictionRow("State House", this.jurisdiction.stateHouse)
|
||||
this.jurisdictionRow("County", this.jurisdiction.county)
|
||||
})
|
||||
.padding(0.75, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.55, em)
|
||||
.marginHorizontal(0.75, em)
|
||||
.gap(0)
|
||||
})
|
||||
.paddingTop(0)
|
||||
|
||||
VStack(() => {}).flex(1)
|
||||
|
||||
// ── Next election countdown ───────────────────────────────
|
||||
VStack(() => {
|
||||
const next = this.elections
|
||||
.filter(e => new Date(e.date) > new Date())
|
||||
.sort((a, b) => new Date(a.date) - new Date(b.date))[0]
|
||||
|
||||
if (next) {
|
||||
const days = Math.ceil((new Date(next.date) - new Date()) / 86400000)
|
||||
VStack(() => {
|
||||
p("NEXT ELECTION")
|
||||
.margin(0).fontSize(0.6, em).fontWeight("700").letterSpacing("0.07em")
|
||||
.color("var(--headertext)").opacity(0.35)
|
||||
p(next.title)
|
||||
.margin(0).marginTop(0.35, em).fontSize(0.8, em).fontWeight("600")
|
||||
.color("var(--headertext)").lineHeight("1.3")
|
||||
HStack(() => {
|
||||
p(`${days}`)
|
||||
.margin(0).fontSize(1.6, em).fontWeight("800")
|
||||
.color("var(--quillred)").lineHeight("1")
|
||||
p("days away")
|
||||
.margin(0).marginLeft(0.35, em).fontSize(0.72, em)
|
||||
.color("var(--headertext)").opacity(0.45)
|
||||
.alignSelf("flex-end").paddingBottom(0.1, em)
|
||||
})
|
||||
.alignItems("flex-end").marginTop(0.5, em)
|
||||
})
|
||||
.padding(0.85, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.55, em)
|
||||
.cursor("pointer")
|
||||
.onClick((done) => {if(!done) return; this.onViewChange("elections")})
|
||||
}
|
||||
})
|
||||
.paddingHorizontal(0.75, em).paddingBottom(1.1, em).flexShrink(0)
|
||||
})
|
||||
.height(100, pct).width(100, pct).boxSizing("border-box").overflowY("auto")
|
||||
}
|
||||
|
||||
navItem(icon, label, viewId) {
|
||||
const isActive = this.view === viewId
|
||||
HStack(() => {
|
||||
p(icon).margin(0).fontSize(0.95, em).lineHeight("1").flexShrink(0)
|
||||
p(label).margin(0).fontSize(0.88, em).fontWeight(isActive ? "600" : "400")
|
||||
.color("var(--headertext)").opacity(isActive ? 1 : 0.65)
|
||||
})
|
||||
.gap(0.62, em).paddingHorizontal(0.85, em).paddingVertical(0.45, em)
|
||||
.marginHorizontal(0.4, em)
|
||||
.borderRadius(0.45, em)
|
||||
.background(isActive ? "var(--app)" : "transparent")
|
||||
.cursor("pointer").alignItems("center")
|
||||
.width("calc(100% - 0.8em)").boxSizing("border-box")
|
||||
.onClick((done) => {if(!done) return; this.onViewChange(viewId)})
|
||||
}
|
||||
|
||||
levelItem(level, label) {
|
||||
const isActive = this.levelFilter === level
|
||||
HStack(() => {
|
||||
p(label).margin(0).fontSize(0.85, em).fontWeight(isActive ? "600" : "400")
|
||||
.color("var(--headertext)").opacity(isActive ? 1 : 0.58)
|
||||
})
|
||||
.paddingHorizontal(0.85, em).paddingVertical(0.38, em)
|
||||
.marginHorizontal(0.4, em)
|
||||
.borderRadius(0.45, em)
|
||||
.background(isActive ? "var(--app)" : "transparent")
|
||||
.cursor("pointer")
|
||||
.width("calc(100% - 0.8em)").boxSizing("border-box")
|
||||
.onClick((done) => {if(!done) return; this.onLevelChange(level)})
|
||||
}
|
||||
|
||||
jurisdictionRow(label, value) {
|
||||
HStack(() => {
|
||||
p(label)
|
||||
.margin(0).fontSize(0.68, em).color("var(--headertext)").opacity(0.4)
|
||||
.width(5.5, em).flexShrink(0)
|
||||
p(value)
|
||||
.margin(0).fontSize(0.72, em).fontWeight("600").color("var(--headertext)")
|
||||
.flex(1).minWidth(0).overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
})
|
||||
.paddingVertical(0.32, em)
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.alignItems("flex-start").gap(0.4, em)
|
||||
}
|
||||
|
||||
sectionLabel(text) {
|
||||
p(text)
|
||||
.margin(0).marginBottom(0.28, em).paddingHorizontal(1.1, em)
|
||||
.fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em")
|
||||
.color("var(--headertext)").opacity(0.35)
|
||||
}
|
||||
}
|
||||
|
||||
register(PoliticsSidebar)
|
||||
266
politics/desktop/politics.js
Normal file
266
politics/desktop/politics.js
Normal file
@@ -0,0 +1,266 @@
|
||||
import "./PoliticsSidebar.js"
|
||||
import "./PoliticsRepresentatives.js"
|
||||
import "./PoliticsElections.js"
|
||||
|
||||
css(`
|
||||
politics- {
|
||||
font-family: 'Arial';
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
`)
|
||||
|
||||
class Politics extends Shadow {
|
||||
constructor() {
|
||||
super()
|
||||
this.view = "representatives"
|
||||
this.levelFilter = "all"
|
||||
|
||||
this.jurisdiction = {
|
||||
federal: "TX-21",
|
||||
stateSenate: "SD-25",
|
||||
stateHouse: "HD-20",
|
||||
county: "Comal County",
|
||||
}
|
||||
|
||||
this.representatives = [
|
||||
// ── Federal ───────────────────────────────────────────────
|
||||
{
|
||||
id: 1, level: "federal",
|
||||
name: "John Cornyn", initials: "JC",
|
||||
title: "U.S. Senator", party: "R",
|
||||
district: "Texas (Class 2)", since: 2002,
|
||||
phone: "(202) 224-2934", website: "cornyn.senate.gov",
|
||||
},
|
||||
{
|
||||
id: 2, level: "federal",
|
||||
name: "Ted Cruz", initials: "TC",
|
||||
title: "U.S. Senator", party: "R",
|
||||
district: "Texas (Class 1)", since: 2013,
|
||||
phone: "(202) 224-5922", website: "cruz.senate.gov",
|
||||
},
|
||||
{
|
||||
id: 3, level: "federal",
|
||||
name: "Chip Roy", initials: "CR",
|
||||
title: "U.S. Representative", party: "R",
|
||||
district: "TX-21", since: 2019,
|
||||
phone: "(202) 225-4236", website: "roy.house.gov",
|
||||
},
|
||||
|
||||
// ── State ─────────────────────────────────────────────────
|
||||
{
|
||||
id: 4, level: "state",
|
||||
name: "Donna Campbell", initials: "DC",
|
||||
title: "Texas State Senator", party: "R",
|
||||
district: "Senate District 25", since: 2013,
|
||||
phone: "(512) 463-0125", website: "senate.texas.gov/member.php?d=25",
|
||||
},
|
||||
{
|
||||
id: 5, level: "state",
|
||||
name: "Terry Wilson", initials: "TW",
|
||||
title: "Texas State Representative", party: "R",
|
||||
district: "House District 20", since: 2017,
|
||||
phone: "(512) 463-0309", website: "house.texas.gov/members/member-page/?district=20",
|
||||
},
|
||||
{
|
||||
id: 6, level: "state",
|
||||
name: "Ken Paxton", initials: "KP",
|
||||
title: "Texas Attorney General", party: "R",
|
||||
district: "Statewide", since: 2015,
|
||||
phone: "(512) 463-2100", website: "texasattorneygeneral.gov",
|
||||
},
|
||||
|
||||
// ── Local ─────────────────────────────────────────────────
|
||||
{
|
||||
id: 7, level: "local",
|
||||
name: "Sherman Krause", initials: "SK",
|
||||
title: "Comal County Judge", party: "R",
|
||||
district: "Comal County", since: 2019,
|
||||
phone: "(830) 221-1100", website: "co.comal.tx.us",
|
||||
},
|
||||
{
|
||||
id: 8, level: "local",
|
||||
name: "Mark Reynolds", initials: "MR",
|
||||
title: "County Sheriff", party: "R",
|
||||
district: "Comal County", since: 2017,
|
||||
phone: "(830) 620-3400", website: null,
|
||||
},
|
||||
{
|
||||
id: 9, level: "local",
|
||||
name: "Jane Guerrero", initials: "JG",
|
||||
title: "New Braunfels Mayor", party: "I",
|
||||
district: "City of New Braunfels", since: 2021,
|
||||
phone: "(830) 221-4000", website: "newbraunfels.gov",
|
||||
},
|
||||
]
|
||||
|
||||
const future = (d) => new Date(Date.now() + d * 86400000)
|
||||
const past = (d) => new Date(Date.now() - d * 86400000)
|
||||
|
||||
this.elections = [
|
||||
// Upcoming
|
||||
{
|
||||
id: 1,
|
||||
level: "federal",
|
||||
title: "U.S. Senate — Texas",
|
||||
type: "general",
|
||||
date: future(209),
|
||||
description: "Class 2 seat — John Cornyn defending",
|
||||
userVoteId: null,
|
||||
userThumbsUp: false,
|
||||
thumbsUp: 14,
|
||||
candidates: [
|
||||
{ id: 101, name: "John Cornyn", initials: "JC", party: "R", incumbent: true, votes: 312 },
|
||||
{ id: 102, name: "Colin Allred", initials: "CA", party: "D", incumbent: false, votes: 187 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
level: "federal",
|
||||
title: "U.S. House — TX-21",
|
||||
type: "general",
|
||||
date: future(209),
|
||||
description: "Chip Roy vs. challenger",
|
||||
userVoteId: null,
|
||||
userThumbsUp: true,
|
||||
thumbsUp: 31,
|
||||
candidates: [
|
||||
{ id: 201, name: "Chip Roy", initials: "CR", party: "R", incumbent: true, votes: 278 },
|
||||
{ id: 202, name: "Marc Segers", initials: "MS", party: "D", incumbent: false, votes: 91 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
level: "state",
|
||||
title: "Texas House District 20",
|
||||
type: "primary",
|
||||
date: future(42),
|
||||
description: "Republican primary — open seat",
|
||||
userVoteId: null,
|
||||
userThumbsUp: false,
|
||||
thumbsUp: 8,
|
||||
candidates: [
|
||||
{ id: 301, name: "Terry Wilson", initials: "TW", party: "R", incumbent: true, votes: 203 },
|
||||
{ id: 302, name: "Brad Fischer", initials: "BF", party: "R", incumbent: false, votes: 144 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
level: "local",
|
||||
title: "Comal County Commissioner Pct. 2",
|
||||
type: "general",
|
||||
date: future(12),
|
||||
description: "Precinct 2 seat",
|
||||
userVoteId: null,
|
||||
userThumbsUp: false,
|
||||
thumbsUp: 5,
|
||||
candidates: [
|
||||
{ id: 401, name: "Lisa Hartmann", initials: "LH", party: "R", incumbent: false, votes: 89 },
|
||||
{ id: 402, name: "David Moreno", initials: "DM", party: "D", incumbent: false, votes: 53 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
level: "local",
|
||||
title: "New Braunfels City Council At-Large",
|
||||
type: "special",
|
||||
date: future(6),
|
||||
description: "Filling vacant at-large seat",
|
||||
userVoteId: null,
|
||||
userThumbsUp: false,
|
||||
thumbsUp: 22,
|
||||
candidates: [
|
||||
{ id: 501, name: "Karen Ellis", initials: "KE", party: "I", incumbent: false, votes: 112 },
|
||||
{ id: 502, name: "Tom Riggs", initials: "TR", party: "I", incumbent: false, votes: 74 },
|
||||
{ id: 503, name: "Anita Solis", initials: "AS", party: "I", incumbent: false, votes: 41 },
|
||||
]
|
||||
},
|
||||
|
||||
// Recent/past
|
||||
{
|
||||
id: 6,
|
||||
level: "state",
|
||||
title: "Texas Senate District 25",
|
||||
type: "primary",
|
||||
date: past(48),
|
||||
description: "Donna Campbell won unopposed",
|
||||
userVoteId: 601,
|
||||
userThumbsUp: true,
|
||||
thumbsUp: 41,
|
||||
candidates: [
|
||||
{ id: 601, name: "Donna Campbell", initials: "DC", party: "R", incumbent: true, votes: 487 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
level: "local",
|
||||
title: "Comal County Sheriff",
|
||||
type: "general",
|
||||
date: past(180),
|
||||
description: "Mark Reynolds re-elected",
|
||||
userVoteId: 701,
|
||||
userThumbsUp: false,
|
||||
thumbsUp: 19,
|
||||
candidates: [
|
||||
{ id: 701, name: "Mark Reynolds", initials: "MR", party: "R", incumbent: true, votes: 623 },
|
||||
{ id: 702, name: "Phil Sanderson", initials: "PS", party: "D", incumbent: false, votes: 214 },
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
handleVote(electionId, candidateId) {
|
||||
const election = this.elections.find(e => e.id === electionId)
|
||||
if (!election || election.userVoteId) return
|
||||
election.userVoteId = candidateId
|
||||
const candidate = election.candidates.find(c => c.id === candidateId)
|
||||
if (candidate) candidate.votes++
|
||||
this.rerender()
|
||||
}
|
||||
|
||||
handleThumbsUp(electionId) {
|
||||
const election = this.elections.find(e => e.id === electionId)
|
||||
if (!election) return
|
||||
election.userThumbsUp = !election.userThumbsUp
|
||||
election.thumbsUp += election.userThumbsUp ? 1 : -1
|
||||
this.rerender()
|
||||
}
|
||||
|
||||
render() {
|
||||
HStack(() => {
|
||||
// Sidebar
|
||||
VStack(() => {
|
||||
PoliticsSidebar(
|
||||
this.view,
|
||||
this.levelFilter,
|
||||
(v) => { this.view = v; this.rerender(); },
|
||||
(l) => { this.levelFilter = l; this.rerender(); },
|
||||
this.jurisdiction,
|
||||
this.elections
|
||||
)
|
||||
})
|
||||
.width(230, px).minWidth(210, px)
|
||||
.height(100, pct)
|
||||
.borderRight("1px solid var(--divider)")
|
||||
.flexShrink(0).overflow("hidden")
|
||||
|
||||
// Main
|
||||
VStack(() => {
|
||||
if (this.view === "representatives") {
|
||||
PoliticsRepresentatives(this.representatives, this.levelFilter)
|
||||
} else {
|
||||
PoliticsElections(
|
||||
this.elections,
|
||||
this.levelFilter,
|
||||
(electionId, candidateId) => this.handleVote(electionId, candidateId),
|
||||
(electionId) => this.handleThumbsUp(electionId)
|
||||
)
|
||||
}
|
||||
})
|
||||
.flex(1).height(100, pct).overflow("hidden")
|
||||
})
|
||||
.height(100, pct).width(100, pct).overflow("hidden")
|
||||
}
|
||||
}
|
||||
|
||||
register(Politics)
|
||||
Reference in New Issue
Block a user