This commit is contained in:
metacryst
2026-04-28 20:05:00 -05:00
commit 0d6c7683ff
123 changed files with 20922 additions and 0 deletions

65
jobs/JobCard.js Normal file
View File

@@ -0,0 +1,65 @@
import server from "/@server/server.js"
css(`
jobcard- p {
font-size: 0.85em;
color: var(--text);
}
`)
class JobCard extends Shadow {
constructor(job) {
super()
this.job = job
}
render() {
VStack(() => {
HStack(() => {
h3(this.job.title)
.color("var(--headertext)")
.fontSize(1.3, em)
.fontWeight("normal")
.margin(0, em)
})
.justifyContent("space-between")
.verticalAlign("center")
p(this.job.company ?? "No company added")
.marginTop(0.75, em)
p(this.job.location ?? "No location added")
.marginTop(0.25, em)
p(this.job.salary_number ? this.salaryLabel(this.job.salary_number, this.job.salary_period) : "No salary added")
.marginTop(0.75, em)
})
.paddingVertical(1.5, em)
.paddingHorizontal(3.5, em)
.marginHorizontal(1, em)
.borderRadius(10, px)
.background("var(--desktop-item-background)")
.border("1px solid var(--desktop-item-border)")
.boxSizing("border-box")
}
salaryLabel(number, period) {
const formattedNumber = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(Number(number));
if (period === "one-time") {
return `One-time payment of $${formattedNumber}`
} else {
return `$${formattedNumber}/${period}`
}
}
async deleteJob(job) {
const result = await server.deleteJob(job.id, job.network_id, global.profile.id)
if (result === null) {
console.log("Failed to delete job")
}
}
}
register(JobCard)

204
jobs/JobForm.js Normal file
View File

@@ -0,0 +1,204 @@
import server from "/@server/server.js"
class JobForm extends Shadow {
inputStyles(el) {
return el
.background("var(--main)")
.color("var(--text)")
.border("1px solid var(--accent)")
.fontSize(0.9, rem)
.backgroundColor("var(--darkaccent)")
.borderRadius(12, px)
.outline("none")
.onTouch((start) => {
if (start) {
this.style.backgroundColor = "var(--accent)"
} else {
this.style.backgroundColor = "var(--darkaccent)"
}
})
}
render() {
ZStack(() => {
p("X")
.color("var(--darkred)")
.fontSize(2, em)
.position("absolute")
.fontFamily("Arial")
.marginTop(1, rem)
.marginLeft(1, rem)
.onTap(() => {
this.toggle()
})
form(() => {
VStack(() => {
h1("Create a Job")
.color("var(--text)")
.textAlign("center")
.fontFamily("Arial")
.marginTop(1.5, em)
input("Title", "70%")
.attr({ name: "title", type: "text" })
.margin(1, em)
.padding(1, em)
.styles(this.inputStyles)
input("Location", "70%")
.attr({ name: "location", type: "text" })
.margin(1, em)
.padding(1, em)
.styles(this.inputStyles)
input("Company", "70%")
.attr({ name: "company", type: "text" })
.margin(1, em)
.padding(1, em)
.styles(this.inputStyles)
HStack(() => {
input("Salary", "30%")
.attr({ name: "salary_number", type: "number", min: "0", step: "0.01" })
.padding(1, em)
.marginHorizontal(1, em)
.styles(this.inputStyles)
select(() => {
option("One-time")
.attr({ value: "one-time"})
option("Hourly")
.attr({ value: "hour"})
option("Monthly")
.attr({ value: "month"})
option("Yearly")
.attr({ value: "year"})
})
.attr({ name: "salary_period" })
.width(40, pct)
.padding(1, em)
.marginHorizontal(1, em)
.styles(this.inputStyles)
})
.margin(1, em)
.boxSizing("border-box")
.verticalAlign("center")
.horizontalAlign("center")
input("Description", "70%")
.attr({ name: "description", type: "text" })
.margin(1, em)
.padding(1, em)
.styles(this.inputStyles)
HStack(() => {
button("==>")
.padding(1, em)
.fontSize(0.9, rem)
.borderRadius(12, px)
.background("var(--searchbackground)")
.color("var(--text)")
.border("1px solid var(--accent)")
.boxSizing("border-box")
.onTouch(function (start) {
if (start) {
this.style.backgroundColor = "var(--accent)"
} else {
this.style.backgroundColor = "var(--searchbackground)"
}
})
})
.width(70, vw)
.margin("auto")
.fontSize(0.9, rem)
.paddingLeft(0, em)
.paddingRight(2, em)
.marginVertical(1, em)
.border("1px solid transparent")
p("")
.state("errormessage", function (msg) {
this.innerText = msg
})
.margin("auto")
.marginTop(1, em)
.color("var(--text)")
.fontFamily("Arial")
.opacity(.7)
.padding(0.5, em)
.backgroundColor("var(--darkred)")
.width(100, pct)
.textAlign("center")
.boxSizing("border-box")
})
.horizontalAlign("center")
})
.onSubmit((e) => {
e.preventDefault()
const data = {
title: e.target.$('[name="title"]').value,
location: e.target.$('[name="location"]').value,
company: e.target.$('[name="company"]').value,
salary_number: e.target.$('[name="salary_number"]').value,
salary_period: e.target.$('[name="salary_period"]').value,
description: e.target.$('[name="description"]').value,
};
this.handleSend(data)
})
})
.position("fixed")
.height(window.visualViewport.height - 20, px)
.width(100, pct)
.top(100, vh)
.background("var(--main)")
.zIndex(4)
.borderTopLeftRadius("10px")
.borderTopRightRadius("10px")
.boxSizing("border-box")
.border("1px solid var(--accent)")
.transition("top .3s")
}
async handleSend(jobData) {
if (!jobData.title) {
this.$(".VStack > p")
.attr({ errormessage: 'Jobs must include a title.' })
.display("")
return;
} else {
this.$(".VStack > p").style.display = "none"
}
const newJob = {
title: jobData.title,
location: jobData.location.trim() === '' ? null : jobData.location.trim(),
company: jobData.company.trim() === '' ? null : jobData.company.trim(),
salary_number: jobData.salary_number.trim() === '' ? null : jobData.salary_number,
salary_period: jobData.salary_number.trim() === '' ? null : jobData.salary_period,
description: jobData.description.trim() === '' ? null : jobData.description.trim()
}
const result = await server.addJob(newJob, global.currentNetwork.id, global.profile.id)
if (!result.error) {
console.log("Added new job: ", result)
this.toggle()
window.dispatchEvent(new CustomEvent('new-job', {
detail: { job: result }
}));
} else {
console.log("Failed to add new event: ", data)
this.$(".VStack > p")
.attr({ errormessage: data.error })
.display("")
}
}
toggle() {
if(this.style.top === "15vh") {
this.style.top = "100vh"
this.pointerEvents = "none"
} else {
this.style.top = "15vh"
this.pointerEvents = "auto"
}
}
}
register(JobForm)

