Files
apps/jobs/jobs.js
metacryst 0d6c7683ff init
2026-04-28 20:05:00 -05:00

442 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)