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)