442 lines
18 KiB
JavaScript
442 lines
18 KiB
JavaScript
import server from "/@server/server.js"
|
||
import "/_/code/components/LoadingCircle.js"
|
||
|
||
css(`
|
||
jobs-, jobs- * { box-sizing: border-box; }
|
||
jobs- {
|
||
font-family: 'Arial';
|
||
scrollbar-width: none;
|
||
-ms-overflow-style: none;
|
||
}
|
||
jobs-::-webkit-scrollbar { display: none; }
|
||
jobs- input::placeholder, jobs- select::placeholder {
|
||
color: var(--headertext);
|
||
opacity: 0.35;
|
||
}
|
||
jobs- select {
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
}
|
||
`)
|
||
|
||
class Jobs extends Shadow {
|
||
selectedJobId = null
|
||
searchText = ""
|
||
searchOpen = false
|
||
filtersOpen = false
|
||
filters = { type: "", level: "" }
|
||
loaded = false
|
||
|
||
constructor() {
|
||
super()
|
||
const cached = global.currentNetwork?.data?.jobs
|
||
if (cached?.length) {
|
||
this.jobs = cached
|
||
this.loaded = true
|
||
} else {
|
||
this.jobs = []
|
||
}
|
||
}
|
||
|
||
async loadJobs() {
|
||
const fetched = await server.getJobs(global.currentNetwork.id)
|
||
this.jobs = fetched || []
|
||
this.loaded = true
|
||
this.rerender()
|
||
}
|
||
|
||
get selectedJob() { return this.jobs.find(j => j.id === this.selectedJobId) || null }
|
||
|
||
get filtered() {
|
||
return this.jobs.filter(job => {
|
||
if (this.searchText) {
|
||
const q = this.searchText.toLowerCase()
|
||
const hay = [job.title, job.company, job.location, job.department].join(" ").toLowerCase()
|
||
if (!hay.includes(q)) return false
|
||
}
|
||
if (this.filters.type && job.employment_type !== this.filters.type) return false
|
||
if (this.filters.level && job.experience_level !== this.filters.level) return false
|
||
return true
|
||
})
|
||
}
|
||
|
||
render() {
|
||
VStack(() => {
|
||
if (this.selectedJobId === null) {
|
||
this.renderList()
|
||
} else {
|
||
this.renderDetail()
|
||
}
|
||
})
|
||
.height(100, pct).width(100, vw).overflow("hidden")
|
||
.onAppear(async () => {
|
||
if (!this.loaded) await this.loadJobs()
|
||
})
|
||
}
|
||
|
||
// ── List ─────────────────────────────────────────────────────────────────
|
||
|
||
renderList() {
|
||
VStack(() => {
|
||
this.renderListHeader()
|
||
|
||
if (this.filtersOpen && !global.appRefreshing) this.renderFilters()
|
||
|
||
const jobs = this.filtered
|
||
VStack(() => {
|
||
if (global.appRefreshing || !this.loaded) {
|
||
LoadingCircle()
|
||
} else if (!jobs.length) {
|
||
VStack(() => {
|
||
p("No jobs match your filters").margin(0).fontSize(0.9, em)
|
||
.color("var(--headertext)").opacity(0.38).textAlign("center")
|
||
}).flex(1).justifyContent("center").alignItems("center")
|
||
} else {
|
||
p(jobs.length === 1 ? "1 job" : `${jobs.length} jobs`)
|
||
.margin(0).paddingHorizontal(1.1, em).paddingTop(0.65, em)
|
||
.fontSize(0.72, em).fontWeight("600")
|
||
.color("var(--headertext)").opacity(0.4).flexShrink(0)
|
||
|
||
jobs.forEach(job => this.renderCard(job))
|
||
}
|
||
})
|
||
.flex(1).overflowY("auto").paddingBottom(2, em)
|
||
})
|
||
.height(100, pct).width(100, pct).overflow("hidden")
|
||
}
|
||
|
||
renderListHeader() {
|
||
VStack(() => {
|
||
HStack(() => {
|
||
p("Jobs")
|
||
.margin(0).fontSize(1.35, em).fontWeight("700")
|
||
.color("var(--headertext)").flex(1)
|
||
|
||
HStack(() => {
|
||
p("🔍")
|
||
.margin(0).fontSize(1.05, em).padding(0.4, em).cursor("pointer")
|
||
.onTouch((start) => {
|
||
if (!start) {
|
||
this.searchOpen = !this.searchOpen
|
||
if (!this.searchOpen) this.searchText = ""
|
||
this.rerender()
|
||
}
|
||
})
|
||
p("⚙️")
|
||
.margin(0).fontSize(1.05, em).padding(0.4, em).cursor("pointer")
|
||
.onTouch((start) => {
|
||
if (!start) { this.filtersOpen = !this.filtersOpen; this.rerender() }
|
||
})
|
||
})
|
||
.gap(0)
|
||
})
|
||
.alignItems("center")
|
||
.paddingHorizontal(1.1, em).paddingTop(1, em).paddingBottom(0.5, em)
|
||
|
||
if (this.searchOpen) {
|
||
HStack(() => {
|
||
p("🔍").margin(0).fontSize(0.78, em).opacity(0.4).flexShrink(0)
|
||
input()
|
||
.attr({ type: "text", placeholder: "Search jobs…", autofocus: "true" })
|
||
.flex(1).border("none").outline("none")
|
||
.background("transparent")
|
||
.color("var(--headertext)").fontSize(0.9, em)
|
||
.onInput((e) => { this.searchText = e.target.value; this.rerender() })
|
||
})
|
||
.gap(0.5, em)
|
||
.paddingHorizontal(0.85, em).paddingVertical(0.55, em)
|
||
.background("var(--darkaccent)")
|
||
.border("1px solid var(--divider)")
|
||
.borderRadius(0.6, em)
|
||
.marginHorizontal(1.1, em).marginBottom(0.5, em)
|
||
.alignItems("center")
|
||
}
|
||
})
|
||
.flexShrink(0)
|
||
}
|
||
|
||
renderFilters() {
|
||
HStack(() => {
|
||
select(() => {
|
||
option("Any type", "")
|
||
option("Full-time", "full-time")
|
||
option("Part-time", "part-time")
|
||
option("Contract", "contract")
|
||
option("Internship","internship")
|
||
})
|
||
.flex(1)
|
||
.padding(0.6, em)
|
||
.background("var(--darkaccent)")
|
||
.border("1px solid var(--divider)")
|
||
.borderRadius(0.55, em)
|
||
.color("var(--headertext)").fontSize(0.85, em)
|
||
.onInput((e) => { this.filters.type = e.target.value; this.rerender() })
|
||
|
||
select(() => {
|
||
option("Any level", "")
|
||
option("Entry", "entry")
|
||
option("Mid", "mid")
|
||
option("Senior", "senior")
|
||
})
|
||
.flex(1)
|
||
.padding(0.6, em)
|
||
.background("var(--darkaccent)")
|
||
.border("1px solid var(--divider)")
|
||
.borderRadius(0.55, em)
|
||
.color("var(--headertext)").fontSize(0.85, em)
|
||
.onInput((e) => { this.filters.level = e.target.value; this.rerender() })
|
||
})
|
||
.gap(0.65, em)
|
||
.paddingHorizontal(1.1, em).paddingBottom(0.65, em)
|
||
.flexShrink(0)
|
||
}
|
||
|
||
renderCard(job) {
|
||
VStack(() => {
|
||
HStack(() => {
|
||
VStack(() => {
|
||
p((job.company || "?")[0].toUpperCase())
|
||
.margin(0).fontSize(1.1, em).fontWeight("700").color("white")
|
||
})
|
||
.width(2.8, em).height(2.8, em).borderRadius(0.55, em)
|
||
.background(this.companyColor(job.company))
|
||
.justifyContent("center").alignItems("center").flexShrink(0)
|
||
|
||
VStack(() => {
|
||
p(job.title)
|
||
.margin(0).fontSize(0.95, em).fontWeight("600")
|
||
.color("var(--headertext)").lineHeight("1.3")
|
||
|
||
p(job.company || "Unknown")
|
||
.margin(0).marginTop(0.1, em).fontSize(0.78, em)
|
||
.color("var(--headertext)").opacity(0.55)
|
||
})
|
||
.flex(1).minWidth(0)
|
||
|
||
VStack(() => {
|
||
p(this.salaryLabel(job.salary_number, job.salary_period))
|
||
.margin(0).fontSize(0.78, em).fontWeight("600")
|
||
.color("var(--headertext)").textAlign("right")
|
||
|
||
p(this.relativeDate(job.posted_at))
|
||
.margin(0).marginTop(0.25, em).fontSize(0.68, em)
|
||
.color("var(--headertext)").opacity(0.38).textAlign("right")
|
||
})
|
||
.alignItems("flex-end").flexShrink(0)
|
||
})
|
||
.gap(0.75, em).alignItems("flex-start")
|
||
|
||
HStack(() => {
|
||
if (job.location) this.chip("📍 " + job.location)
|
||
if (job.employment_type) this.chip(this.formatType(job.employment_type))
|
||
if (job.experience_level) this.chip(this.formatLevel(job.experience_level))
|
||
})
|
||
.gap(0.4, em).flexWrap("wrap").marginTop(0.65, em)
|
||
})
|
||
.padding(1, em)
|
||
.marginHorizontal(1.1, em).marginTop(0.5, em)
|
||
.background("var(--darkaccent)")
|
||
.border("1px solid var(--divider)")
|
||
.borderRadius(0.75, em)
|
||
.onTouch((start) => {
|
||
if (!start) { this.selectedJobId = job.id; this.rerender() }
|
||
})
|
||
}
|
||
|
||
chip(text) {
|
||
p(text)
|
||
.margin(0).paddingHorizontal(0.55, em).paddingVertical(0.2, em)
|
||
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||
.borderRadius(100, px).fontSize(0.7, em)
|
||
.color("var(--headertext)").opacity(0.7).whiteSpace("nowrap")
|
||
}
|
||
|
||
// ── Detail ────────────────────────────────────────────────────────────────
|
||
|
||
renderDetail() {
|
||
const job = this.selectedJob
|
||
if (!job) return
|
||
|
||
VStack(() => {
|
||
// Header bar
|
||
HStack(() => {
|
||
p("‹")
|
||
.margin(0).fontSize(1.8, em).lineHeight("1")
|
||
.color("var(--headertext)").paddingRight(0.5, em).cursor("pointer")
|
||
.onTouch((start) => {
|
||
if (!start) { this.selectedJobId = null; this.rerender() }
|
||
})
|
||
p(job.company || "Job")
|
||
.margin(0).fontSize(0.95, em).fontWeight("600")
|
||
.color("var(--headertext)").opacity(0.7)
|
||
.flex(1).overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||
})
|
||
.gap(0.25, em).paddingHorizontal(1.1, em).paddingVertical(0.85, em)
|
||
.borderBottom("1px solid var(--divider)")
|
||
.alignItems("center").flexShrink(0)
|
||
|
||
// Scrollable body
|
||
VStack(() => {
|
||
// Company + title
|
||
HStack(() => {
|
||
VStack(() => {
|
||
p((job.company || "?")[0].toUpperCase())
|
||
.margin(0).fontSize(1.6, em).fontWeight("700").color("white")
|
||
})
|
||
.width(3.5, em).height(3.5, em).borderRadius(0.65, em)
|
||
.background(this.companyColor(job.company))
|
||
.justifyContent("center").alignItems("center").flexShrink(0)
|
||
|
||
VStack(() => {
|
||
p(job.title)
|
||
.margin(0).fontSize(1.1, em).fontWeight("700")
|
||
.color("var(--headertext)").lineHeight("1.25")
|
||
p(job.company || "Unknown Company")
|
||
.margin(0).marginTop(0.15, em).fontSize(0.88, em)
|
||
.color("var(--headertext)").opacity(0.55)
|
||
})
|
||
.flex(1).minWidth(0)
|
||
})
|
||
.gap(0.9, em).alignItems("center")
|
||
.paddingHorizontal(1.1, em).paddingTop(1.25, em).paddingBottom(0.9, em)
|
||
|
||
// Meta chips
|
||
HStack(() => {
|
||
if (job.location) this.chip("📍 " + job.location)
|
||
if (job.employment_type) this.chip(this.formatType(job.employment_type))
|
||
if (job.experience_level) this.chip(this.formatLevel(job.experience_level))
|
||
if (job.department) this.chip(job.department)
|
||
if (job.salary_number) this.chip(this.salaryLabel(job.salary_number, job.salary_period))
|
||
})
|
||
.gap(0.45, em).flexWrap("wrap")
|
||
.paddingHorizontal(1.1, em).paddingBottom(0.85, em)
|
||
.borderBottom("1px solid var(--divider)")
|
||
|
||
// Stats
|
||
HStack(() => {
|
||
if (job.applicants !== undefined) {
|
||
p(job.applicants + " applicants")
|
||
.margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.4)
|
||
}
|
||
if (job.posted_at) {
|
||
p(this.relativeDate(job.posted_at))
|
||
.margin(0).fontSize(0.78, em).color("var(--headertext)").opacity(0.4)
|
||
}
|
||
})
|
||
.gap(1.25, em).paddingHorizontal(1.1, em).paddingVertical(0.75, em)
|
||
.borderBottom("1px solid var(--divider)")
|
||
|
||
// CTA buttons
|
||
HStack(() => {
|
||
button("Apply now")
|
||
.flex(1).paddingVertical(0.72, em)
|
||
.background("var(--quillred)").color("white")
|
||
.border("none").borderRadius(0.55, em)
|
||
.fontWeight("600").fontSize(0.92, em).cursor("pointer")
|
||
|
||
button("Save job")
|
||
.flex(1).paddingVertical(0.72, em)
|
||
.background("transparent").color("var(--headertext)")
|
||
.border("1px solid var(--divider)").borderRadius(0.55, em)
|
||
.fontWeight("500").fontSize(0.92, em).cursor("pointer")
|
||
})
|
||
.gap(0.65, em).paddingHorizontal(1.1, em).paddingVertical(0.85, em)
|
||
.borderBottom("1px solid var(--divider)")
|
||
|
||
// Description
|
||
if (job.description) {
|
||
this.section("About the role", () => {
|
||
p(job.description)
|
||
.margin(0).fontSize(0.88, em).lineHeight("1.65")
|
||
.color("var(--headertext)").opacity(0.8)
|
||
.whiteSpace("pre-wrap")
|
||
})
|
||
}
|
||
|
||
// Skills
|
||
if (job.skills?.length) {
|
||
this.section("Skills & requirements", () => {
|
||
HStack(() => {
|
||
job.skills.forEach(s => {
|
||
p(s)
|
||
.margin(0).paddingHorizontal(0.75, em).paddingVertical(0.3, em)
|
||
.background("var(--darkaccent)").border("1px solid var(--divider)")
|
||
.borderRadius(100, px).fontSize(0.78, em)
|
||
.color("var(--headertext)").opacity(0.8).whiteSpace("nowrap")
|
||
})
|
||
})
|
||
.gap(0.45, em).flexWrap("wrap")
|
||
})
|
||
}
|
||
|
||
// Details table
|
||
this.section("Job details", () => {
|
||
VStack(() => {
|
||
this.detailRow("Type", this.formatType(job.employment_type) || "—")
|
||
this.detailRow("Level", this.formatLevel(job.experience_level) || "—")
|
||
this.detailRow("Location", job.location || "—")
|
||
this.detailRow("Pay", job.salary_number ? this.salaryLabel(job.salary_number, job.salary_period) : "—")
|
||
if (job.department) this.detailRow("Dept.", job.department)
|
||
}).gap(0)
|
||
})
|
||
|
||
div().height(2, em)
|
||
})
|
||
.flex(1).overflowY("auto")
|
||
})
|
||
.height(100, pct).width(100, pct).overflow("hidden")
|
||
}
|
||
|
||
section(title, fn) {
|
||
VStack(() => {
|
||
p(title)
|
||
.margin(0).marginBottom(0.65, em)
|
||
.fontSize(0.72, em).fontWeight("700").letterSpacing("0.05em")
|
||
.color("var(--headertext)").opacity(0.38).textTransform("uppercase")
|
||
fn()
|
||
})
|
||
.paddingHorizontal(1.1, em).paddingTop(1.1, em).paddingBottom(0.9, em)
|
||
.borderBottom("1px solid var(--divider)")
|
||
}
|
||
|
||
detailRow(label, value) {
|
||
HStack(() => {
|
||
p(label).margin(0).fontSize(0.85, em).color("var(--headertext)").opacity(0.45).width(5.5, em).flexShrink(0)
|
||
p(value).margin(0).fontSize(0.85, em).color("var(--headertext)").fontWeight("500")
|
||
})
|
||
.paddingVertical(0.55, em).alignItems("flex-start")
|
||
.borderBottom("1px solid var(--divider)")
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||
|
||
formatType(t) { return { "full-time": "Full-time", "part-time": "Part-time", "contract": "Contract", "internship": "Internship" }[t] || t }
|
||
formatLevel(l) { return { "entry": "Entry level", "mid": "Mid level", "senior": "Senior" }[l] || l }
|
||
|
||
salaryLabel(n, p) {
|
||
if (!n) return "—"
|
||
const fmt = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(Number(n))
|
||
return { year: `$${fmt}/yr`, month: `$${fmt}/mo`, hour: `$${fmt}/hr`, "one-time": `$${fmt}` }[p] || `$${fmt}`
|
||
}
|
||
|
||
relativeDate(date) {
|
||
if (!date) return ""
|
||
const days = Math.floor((Date.now() - new Date(date)) / 86400000)
|
||
if (days === 0) return "Today"
|
||
if (days === 1) return "Yesterday"
|
||
if (days < 7) return `${days}d ago`
|
||
if (days < 30) return `${Math.floor(days / 7)}w ago`
|
||
return `${Math.floor(days / 30)}mo ago`
|
||
}
|
||
|
||
companyColor(company) {
|
||
const colors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"]
|
||
if (!company) return colors[0]
|
||
let hash = 0
|
||
for (let i = 0; i < company.length; i++) hash = company.charCodeAt(i) + ((hash << 5) - hash)
|
||
return colors[Math.abs(hash) % colors.length]
|
||
}
|
||
}
|
||
|
||
register(Jobs)
|