60
jobs/JobsGrid.js Normal file
View File

@@ -0,0 +1,60 @@
class JobsGrid extends Shadow {
jobs;
constructor(jobs) {
super()
this.jobs = jobs
}
boldUntilFirstSpace(text) {
const index = text.indexOf(' ');
if (index === -1) {
// No spaces — bold the whole thing
return `<b>${text}</b>`;
}
return `<b>${text.slice(0, index)}</b>${text.slice(index)}`;
}
render() {
VStack(() => {
h3("Results")
.marginTop(0.1, em)
.marginBottom(1, em)
.marginLeft(0.4, em)
.color("var(--accent2)")
if (this.jobs.length > 0) {
ZStack(() => {
for (let i = 0; i < this.jobs.length; i++) {
VStack(() => {
p(this.jobs[i].title)
.fontSize(1.2, em)
.fontWeight("bold")
.marginBottom(0.5, em)
p(this.jobs[i].company)
p(this.jobs[i].city + ", " + this.jobs[i].state)
.marginBottom(0.5, em)
p(this.boldUntilFirstSpace(this.jobs[i].salary))
})
.padding(1, em)
.borderRadius(5, "px")
.background("var(--darkbrown)")
}
})
.display("grid")
.gridTemplateColumns("repeat(auto-fill, minmax(250px, 1fr))")
.gap(1, em)
} else {
p("No Jobs!")
}
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.paddingTop(2, em)
.gap(0, em)
.width(100, "%")
}
}
register(JobsGrid)

26
jobs/JobsSidebar.js Normal file
View File

@@ -0,0 +1,26 @@
class JobsSidebar extends Shadow {
render() {
VStack(() => {
h3("Location")
.color("var(--accent2)")
.marginBottom(0, em)
HStack(() => {
input("Location", "100%")
.paddingLeft(3, em)
.paddingVertical(0.75, em)
.backgroundImage("/_/icons/locationPin.svg")
.backgroundRepeat("no-repeat")
.backgroundSize("18px 18px")
.backgroundPosition("10px center")
})
})
.paddingTop(1, em)
.paddingLeft(3, em)
.paddingRight(3, em)
.gap(1, em)
.minWidth(10, vw)
}
}
register(JobsSidebar)

View File

@@ -0,0 +1,293 @@
class DesktopJobDetail extends Shadow {
constructor(job, calendars) {
super()
this.job = job
}
render() {
if (!this.job) {
VStack(() => {
p("💼")
.margin(0)
.fontSize(2.5, em)
.opacity(0.2)
p("Select a job to view details")
.margin(0)
.marginTop(0.75, em)
.fontSize(0.92, em)
.color("var(--headertext)")
.opacity(0.35)
.textAlign("center")
})
.flex(1)
.height(100, pct)
.justifyContent("center")
.alignItems("center")
return;
}
const job = this.job;
VStack(() => {
// ── Header ────────────────────────────────────────────────
VStack(() => {
// Logo + title
HStack(() => {
VStack(() => {
p(job.company ? job.company[0].toUpperCase() : "?")
.margin(0)
.fontSize(1.6, em)
.fontWeight("700")
.color("white")
})
.width(3.8, em)
.height(3.8, em)
.borderRadius(0.65, em)
.background(this.companyColor(job.company))
.justifyContent("center")
.alignItems("center")
.flexShrink(0)
VStack(() => {
h2(job.title)
.margin(0)
.fontSize(1.18, em)
.fontWeight("700")
.color("var(--headertext)")
.lineHeight("1.25")
p(job.company || "Unknown Company")
.margin(0)
.marginTop(0.18, em)
.fontSize(0.9, em)
.fontWeight("500")
.color("var(--headertext)")
.opacity(0.6)
})
.flex(1)
.minWidth(0)
})
.gap(0.9, em)
.alignItems("center")
.marginBottom(1, em)
// Meta chips row
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")
.marginBottom(0.85, em)
// Stats row
HStack(() => {
if (job.applicants !== undefined) {
this.stat(job.applicants + " applicants")
}
if (job.posted_at) {
this.stat(this.relativeDate(job.posted_at))
}
})
.gap(1.2, em)
.marginBottom(1.1, em)
// CTA buttons
HStack(() => {
button("Apply now")
.paddingHorizontal(1.4, em)
.paddingVertical(0.58, em)
.background("var(--quillred)")
.color("white")
.border("none")
.borderRadius(0.45, em)
.fontWeight("600")
.fontSize(0.88, em)
.cursor("pointer")
button("Save job")
.paddingHorizontal(1.2, em)
.paddingVertical(0.58, em)
.background("transparent")
.color("var(--headertext)")
.border("1px solid var(--divider)")
.borderRadius(0.45, em)
.fontWeight("500")
.fontSize(0.88, em)
.cursor("pointer")
})
.gap(0.65, em)
})
.paddingHorizontal(1.75, em)
.paddingTop(1.6, em)
.paddingBottom(1.25, em)
.borderBottom("1px solid var(--divider)")
.flexShrink(0)
// ── Body ──────────────────────────────────────────────────
VStack(() => {
// About the role
if (job.description) {
this.section("About the role", () => {
p(job.description)
.margin(0)
.fontSize(0.88, em)
.color("var(--headertext)")
.opacity(0.8)
.lineHeight("1.65")
.whiteSpace("pre-wrap")
})
}
// Skills / requirements
if (job.skills && job.skills.length > 0) {
this.section("Skills & requirements", () => {
HStack(() => {
job.skills.forEach(skill => {
p(skill)
.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")
})
}
// Job details table
this.section("Job details", () => {
VStack(() => {
this.detailRow("Employment type", this.formatType(job.employment_type) || "—")
this.detailRow("Experience level", this.formatLevel(job.experience_level) || "—")
this.detailRow("Location", job.location || "—")
this.detailRow("Compensation", job.salary_number ? this.salaryLabel(job.salary_number, job.salary_period) : "—")
if (job.department) this.detailRow("Department", job.department)
})
.gap(0)
})
})
.paddingHorizontal(1.75, em)
.paddingTop(0.25, em)
.paddingBottom(2, em)
.gap(0)
.overflowY("auto")
.flex(1)
})
.height(100, pct)
.width(100, pct)
.overflowY("hidden")
.boxSizing("border-box")
}
section(title, contentFn) {
VStack(() => {
p(title)
.margin(0)
.marginBottom(0.7, em)
.fontSize(0.82, em)
.fontWeight("700")
.letterSpacing("0.04em")
.color("var(--headertext)")
.opacity(0.38)
.textTransform("uppercase")
contentFn()
})
.paddingTop(1.35, em)
.paddingBottom(0.35, em)
.borderBottom("1px solid var(--divider)")
.width(100, pct)
.boxSizing("border-box")
}
detailRow(label, value) {
HStack(() => {
p(label)
.margin(0)
.fontSize(0.85, em)
.color("var(--headertext)")
.opacity(0.45)
.width(9, 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)")
.width(100, pct)
}
chip(text) {
p(text)
.margin(0)
.paddingHorizontal(0.65, em)
.paddingVertical(0.25, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(100, px)
.fontSize(0.78, em)
.color("var(--headertext)")
.opacity(0.75)
.whiteSpace("nowrap")
}
stat(text) {
p(text)
.margin(0)
.fontSize(0.78, em)
.color("var(--headertext)")
.opacity(0.4)
}
formatType(type) {
return { "full-time": "Full-time", "part-time": "Part-time", "contract": "Contract", "internship": "Internship" }[type] || type
}
formatLevel(level) {
return { "entry": "Entry level", "mid": "Mid level", "senior": "Senior" }[level] || level
}
salaryLabel(number, period) {
const n = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(Number(number));
if (period === "one-time") return `$${n} one-time`;
if (period === "year") return `$${n}/yr`;
if (period === "month") return `$${n}/mo`;
if (period === "hour") return `$${n}/hr`;
return `$${n}`;
}
relativeDate(date) {
const d = new Date(date);
const days = Math.floor((Date.now() - d) / 86400000);
if (days === 0) return "Posted today";
if (days === 1) return "Posted yesterday";
if (days < 7) return `Posted ${days} days ago`;
if (days < 30) return `Posted ${Math.floor(days / 7)}w ago`;
return `Posted ${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(DesktopJobDetail)

View File

@@ -0,0 +1,169 @@
class DesktopJobsList extends Shadow {
constructor(jobs, selectedId, onSelect) {
super()
this.jobs = jobs
this.selectedId = selectedId
this.onSelect = onSelect
}
render() {
VStack(() => {
p(this.jobs.length === 1 ? "1 job" : `${this.jobs.length} jobs`)
.margin(0)
.marginBottom(0.75, em)
.fontSize(0.78, em)
.fontWeight("600")
.color("var(--headertext)")
.opacity(0.45)
.flexShrink(0)
if (this.jobs.length === 0) {
VStack(() => {
p("No jobs match your filters")
.margin(0)
.fontSize(0.9, em)
.color("var(--headertext)")
.opacity(0.4)
.textAlign("center")
})
.flex(1)
.justifyContent("center")
.alignItems("center")
} else {
this.jobs.forEach(job => this.renderCard(job))
}
})
.paddingHorizontal(0.75, em)
.paddingTop(1, em)
.paddingBottom(1, em)
.gap(0.5, em)
.overflowY("auto")
.height(100, pct)
.boxSizing("border-box")
}
renderCard(job) {
const self = this
const isSelected = job.id === this.selectedId;
VStack(() => {
// Logo placeholder + title row
HStack(() => {
// Company logo circle
VStack(() => {
p(job.company ? job.company[0].toUpperCase() : "?")
.margin(0)
.fontSize(1.1, em)
.fontWeight("700")
.color("white")
})
.width(2.6, em)
.height(2.6, em)
.borderRadius(0.45, em)
.background(this.companyColor(job.company))
.justifyContent("center")
.alignItems("center")
.flexShrink(0)
VStack(() => {
p(job.title)
.margin(0)
.fontSize(0.92, em)
.fontWeight("600")
.color("var(--headertext)")
.lineHeight("1.3")
p(job.company || "Unknown Company")
.margin(0)
.marginTop(0.1, em)
.fontSize(0.78, em)
.color("var(--headertext)")
.opacity(0.55)
})
.gap(0)
.flex(1)
.minWidth(0)
})
.gap(0.65, em)
.alignItems("flex-start")
// Meta row
HStack(() => {
if (job.location) {
this.metaChip("📍 " + job.location)
}
if (job.employment_type) {
this.metaChip(this.formatType(job.employment_type))
}
if (job.salary_number) {
this.metaChip(this.salaryLabel(job.salary_number, job.salary_period))
}
})
.gap(0.35, em)
.flexWrap("wrap")
// Posted date
if (job.posted_at) {
p(this.relativeDate(job.posted_at))
.margin(0)
.fontSize(0.7, em)
.color("var(--headertext)")
.opacity(0.35)
}
})
.gap(0.55, em)
.padding(0.9, em)
.borderRadius(0.65, em)
.background(isSelected ? "var(--app)" : "var(--darkaccent)")
.border(`1px solid ${isSelected ? "var(--quillred)" : "var(--divider)"}`)
.cursor("pointer")
.boxSizing("border-box")
.width(100, pct)
.onClick(function(done){ if(done){ self.onSelect(job.id) } })
}
metaChip(text) {
p(text)
.margin(0)
.fontSize(0.7, em)
.color("var(--headertext)")
.opacity(0.65)
.paddingHorizontal(0.5, em)
.paddingVertical(0.18, em)
.borderRadius(100, px)
.whiteSpace("nowrap")
}
formatType(type) {
return { "full-time": "Full-time", "part-time": "Part-time", "contract": "Contract", "internship": "Internship" }[type] || type
}
salaryLabel(number, period) {
const n = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(Number(number));
if (period === "one-time") return `$${n} one-time`;
if (period === "year") return `$${n}/yr`;
if (period === "month") return `$${n}/mo`;
if (period === "hour") return `$${n}/hr`;
return `$${n}`;
}
relativeDate(date) {
const d = new Date(date);
const days = Math.floor((Date.now() - d) / 86400000);
if (days === 0) return "Posted today";
if (days === 1) return "Posted yesterday";
if (days < 7) return `Posted ${days} days ago`;
if (days < 30) return `Posted ${Math.floor(days / 7)}w ago`;
return `Posted ${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(DesktopJobsList)

View File

@@ -0,0 +1,111 @@
class DesktopJobsToolbar extends Shadow {
constructor(filters, onFiltersChange) {
super()
this.filters = filters
this.onFiltersChange = onFiltersChange
}
render() {
HStack(() => {
// Keyword search
HStack(() => {
p("🔍")
.margin(0)
.fontSize(0.85, em)
.opacity(0.45)
.flexShrink(0)
input()
.attr({ type: "text", placeholder: "Search jobs, titles, companies…", value: this.filters.keyword })
.flex(1)
.border("none")
.outline("none")
.background("transparent")
.color("var(--headertext)")
.fontSize(0.9, em)
.onInput((e) => this.onFiltersChange({ ...this.filters, keyword: e.target.value }))
})
.gap(0.55, em)
.paddingHorizontal(0.9, em)
.paddingVertical(0.6, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.5, em)
.alignItems("center")
.flex(1)
// Location
HStack(() => {
p("📍")
.margin(0)
.fontSize(0.85, em)
.opacity(0.45)
.flexShrink(0)
input()
.attr({ type: "text", placeholder: "Location", value: this.filters.location })
.flex(1)
.border("none")
.outline("none")
.background("transparent")
.color("var(--headertext)")
.fontSize(0.9, em)
.onInput((e) => this.onFiltersChange({ ...this.filters, location: e.target.value }))
})
.gap(0.55, em)
.paddingHorizontal(0.9, em)
.paddingVertical(0.6, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.5, em)
.alignItems("center")
.width(200, px)
.flexShrink(0)
// Job type filter
this.filterSelect("Type", [
{ label: "All Types", value: "" },
{ label: "Full-time", value: "full-time" },
{ label: "Part-time", value: "part-time" },
{ label: "Contract", value: "contract" },
{ label: "Internship", value: "internship" },
], this.filters.type, (v) => this.onFiltersChange({ ...this.filters, type: v }))
// Experience level
this.filterSelect("Level", [
{ label: "All Levels", value: "" },
{ label: "Entry", value: "entry" },
{ label: "Mid", value: "mid" },
{ label: "Senior", value: "senior" },
], this.filters.level, (v) => this.onFiltersChange({ ...this.filters, level: v }))
})
.gap(0.65, em)
.paddingHorizontal(1.5, em)
.paddingVertical(0.85, em)
.borderBottom("1px solid var(--divider)")
.alignItems("center")
.width(100, pct)
.boxSizing("border-box")
.flexShrink(0)
}
filterSelect(placeholder, options, value, onChange) {
select(() => {
options.forEach(opt => {
option(opt.label)
.attr({ value: opt.value, selected: opt.value === value ? "" : null })
})
})
.paddingVertical(0.55, em)
.paddingHorizontal(0.75, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.5, em)
.color("var(--headertext)")
.fontSize(0.85, em)
.outline("none")
.cursor("pointer")
.flexShrink(0)
.onEvent("change", (e) => onChange(e.target.value))
}
}
register(DesktopJobsToolbar)

338
jobs/desktop/jobs.js Normal file
View File

@@ -0,0 +1,338 @@
import "./DesktopJobsList.js"
import "./DesktopJobDetail.js"
import server from "/@server/server.js"
import "/_/code/components/LoadingCircle.js"
css(`
jobs- {
font-family: 'Arial';
scrollbar-width: none;
-ms-overflow-style: none;
}
jobs- select {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.65em center;
padding-right: 1.8em !important;
}
`)
class Jobs extends Shadow {
loaded = false
constructor() {
super()
this.filters = { keyword: "", location: "", type: "", level: "" }
this.selectedJobId = null
this.jobs = [
{
id: 1,
title: "Senior Frontend Engineer",
company: "Acme Corp",
location: "San Francisco, CA",
employment_type: "full-time",
experience_level: "senior",
department: "Engineering",
salary_number: 165000,
salary_period: "year",
applicants: 47,
posted_at: new Date(Date.now() - 2 * 86400000),
skills: ["React", "TypeScript", "CSS", "GraphQL", "Node.js"],
description: "We're looking for a Senior Frontend Engineer to join our growing product team. You'll work closely with designers, backend engineers, and product managers to build fast, accessible, and beautiful web experiences.\n\nYou'll have ownership over large features and be expected to make architectural decisions. We ship every week and care deeply about code quality and UX."
},
{
id: 2,
title: "Product Designer",
company: "Blue River",
location: "New York, NY",
employment_type: "full-time",
experience_level: "mid",
department: "Design",
salary_number: 120000,
salary_period: "year",
applicants: 112,
posted_at: new Date(Date.now() - 5 * 86400000),
skills: ["Figma", "Design Systems", "Prototyping", "User Research"],
description: "Blue River is hiring a Product Designer to lead design across our core consumer product. You'll partner with PMs and engineers to take features from zero to one, establish design patterns, and advocate for users in every decision.\n\nWe're a small, fast-moving team and designers have a huge impact here."
},
{
id: 3,
title: "Backend Engineer",
company: "Orbit Systems",
location: "Remote",
employment_type: "contract",
experience_level: "mid",
department: "Platform",
salary_number: 95,
salary_period: "hour",
applicants: 29,
posted_at: new Date(Date.now() - 1 * 86400000),
skills: ["Go", "PostgreSQL", "Kubernetes", "gRPC", "Redis"],
description: "6-month contract (potential to extend) for a backend engineer to help scale our platform infrastructure. You'll be responsible for building and maintaining APIs used by millions of users, optimizing database performance, and improving system reliability."
},
{
id: 4,
title: "Marketing Manager",
company: "Groundwork",
location: "Austin, TX",
employment_type: "full-time",
experience_level: "mid",
department: "Marketing",
salary_number: 98000,
salary_period: "year",
applicants: 88,
posted_at: new Date(Date.now() - 10 * 86400000),
skills: ["SEO", "Content Strategy", "Analytics", "Paid Acquisition", "Email Marketing"],
description: "We need a Marketing Manager to own our top-of-funnel growth. You'll manage content, paid channels, and email campaigns — and be the person who keeps the brand consistent across every touchpoint. You'll report directly to our Head of Growth."
},
{
id: 5,
title: "Data Analyst",
company: "Compass Data",
location: "Chicago, IL",
employment_type: "full-time",
experience_level: "entry",
department: "Analytics",
salary_number: 75000,
salary_period: "year",
applicants: 203,
posted_at: new Date(Date.now() - 3 * 86400000),
skills: ["SQL", "Python", "Tableau", "dbt", "Excel"],
description: "A great entry-level opportunity for someone who loves data. You'll work with our analytics team to build dashboards, run ad-hoc analysis, and help business teams make data-driven decisions. We'll invest in your growth and give you plenty of mentorship."
},
{
id: 6,
title: "iOS Engineer",
company: "Fieldwork",
location: "Seattle, WA",
employment_type: "full-time",
experience_level: "senior",
department: "Mobile",
salary_number: 175000,
salary_period: "year",
applicants: 34,
posted_at: new Date(Date.now() - 7 * 86400000),
skills: ["Swift", "SwiftUI", "Combine", "CoreData", "Xcode"],
description: "Fieldwork is building next-generation tools for field service teams. Our iOS app is the most important surface we have — technicians use it every day on job sites. We need a senior iOS engineer who cares about performance, offline reliability, and a great UX."
},
{
id: 7,
title: "Operations Coordinator",
company: "Maple & Co",
location: "Boston, MA",
employment_type: "part-time",
experience_level: "entry",
department: "Operations",
salary_number: 28,
salary_period: "hour",
applicants: 61,
posted_at: new Date(Date.now() - 14 * 86400000),
skills: ["Project Management", "Excel", "Communication", "Scheduling"],
description: "Part-time role (20 hrs/week) helping our operations team stay organized. You'll coordinate schedules, manage vendor relationships, and help improve internal workflows. Ideal for someone who's highly organized and excited to grow into a full-time ops role."
},
{
id: 8,
title: "Machine Learning Intern",
company: "NeuralPath",
location: "Remote",
employment_type: "internship",
experience_level: "entry",
department: "AI Research",
salary_number: 8000,
salary_period: "month",
applicants: 394,
posted_at: new Date(Date.now() - 0),
skills: ["Python", "PyTorch", "Linear Algebra", "Git"],
description: "Summer internship (12 weeks) on our ML research team. You'll work alongside researchers on real problems in NLP and recommendation systems. We expect you to ship something you're proud of by the end of the summer. Strong preference for candidates who can start June 2."
},
]
this.selectedJobId = this.jobs[0]?.id || null
this.loadJobs()
}
async loadJobs() {
const fetched = await server.getJobs(global.currentNetwork.id)
this.loaded = true
if (fetched?.length) {
this.jobs = fetched
if (!this.selectedJobId) this.selectedJobId = fetched[0]?.id || null
}
this.rerender()
}
get filteredJobs() {
const { keyword, location, type, level } = this.filters;
return this.jobs.filter(job => {
if (keyword) {
const kw = keyword.toLowerCase();
const hay = [job.title, job.company, job.description, job.department].join(" ").toLowerCase();
if (!hay.includes(kw)) return false;
}
if (location) {
if (!job.location?.toLowerCase().includes(location.toLowerCase())) return false;
}
if (type && job.employment_type !== type) return false;
if (level && job.experience_level !== level) return false;
return true;
});
}
get selectedJob() {
return this.jobs.find(j => j.id === this.selectedJobId) || null;
}
filterSelect(options, currentValue, onChange) {
select(() => {
options.forEach(opt => {
option(opt.label)
.attr({ value: opt.value, selected: opt.value === currentValue ? "" : null })
})
})
.paddingVertical(0.55, em)
.paddingHorizontal(0.75, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.5, em)
.color("var(--headertext)")
.fontSize(0.85, em)
.outline("none")
.cursor("pointer")
.flexShrink(0)
.onEvent("change", (e) => onChange(e.target.value))
}
render() {
const filtered = this.filteredJobs;
VStack(() => {
// Toolbar — rendered inline so text inputs stay in this component
// and don't lose focus on rerender
HStack(() => {
HStack(() => {
p("🔍")
.margin(0).fontSize(0.85, em).opacity(0.45).flexShrink(0)
input()
.attr({ type: "text", placeholder: "Search jobs, titles, companies…", value: this.filters.keyword })
.flex(1).border("none").outline("none")
.background("transparent").color("var(--headertext)")
.fontSize(0.9, em)
.onInput((e) => {
this.filters.keyword = e.target.value
const newFiltered = this.filteredJobs
if (this.selectedJobId && !newFiltered.find(j => j.id === this.selectedJobId)) {
this.selectedJobId = newFiltered[0]?.id || null
}
this.rerender()
})
})
.gap(0.55, em)
.marginLeft(60, px)
.paddingHorizontal(0.9, em)
.paddingVertical(0.6, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.5, em)
.alignItems("center")
.flex(1)
HStack(() => {
p("📍")
.margin(0).fontSize(0.85, em).opacity(0.45).flexShrink(0)
input()
.attr({ type: "text", placeholder: "Location", value: this.filters.location })
.flex(1).border("none").outline("none")
.background("transparent").color("var(--headertext)")
.fontSize(0.9, em)
.onInput((e) => {
this.filters.location = e.target.value
const newFiltered = this.filteredJobs
if (this.selectedJobId && !newFiltered.find(j => j.id === this.selectedJobId)) {
this.selectedJobId = newFiltered[0]?.id || null
}
this.rerender()
})
})
.gap(0.55, em).paddingHorizontal(0.9, em).paddingVertical(0.6, em)
.background("var(--darkaccent)").border("1px solid var(--divider)")
.borderRadius(0.5, em).alignItems("center").width(200, px).flexShrink(0)
this.filterSelect([
{ label: "All Types", value: "" },
{ label: "Full-time", value: "full-time" },
{ label: "Part-time", value: "part-time" },
{ label: "Contract", value: "contract" },
{ label: "Internship", value: "internship" },
], this.filters.type, (v) => {
this.filters.type = v
const newFiltered = this.filteredJobs
if (this.selectedJobId && !newFiltered.find(j => j.id === this.selectedJobId)) {
this.selectedJobId = newFiltered[0]?.id || null
}
this.rerender()
})
this.filterSelect([
{ label: "All Levels", value: "" },
{ label: "Entry", value: "entry" },
{ label: "Mid", value: "mid" },
{ label: "Senior", value: "senior" },
], this.filters.level, (v) => {
this.filters.level = v
const newFiltered = this.filteredJobs
if (this.selectedJobId && !newFiltered.find(j => j.id === this.selectedJobId)) {
this.selectedJobId = newFiltered[0]?.id || null
}
this.rerender()
})
})
.gap(0.65, em).paddingHorizontal(1.5, em).paddingVertical(0.85, em)
.borderBottom("1px solid var(--divider)").alignItems("center")
.width(100, pct).boxSizing("border-box").flexShrink(0)
if (!this.loaded) {
VStack(() => LoadingCircle())
.flex(1)
.justifyContent("center")
.alignItems("center")
} else {
HStack(() => {
// Left: job list
VStack(() => {
DesktopJobsList(filtered, this.selectedJobId, (id) => {
this.selectedJobId = id;
this.rerender();
})
})
.width(360, px)
.minWidth(320, px)
.maxWidth(400, px)
.height(100, pct)
.borderRight("1px solid var(--divider)")
.flexShrink(0)
.overflow("hidden")
// Right: job detail
VStack(() => {
DesktopJobDetail(this.selectedJob)
})
.flex(1)
.height(100, pct)
.overflow("hidden")
})
.flex(1)
.minHeight(0)
.width(100, pct)
.overflow("hidden")
}
})
.height(100, pct)
.width(100, pct)
.overflow("hidden")
}
}
register(Jobs)

3
jobs/icons/jobs.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="100" height="90" viewBox="0 0 100 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M93.3636 15.8877H73.7455V11.2591C73.7455 5.05435 68.7 0 62.5 0H37.2909C31.0909 0 26.0455 5.04529 26.0455 11.2591V15.8877H6.63636C2.98182 15.8877 0 18.8678 0 22.5272V41.2772C0 43.4783 1.46364 45.3895 3.57273 46.0054V83.3696C3.57273 87.029 6.54545 90 10.1909 90H89.8091C93.4636 90 96.4273 87.029 96.4273 83.3696V46.0054C98.5364 45.3895 100 43.4783 100 41.2772V22.5272C100 18.8678 97.0273 15.8877 93.3636 15.8877ZM93.7 83.3696C93.7 85.5254 91.9545 87.2826 89.8091 87.2826H10.1909C8.04545 87.2826 6.3 85.5254 6.3 83.3696V46.712L43.7364 56.2138V60.933C43.7364 63.1522 45.5455 64.9547 47.7636 64.9547H52.0364C54.2545 64.9547 56.0546 63.1522 56.0546 60.933V56.2772L93.7 46.712V83.3696ZM52.0273 50.1993C52.7455 50.1993 53.3182 50.788 53.3182 51.5036V60.933C53.3182 61.6576 52.7364 62.2373 52.0273 62.2373H47.7545C47.0364 62.2373 46.4545 61.6485 46.4545 60.933V51.5036C46.4545 50.779 47.0364 50.1993 47.7545 50.1993H52.0273ZM97.2636 41.2772C97.2636 42.2917 96.5727 43.1793 95.5818 43.433C55.2455 53.6775 56.0455 53.5688 56.0455 53.4783V51.5127C56.0455 49.2935 54.2364 47.4909 52.0273 47.4909H47.7545C45.5636 47.4909 43.7273 49.2482 43.7273 51.5127V53.4149L4.40909 43.433C3.41818 43.1793 2.72727 42.3007 2.72727 41.2772V22.5272C2.72727 20.3623 4.48182 18.6051 6.63636 18.6051H93.3636C95.5182 18.6051 97.2727 20.3623 97.2727 22.5272V41.2772H97.2636ZM28.7727 11.25C28.7727 6.53986 32.5909 2.70833 37.2909 2.70833H62.5C67.2 2.70833 71.0182 6.53986 71.0182 11.25V15.8786H66.3182V11.25C66.3182 9.13949 64.6 7.41848 62.5 7.41848H37.2909C35.1818 7.41848 33.4727 9.13949 33.4727 11.25V15.8786H28.7727V11.25ZM36.2 15.8786V11.25C36.2 10.6341 36.6909 10.1359 37.2909 10.1359H62.5C63.1 10.1359 63.5909 10.6341 63.5909 11.25V15.8786H36.1909H36.2Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

3
jobs/icons/jobslight.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="110" height="100" viewBox="0 0 110 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M102.7 17.54H81.12V12.43C81.12 5.58 75.57 0 68.75 0H41.02C34.2 0 28.65 5.57 28.65 12.43V17.54H7.3C3.28 17.54 0 20.83 0 24.87V45.57C0 48 1.61 50.11 3.93 50.79V92.04C3.93 96.08 7.2 99.36 11.21 99.36H98.79C102.81 99.36 106.07 96.08 106.07 92.04V50.79C108.39 50.11 110 48 110 45.57V24.87C110 20.83 106.73 17.54 102.7 17.54ZM103.07 92.04C103.07 94.42 101.15 96.36 98.79 96.36H11.21C8.85 96.36 6.93 94.42 6.93 92.04V51.57L48.11 62.06V67.27C48.11 69.72 50.1 71.71 52.54 71.71H57.24C59.68 71.71 61.66 69.72 61.66 67.27V62.13L103.07 51.57V92.04ZM57.23 55.42C58.02 55.42 58.65 56.07 58.65 56.86V67.27C58.65 68.07 58.01 68.71 57.23 68.71H52.53C51.74 68.71 51.1 68.06 51.1 67.27V56.86C51.1 56.06 51.74 55.42 52.53 55.42H57.23ZM106.99 45.57C106.99 46.69 106.23 47.67 105.14 47.95C60.77 59.26 61.65 59.14 61.65 59.04V56.87C61.65 54.42 59.66 52.43 57.23 52.43H52.53C50.12 52.43 48.1 54.37 48.1 56.87V58.97L4.85 47.95C3.76 47.67 3 46.7 3 45.57V24.87C3 22.48 4.93 20.54 7.3 20.54H102.7C105.07 20.54 107 22.48 107 24.87V45.57H106.99ZM31.65 12.42C31.65 7.22 35.85 2.99 41.02 2.99H68.75C73.92 2.99 78.12 7.22 78.12 12.42V17.53H72.95V12.42C72.95 10.09 71.06 8.19 68.75 8.19H41.02C38.7 8.19 36.82 10.09 36.82 12.42V17.53H31.65V12.42ZM39.82 17.53V12.42C39.82 11.74 40.36 11.19 41.02 11.19H68.75C69.41 11.19 69.95 11.74 69.95 12.42V17.53H39.81H39.82Z" fill="#FFE9C8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="102" height="92" viewBox="0 0 102 92" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M63.5 0.5C69.9767 0.5 75.245 5.77871 75.2451 12.2588V16.3877H94.3633C98.3036 16.3877 101.5 19.5923 101.5 23.5273V42.2773C101.5 44.5771 100.051 46.5844 97.9277 47.3652V84.3691C97.9277 88.3036 94.741 91.4997 90.8096 91.5H11.1904C7.26853 91.4997 4.07227 88.304 4.07227 84.3691V47.3652C1.94941 46.5844 0.500071 44.5771 0.5 42.2773V23.5273C0.5 19.5918 3.70596 16.3877 7.63672 16.3877H26.5459V12.2588C26.546 5.76939 31.8146 0.5 38.291 0.5H63.5ZM7.7998 84.3691C7.7998 86.2515 9.32416 87.782 11.1904 87.7822H90.8096C92.6758 87.782 94.2002 86.2515 94.2002 84.3691V48.3545L57.5547 57.665V61.9326C57.5547 64.4275 55.5309 66.4551 53.0361 66.4551H48.7637C46.2703 66.4551 44.2363 64.4289 44.2363 61.9326V57.6025L7.7998 48.3535V84.3691ZM48.7549 51.6992C48.3134 51.6992 47.9541 52.0548 47.9541 52.5039V61.9326C47.9541 62.3749 48.3157 62.7373 48.7549 62.7373H53.0273C53.4574 62.7373 53.8184 62.3839 53.8184 61.9326V52.5039C53.8184 52.0571 53.462 51.6993 53.0273 51.6992H48.7549ZM7.63672 20.1055C5.75962 20.1055 4.22754 21.6373 4.22754 23.5273V42.2773C4.22761 43.0695 4.76073 43.7505 5.5332 43.9482H5.53223L44.2275 53.7725V52.5127C44.2275 49.9652 46.2947 47.9912 48.7549 47.9912H53.0273C55.5123 47.9912 57.5459 50.0171 57.5459 52.5127V53.8721C58.1385 53.7243 59.2979 53.4278 61.5635 52.8467C66.4059 51.6045 76.2902 49.0704 96.458 43.9482L96.5996 43.9062C97.2939 43.665 97.7636 43.0127 97.7637 42.2773V41.7773H97.7725V23.5273C97.7725 21.6373 96.2404 20.1055 94.3633 20.1055H7.63672ZM38.291 4.20801C33.8686 4.20801 30.2725 7.81459 30.2725 12.25V16.3789H33.9727V12.25C33.9727 9.8655 35.9036 7.91895 38.291 7.91895H63.5C65.8775 7.91895 67.8184 9.86473 67.8184 12.25V16.3789H71.5186V12.25C71.5186 7.81459 67.9225 4.20801 63.5 4.20801H38.291ZM38.291 11.6357C37.9761 11.6357 37.7002 11.9012 37.7002 12.25V16.3789H64.0908V12.25C64.0908 11.9012 63.8149 11.6357 63.5 11.6357H38.291Z" fill="#BD2D2D" stroke="#FFE9C8"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

441
jobs/jobs.js Normal file
View File

@@ -0,0 +1,441 @@
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)