@jsonresume/jobs 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/bin/cli.js +6 -4
- package/package.json +1 -1
- package/src/api.js +9 -1
- package/src/cache.js +2 -1
- package/src/formatters.js +0 -20
- package/src/localApi.js +14 -3
- package/src/tui/AIPanel.js +113 -15
- package/src/tui/App.js +149 -14
- package/src/tui/FilterManager.js +3 -7
- package/src/tui/HelpModal.js +17 -11
- package/src/tui/JobDetail.js +2 -0
- package/src/tui/JobList.js +52 -5
- package/src/tui/SearchManager.js +3 -3
- package/src/tui/StatusBar.js +27 -8
- package/src/tui/useAI.js +387 -4
- package/src/tui/useJobs.js +2 -1
- package/src/tui/PreviewPane.js +0 -68
package/src/tui/useAI.js
CHANGED
|
@@ -1,19 +1,74 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react';
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
import { writeFileSync } from 'fs';
|
|
2
3
|
import { generateText } from 'ai';
|
|
3
4
|
import { openai } from '@ai-sdk/openai';
|
|
4
5
|
|
|
6
|
+
function buildResumeText(resume) {
|
|
7
|
+
return [
|
|
8
|
+
resume?.basics?.name,
|
|
9
|
+
resume?.basics?.label,
|
|
10
|
+
resume?.basics?.summary,
|
|
11
|
+
...(resume?.skills || []).map(
|
|
12
|
+
(s) => `${s.name}: ${(s.keywords || []).join(', ')}`
|
|
13
|
+
),
|
|
14
|
+
...(resume?.work || [])
|
|
15
|
+
.slice(0, 5)
|
|
16
|
+
.map(
|
|
17
|
+
(w) =>
|
|
18
|
+
`${w.position} at ${w.name}${
|
|
19
|
+
w.startDate ? ` (${w.startDate})` : ''
|
|
20
|
+
}: ${w.summary || ''}`
|
|
21
|
+
),
|
|
22
|
+
...(resume?.projects || [])
|
|
23
|
+
.slice(0, 3)
|
|
24
|
+
.map((p) => `Project: ${p.name} — ${p.description || ''}`),
|
|
25
|
+
]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join('\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildJobText(job, rawContent) {
|
|
31
|
+
return [
|
|
32
|
+
`Title: ${job.title}`,
|
|
33
|
+
`Company: ${job.company}`,
|
|
34
|
+
`Salary: ${job.salary || 'Not listed'}`,
|
|
35
|
+
`Remote: ${job.remote || 'Not specified'}`,
|
|
36
|
+
job.experience ? `Experience: ${job.experience}` : '',
|
|
37
|
+
job.type ? `Type: ${job.type}` : '',
|
|
38
|
+
job.url ? `HN Post: ${job.url}` : '',
|
|
39
|
+
`Description: ${job.description || ''}`,
|
|
40
|
+
`Skills: ${(job.skills || []).map((s) => s.name || s).join(', ')}`,
|
|
41
|
+
...(job.qualifications || []).map((q) => `Qualification: ${q}`),
|
|
42
|
+
...(job.responsibilities || []).map((r) => `Responsibility: ${r}`),
|
|
43
|
+
rawContent ? `\nFull original posting:\n${rawContent}` : '',
|
|
44
|
+
]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
5
49
|
export function useAI(resume) {
|
|
6
50
|
const [text, setText] = useState('');
|
|
7
51
|
const [loading, setLoading] = useState(false);
|
|
8
52
|
const [error, setError] = useState(null);
|
|
53
|
+
const [mode, setMode] = useState('ai'); // 'ai' | 'cover'
|
|
9
54
|
const hasKey = Boolean(process.env.OPENAI_API_KEY);
|
|
55
|
+
const childRef = useRef(null);
|
|
56
|
+
const dossierJobId = useRef(null);
|
|
57
|
+
// Local cache: jobId → { text, done, loading }
|
|
58
|
+
const dossierCache = useRef(new Map());
|
|
59
|
+
// Bump to trigger re-renders when dossier status changes
|
|
60
|
+
const [, setDossierTick] = useState(0);
|
|
61
|
+
const bumpTick = () => setDossierTick((t) => t + 1);
|
|
10
62
|
|
|
11
63
|
const summarizeJob = useCallback(
|
|
12
64
|
async (job) => {
|
|
13
65
|
if (!hasKey) {
|
|
14
|
-
setError(
|
|
66
|
+
setError(
|
|
67
|
+
'AI requires OPENAI_API_KEY — run: export OPENAI_API_KEY=sk-...'
|
|
68
|
+
);
|
|
15
69
|
return;
|
|
16
70
|
}
|
|
71
|
+
setMode('ai');
|
|
17
72
|
setLoading(true);
|
|
18
73
|
setError(null);
|
|
19
74
|
setText('');
|
|
@@ -54,12 +109,292 @@ export function useAI(resume) {
|
|
|
54
109
|
[resume, hasKey]
|
|
55
110
|
);
|
|
56
111
|
|
|
112
|
+
const dossier = useCallback(
|
|
113
|
+
async (job, api) => {
|
|
114
|
+
const cached = dossierCache.current.get(job.id);
|
|
115
|
+
|
|
116
|
+
// If dossier is already loading or loaded for this job, just show it
|
|
117
|
+
if (cached && (cached.loading || cached.done)) {
|
|
118
|
+
setMode('cover');
|
|
119
|
+
setText(cached.text || '');
|
|
120
|
+
setLoading(cached.loading || false);
|
|
121
|
+
setError(null);
|
|
122
|
+
dossierJobId.current = job.id;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
setMode('cover');
|
|
127
|
+
setError(null);
|
|
128
|
+
setText('');
|
|
129
|
+
dossierJobId.current = job.id;
|
|
130
|
+
dossierCache.current.set(job.id, {
|
|
131
|
+
text: '',
|
|
132
|
+
done: false,
|
|
133
|
+
loading: true,
|
|
134
|
+
});
|
|
135
|
+
bumpTick();
|
|
136
|
+
|
|
137
|
+
// Helper to update both state and cache
|
|
138
|
+
// Only updates visible state if this job is still the active dossier
|
|
139
|
+
const updateText = (val) => {
|
|
140
|
+
const entry = dossierCache.current.get(job.id);
|
|
141
|
+
if (entry) entry.text = val;
|
|
142
|
+
if (dossierJobId.current === job.id) setText(val);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Check server for existing dossier
|
|
146
|
+
try {
|
|
147
|
+
const { content } = await api.fetchDossier(job.id);
|
|
148
|
+
if (content) {
|
|
149
|
+
updateText(content);
|
|
150
|
+
dossierCache.current.set(job.id, {
|
|
151
|
+
text: content,
|
|
152
|
+
done: true,
|
|
153
|
+
loading: false,
|
|
154
|
+
});
|
|
155
|
+
bumpTick();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
/* no cached dossier */
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check if claude CLI is available
|
|
163
|
+
const { execSync } = await import('child_process');
|
|
164
|
+
let claudePath;
|
|
165
|
+
try {
|
|
166
|
+
claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
|
|
167
|
+
} catch {
|
|
168
|
+
setError(
|
|
169
|
+
'Claude Code CLI not found. Install it: npm install -g @anthropic-ai/claude-code'
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Kill any existing claude process
|
|
175
|
+
if (childRef.current) {
|
|
176
|
+
try {
|
|
177
|
+
childRef.current.kill();
|
|
178
|
+
} catch {}
|
|
179
|
+
childRef.current = null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setLoading(true);
|
|
183
|
+
|
|
184
|
+
// Fetch resume if not available yet
|
|
185
|
+
let currentResume = resume;
|
|
186
|
+
if (!currentResume) {
|
|
187
|
+
try {
|
|
188
|
+
const me = await api.fetchMe();
|
|
189
|
+
currentResume = me.resume;
|
|
190
|
+
} catch {
|
|
191
|
+
/* proceed without resume */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Fetch full job detail for raw content
|
|
196
|
+
let rawContent = '';
|
|
197
|
+
try {
|
|
198
|
+
const detail = await api.fetchJobDetail(job.id);
|
|
199
|
+
rawContent = detail.raw_content || detail.full_description || '';
|
|
200
|
+
} catch {}
|
|
201
|
+
|
|
202
|
+
const resumeText = buildResumeText(currentResume);
|
|
203
|
+
const jobText = buildJobText(job, rawContent);
|
|
204
|
+
|
|
205
|
+
const prompt = `You are a job search research assistant. A candidate is considering applying to this role. Do thorough research and produce a comprehensive dossier.
|
|
206
|
+
|
|
207
|
+
## Candidate Resume
|
|
208
|
+
${resumeText}
|
|
209
|
+
|
|
210
|
+
## Job Posting
|
|
211
|
+
${jobText}
|
|
212
|
+
|
|
213
|
+
## Your Task
|
|
214
|
+
|
|
215
|
+
Research everything you can and produce a complete dossier covering:
|
|
216
|
+
|
|
217
|
+
### 1. Company Deep Dive
|
|
218
|
+
- What the company does, their products/services
|
|
219
|
+
- Funding stage, size, recent news
|
|
220
|
+
- Tech stack and engineering culture
|
|
221
|
+
- Leadership team
|
|
222
|
+
- Employee sentiment and Glassdoor/Blind highlights
|
|
223
|
+
- Any red flags or concerns
|
|
224
|
+
|
|
225
|
+
### 2. Role Analysis
|
|
226
|
+
- What you'd actually be doing day-to-day
|
|
227
|
+
- Team context — who you'd work with
|
|
228
|
+
- Growth trajectory for this role
|
|
229
|
+
- How this role fits into the company's current priorities
|
|
230
|
+
|
|
231
|
+
### 3. Fit Assessment
|
|
232
|
+
- Matching skills and experience (be specific, reference the candidate's actual background)
|
|
233
|
+
- Skill gaps to acknowledge or address
|
|
234
|
+
- Adjacent experience that transfers
|
|
235
|
+
- Overall fit rating: Strong / Good / Stretch
|
|
236
|
+
|
|
237
|
+
### 4. Cover Letter Talking Points
|
|
238
|
+
- 5-7 specific bullet points to mention, each referencing the candidate's real experience
|
|
239
|
+
- What angle to take (technical depth? leadership? domain expertise?)
|
|
240
|
+
- What to emphasize vs. downplay
|
|
241
|
+
|
|
242
|
+
### 5. Contact Info
|
|
243
|
+
- Email addresses from the posting
|
|
244
|
+
- Who posted (HN username from the URL if available)
|
|
245
|
+
- Best way to reach out
|
|
246
|
+
|
|
247
|
+
### 6. Interview Prep
|
|
248
|
+
- Questions they'll likely ask for this role
|
|
249
|
+
- Questions the candidate should ask
|
|
250
|
+
- Topics to research before interviewing
|
|
251
|
+
|
|
252
|
+
### 7. Compensation Context
|
|
253
|
+
- Market rate for this role/level/location
|
|
254
|
+
- How the listed salary compares
|
|
255
|
+
- Negotiation leverage points
|
|
256
|
+
|
|
257
|
+
Be thorough, specific, and opinionated. Reference the candidate's actual experience when making recommendations.`;
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const { spawn } = await import('child_process');
|
|
261
|
+
// Remove CLAUDECODE env var to allow nested claude sessions
|
|
262
|
+
const env = { ...process.env };
|
|
263
|
+
delete env.CLAUDECODE;
|
|
264
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
265
|
+
delete env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
|
266
|
+
|
|
267
|
+
const child = spawn(
|
|
268
|
+
claudePath,
|
|
269
|
+
[
|
|
270
|
+
'--print',
|
|
271
|
+
'--output-format',
|
|
272
|
+
'stream-json',
|
|
273
|
+
'--verbose',
|
|
274
|
+
'--allowedTools',
|
|
275
|
+
'WebSearch',
|
|
276
|
+
'WebFetch',
|
|
277
|
+
'--',
|
|
278
|
+
prompt,
|
|
279
|
+
],
|
|
280
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], env }
|
|
281
|
+
);
|
|
282
|
+
childRef.current = child;
|
|
283
|
+
|
|
284
|
+
let finalResult = '';
|
|
285
|
+
let buffer = '';
|
|
286
|
+
let statusLine = '';
|
|
287
|
+
|
|
288
|
+
function processLine(line) {
|
|
289
|
+
if (!line.trim()) return;
|
|
290
|
+
try {
|
|
291
|
+
const event = JSON.parse(line);
|
|
292
|
+
|
|
293
|
+
if (event.type === 'assistant') {
|
|
294
|
+
// Extract text from assistant message content
|
|
295
|
+
const content = event.message?.content || [];
|
|
296
|
+
for (const block of content) {
|
|
297
|
+
if (block.type === 'text' && block.text) {
|
|
298
|
+
finalResult = block.text;
|
|
299
|
+
updateText(
|
|
300
|
+
statusLine ? `${statusLine}\n\n${finalResult}` : finalResult
|
|
301
|
+
);
|
|
302
|
+
} else if (block.type === 'tool_use') {
|
|
303
|
+
// Show what Claude is doing (e.g. WebSearch, WebFetch)
|
|
304
|
+
const name = block.name || 'tool';
|
|
305
|
+
const input = block.input || {};
|
|
306
|
+
if (name === 'WebSearch' || name === 'WebFetch') {
|
|
307
|
+
statusLine = `🔍 ${name}: ${
|
|
308
|
+
input.query || input.url || ''
|
|
309
|
+
}`;
|
|
310
|
+
} else {
|
|
311
|
+
statusLine = `⚙ Using ${name}…`;
|
|
312
|
+
}
|
|
313
|
+
updateText(
|
|
314
|
+
finalResult ? `${statusLine}\n\n${finalResult}` : statusLine
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (event.type === 'result' && event.result) {
|
|
321
|
+
finalResult = event.result;
|
|
322
|
+
updateText(finalResult);
|
|
323
|
+
}
|
|
324
|
+
} catch {
|
|
325
|
+
// Not valid JSON, skip
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
child.stdout.on('data', (chunk) => {
|
|
330
|
+
buffer += chunk.toString();
|
|
331
|
+
// Process complete JSON lines
|
|
332
|
+
const lines = buffer.split('\n');
|
|
333
|
+
buffer = lines.pop() || '';
|
|
334
|
+
for (const line of lines) {
|
|
335
|
+
processLine(line);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
child.stderr.on('data', () => {});
|
|
340
|
+
|
|
341
|
+
await new Promise((resolve, reject) => {
|
|
342
|
+
child.on('close', (code) => {
|
|
343
|
+
// Only clear childRef if it's still our process
|
|
344
|
+
if (childRef.current === child) childRef.current = null;
|
|
345
|
+
// Process any remaining buffer
|
|
346
|
+
if (buffer.trim()) processLine(buffer);
|
|
347
|
+
if (code === 0) {
|
|
348
|
+
resolve();
|
|
349
|
+
} else if (code !== null) {
|
|
350
|
+
reject(new Error(`Claude exited with code ${code}`));
|
|
351
|
+
} else {
|
|
352
|
+
resolve(); // killed
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
child.on('error', (err) => {
|
|
356
|
+
if (childRef.current === child) childRef.current = null;
|
|
357
|
+
reject(err);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Save to server if we got output
|
|
362
|
+
if (finalResult.trim()) {
|
|
363
|
+
try {
|
|
364
|
+
await api.saveDossier(job.id, finalResult);
|
|
365
|
+
} catch {}
|
|
366
|
+
}
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if (err.message?.includes('exited with code')) {
|
|
369
|
+
setError(`Claude failed — ${err.message}`);
|
|
370
|
+
} else if (err.message !== 'killed') {
|
|
371
|
+
setError(err.message);
|
|
372
|
+
}
|
|
373
|
+
} finally {
|
|
374
|
+
const entry = dossierCache.current.get(job.id);
|
|
375
|
+
if (entry) {
|
|
376
|
+
entry.done = true;
|
|
377
|
+
entry.loading = false;
|
|
378
|
+
}
|
|
379
|
+
bumpTick();
|
|
380
|
+
if (dossierJobId.current === job.id) {
|
|
381
|
+
childRef.current = null;
|
|
382
|
+
setLoading(false);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
[resume]
|
|
387
|
+
);
|
|
388
|
+
|
|
57
389
|
const batchReview = useCallback(
|
|
58
390
|
async (jobs) => {
|
|
59
391
|
if (!hasKey) {
|
|
60
|
-
setError(
|
|
392
|
+
setError(
|
|
393
|
+
'AI requires OPENAI_API_KEY — run: export OPENAI_API_KEY=sk-...'
|
|
394
|
+
);
|
|
61
395
|
return;
|
|
62
396
|
}
|
|
397
|
+
setMode('ai');
|
|
63
398
|
setLoading(true);
|
|
64
399
|
setError(null);
|
|
65
400
|
setText('');
|
|
@@ -95,13 +430,61 @@ export function useAI(resume) {
|
|
|
95
430
|
[resume, hasKey]
|
|
96
431
|
);
|
|
97
432
|
|
|
433
|
+
const cancel = useCallback(() => {
|
|
434
|
+
if (childRef.current) {
|
|
435
|
+
try {
|
|
436
|
+
childRef.current.kill();
|
|
437
|
+
} catch {}
|
|
438
|
+
childRef.current = null;
|
|
439
|
+
}
|
|
440
|
+
}, []);
|
|
441
|
+
|
|
442
|
+
const textRef = useRef('');
|
|
443
|
+
textRef.current = text;
|
|
444
|
+
|
|
445
|
+
const exportDossier = useCallback((job) => {
|
|
446
|
+
// Use visible text state, or fall back to cache
|
|
447
|
+
const content =
|
|
448
|
+
textRef.current || dossierCache.current.get(job?.id)?.text || '';
|
|
449
|
+
if (!content) return null;
|
|
450
|
+
const company = (job?.company || 'unknown')
|
|
451
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
452
|
+
.replace(/-+/g, '-')
|
|
453
|
+
.replace(/^-|-$/g, '')
|
|
454
|
+
.toLowerCase()
|
|
455
|
+
.slice(0, 50);
|
|
456
|
+
const filename = `dossier-${company}.md`;
|
|
457
|
+
writeFileSync(filename, content, 'utf-8');
|
|
458
|
+
return filename;
|
|
459
|
+
}, []);
|
|
460
|
+
|
|
461
|
+
// Expose dossier status for job list icons
|
|
462
|
+
// Returns: 'generating' | 'done' | null
|
|
463
|
+
const getDossierStatus = useCallback((jobId) => {
|
|
464
|
+
const entry = dossierCache.current.get(jobId);
|
|
465
|
+
if (!entry) return null;
|
|
466
|
+
if (entry.loading) return 'generating';
|
|
467
|
+
if (entry.done) return 'done';
|
|
468
|
+
return null;
|
|
469
|
+
}, []);
|
|
470
|
+
|
|
98
471
|
return {
|
|
99
472
|
text,
|
|
100
473
|
loading,
|
|
101
474
|
error,
|
|
102
475
|
hasKey,
|
|
476
|
+
mode,
|
|
477
|
+
hasActiveProcess: Boolean(childRef.current),
|
|
478
|
+
getDossierStatus,
|
|
103
479
|
summarizeJob,
|
|
480
|
+
dossier,
|
|
104
481
|
batchReview,
|
|
105
|
-
|
|
482
|
+
exportDossier,
|
|
483
|
+
cancel,
|
|
484
|
+
clear: () => {
|
|
485
|
+
cancel();
|
|
486
|
+
setText('');
|
|
487
|
+
dossierJobId.current = null;
|
|
488
|
+
},
|
|
106
489
|
};
|
|
107
490
|
}
|
package/src/tui/useJobs.js
CHANGED
|
@@ -12,6 +12,7 @@ export function useJobs(api, activeFilters, tab, searchId) {
|
|
|
12
12
|
const params = useMemo(() => {
|
|
13
13
|
const p = { top: 100, days: 30 };
|
|
14
14
|
if (searchId) p.searchId = searchId;
|
|
15
|
+
if (api.mode) p.mode = api.mode;
|
|
15
16
|
for (const f of activeFilters || []) {
|
|
16
17
|
if (f.type === 'days') p.days = Number(f.value) || 30;
|
|
17
18
|
if (f.type === 'remote') p.remote = true;
|
|
@@ -19,7 +20,7 @@ export function useJobs(api, activeFilters, tab, searchId) {
|
|
|
19
20
|
if (f.type === 'search') p.search = f.value || '';
|
|
20
21
|
}
|
|
21
22
|
return p;
|
|
22
|
-
}, [activeFilters, searchId]);
|
|
23
|
+
}, [activeFilters, searchId, api.mode]);
|
|
23
24
|
|
|
24
25
|
const paramsKey = JSON.stringify(params);
|
|
25
26
|
|
package/src/tui/PreviewPane.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { Box, Text } from 'ink';
|
|
2
|
-
import { h } from './h.js';
|
|
3
|
-
import { formatSalary, formatLocation, stateIcon } from '../formatters.js';
|
|
4
|
-
|
|
5
|
-
export default function PreviewPane({ job }) {
|
|
6
|
-
if (!job) return null;
|
|
7
|
-
|
|
8
|
-
const cols = process.stdout.columns || 80;
|
|
9
|
-
const loc = formatLocation(job.location, job.remote);
|
|
10
|
-
const sal = formatSalary(job.salary, job.salary_usd);
|
|
11
|
-
const icon = stateIcon(job.state);
|
|
12
|
-
|
|
13
|
-
const skills = job.skills
|
|
14
|
-
? job.skills
|
|
15
|
-
.slice(0, 8)
|
|
16
|
-
.map((s) => s.name || s)
|
|
17
|
-
.join(' · ')
|
|
18
|
-
: null;
|
|
19
|
-
|
|
20
|
-
const desc = job.description
|
|
21
|
-
? job.description.replace(/\n/g, ' ').slice(0, cols * 2)
|
|
22
|
-
: null;
|
|
23
|
-
|
|
24
|
-
return h(
|
|
25
|
-
Box,
|
|
26
|
-
{
|
|
27
|
-
flexDirection: 'column',
|
|
28
|
-
borderStyle: 'single',
|
|
29
|
-
borderColor: 'gray',
|
|
30
|
-
borderTop: true,
|
|
31
|
-
borderBottom: false,
|
|
32
|
-
borderLeft: false,
|
|
33
|
-
borderRight: false,
|
|
34
|
-
paddingX: 1,
|
|
35
|
-
},
|
|
36
|
-
// Title line
|
|
37
|
-
h(
|
|
38
|
-
Box,
|
|
39
|
-
{ gap: 1 },
|
|
40
|
-
h(Text, { bold: true, color: 'white' }, job.title || '—'),
|
|
41
|
-
h(Text, { dimColor: true }, 'at'),
|
|
42
|
-
h(Text, { bold: true, color: 'cyan' }, job.company || '—'),
|
|
43
|
-
job.state ? h(Text, null, ` ${icon}`) : null
|
|
44
|
-
),
|
|
45
|
-
// Meta line
|
|
46
|
-
h(
|
|
47
|
-
Box,
|
|
48
|
-
{ gap: 2 },
|
|
49
|
-
h(Text, { dimColor: true }, `📍 ${loc}`),
|
|
50
|
-
h(Text, { dimColor: true }, `💰 ${sal}`),
|
|
51
|
-
job.experience
|
|
52
|
-
? h(Text, { dimColor: true }, `📊 ${job.experience}`)
|
|
53
|
-
: null,
|
|
54
|
-
job.url ? h(Text, { color: 'blue', dimColor: true }, 'o:open') : null
|
|
55
|
-
),
|
|
56
|
-
// Skills
|
|
57
|
-
skills
|
|
58
|
-
? h(
|
|
59
|
-
Box,
|
|
60
|
-
null,
|
|
61
|
-
h(Text, { color: 'yellow' }, 'Skills: '),
|
|
62
|
-
h(Text, { dimColor: true }, skills)
|
|
63
|
-
)
|
|
64
|
-
: null,
|
|
65
|
-
// Description snippet
|
|
66
|
-
desc ? h(Text, { dimColor: true, wrap: 'truncate-end' }, desc) : null
|
|
67
|
-
);
|
|
68
|
-
}
|