@jsonresume/jobs 0.8.0 → 0.10.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 +141 -33
- package/bin/cli.js +63 -6
- package/package.json +1 -1
- package/src/api.js +1 -1
- package/src/formatters.js +0 -20
- package/src/localApi.js +72 -0
- package/src/localState.js +37 -0
- package/src/tui/App.js +22 -10
- package/src/tui/FilterManager.js +3 -7
- package/src/tui/Header.js +1 -3
- package/src/tui/HelpModal.js +16 -11
- package/src/tui/JobDetail.js +2 -1
- package/src/tui/SearchManager.js +3 -3
- package/src/tui/StatusBar.js +18 -7
- package/src/tui/useAI.js +6 -2
- package/src/tui/PreviewPane.js +0 -68
package/README.md
CHANGED
|
@@ -6,18 +6,30 @@
|
|
|
6
6
|
|
|
7
7
|
Search Hacker News "Who is Hiring" jobs matched against your [JSON Resume](https://jsonresume.org). Jobs are semantically ranked using AI embeddings — your resume is compared against hundreds of monthly job postings to surface the best fits.
|
|
8
8
|
|
|
9
|
+

|
|
10
|
+
|
|
9
11
|
## Quick Start
|
|
10
12
|
|
|
13
|
+
**With a registry account:**
|
|
14
|
+
|
|
11
15
|
```bash
|
|
12
16
|
npx @jsonresume/jobs
|
|
13
17
|
```
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
The CLI walks you through login on first run — all you need is a resume hosted at [registry.jsonresume.org](https://registry.jsonresume.org).
|
|
20
|
+
|
|
21
|
+
**With a local resume file (no account needed):**
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx @jsonresume/jobs --resume ./resume.json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or just drop a `resume.json` in your current directory and run `npx @jsonresume/jobs` — it's auto-detected.
|
|
16
28
|
|
|
17
29
|
## Prerequisites
|
|
18
30
|
|
|
19
31
|
- **Node.js** >= 18
|
|
20
|
-
- A JSON Resume on the [
|
|
32
|
+
- A [JSON Resume](https://jsonresume.org/schema/) — either hosted on the [registry](https://registry.jsonresume.org) or as a local `resume.json` file
|
|
21
33
|
- *(Optional)* `OPENAI_API_KEY` — enables AI summaries and batch ranking in the TUI
|
|
22
34
|
|
|
23
35
|
## Installation
|
|
@@ -57,6 +69,27 @@ To clear saved credentials:
|
|
|
57
69
|
npx @jsonresume/jobs logout
|
|
58
70
|
```
|
|
59
71
|
|
|
72
|
+
## Local Mode (No Account)
|
|
73
|
+
|
|
74
|
+
You don't need a registry account to use the TUI. If you have a `resume.json` file following the [JSON Resume schema](https://jsonresume.org/schema/), you can use it directly:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Explicit path
|
|
78
|
+
npx @jsonresume/jobs --resume ./my-resume.json
|
|
79
|
+
|
|
80
|
+
# Auto-detect (looks for resume.json in current directory)
|
|
81
|
+
npx @jsonresume/jobs
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
In local mode:
|
|
85
|
+
|
|
86
|
+
- Job matching works the same way (your resume is sent to the server for embedding and vector search)
|
|
87
|
+
- **Marks are saved locally** to `~/.jsonresume/local-marks.json` instead of the server
|
|
88
|
+
- Filters and export work identically
|
|
89
|
+
- Custom search profiles are not available (they require a registry account for server-side storage)
|
|
90
|
+
|
|
91
|
+
This is useful if you want to try the tool without setting up a registry account, or if you prefer to keep your resume as a local file.
|
|
92
|
+
|
|
60
93
|
## Interactive TUI
|
|
61
94
|
|
|
62
95
|
The default command launches a full terminal interface for browsing and managing jobs.
|
|
@@ -65,20 +98,31 @@ The default command launches a full terminal interface for browsing and managing
|
|
|
65
98
|
npx @jsonresume/jobs
|
|
66
99
|
```
|
|
67
100
|
|
|
101
|
+
### Layout
|
|
102
|
+
|
|
103
|
+
The TUI has three main regions:
|
|
104
|
+
|
|
105
|
+
- **Header** — shows the app title, active search profile name, tab bar (All / Interested / Applied / Maybe / Passed) with live counts, and any active filter pills
|
|
106
|
+
- **Content area** — job list in list view, or a split-pane layout (40% compact list + 60% job detail) in detail view
|
|
107
|
+
- **Status bar** — context-sensitive keyboard hints, loading/reranking indicators, and toast notifications
|
|
108
|
+
|
|
68
109
|
### Features
|
|
69
110
|
|
|
70
|
-
- **Split-pane detail view** — press Enter to see a compact job list on the left and full details on the right
|
|
71
|
-
- **Tab-based views** — All / Interested / Applied / Maybe / Passed with live counts
|
|
72
|
-
- **Persistent filters** — remote, salary, keyword,
|
|
73
|
-
- **Custom search profiles** — targeted searches like "remote React jobs in climate tech" with AI-powered reranking
|
|
111
|
+
- **Split-pane detail view** — press `Enter` to see a compact job list on the left and full details on the right; navigate jobs with `j`/`k` without leaving the detail panel
|
|
112
|
+
- **Tab-based views** — All / Interested / Applied / Maybe / Passed — always visible with live counts, cycle with `Tab`/`Shift+Tab`
|
|
113
|
+
- **Persistent filters** — remote, salary, keyword, date range — saved to disk per search profile and restored when you switch profiles
|
|
114
|
+
- **Custom search profiles** — targeted searches like "remote React jobs in climate tech" with AI-powered reranking via HyDE embeddings
|
|
74
115
|
- **Two-pass loading** — results appear instantly from vector search, then reshuffle when LLM reranking completes in the background
|
|
75
|
-
- **Batch operations** — select multiple jobs with `v`, then bulk-mark them all at once
|
|
76
|
-
- **Inline search** — press `n` to quickly filter visible jobs by keyword
|
|
77
|
-
- **Export** — press `e` to export your shortlist to a markdown file
|
|
78
|
-
- **Toast notifications** — instant feedback on every action
|
|
79
|
-
- **Help modal** — press `?` for a full keyboard reference
|
|
80
|
-
- **AI summaries** — per-job
|
|
81
|
-
- **Vim-style navigation** — `j`/`k
|
|
116
|
+
- **Batch operations** — select multiple jobs with `v`, then bulk-mark them all at once with `i`/`x`/`m`/`p`
|
|
117
|
+
- **Inline search** — press `n` to quickly filter visible jobs by keyword; press `Esc` to clear
|
|
118
|
+
- **Export** — press `e` to export your shortlist, applied, and maybe lists to a `job-hunt-YYYY-MM-DD.md` markdown file in the current directory
|
|
119
|
+
- **Toast notifications** — instant feedback on every action (marking, exporting, refreshing)
|
|
120
|
+
- **Help modal** — press `?` for a full keyboard reference organized by section
|
|
121
|
+
- **AI summaries** — press `Space` for a per-job AI summary, or `S` for a batch review of all visible jobs (requires `OPENAI_API_KEY`)
|
|
122
|
+
- **Vim-style navigation** — `j`/`k` to move, `g`/`G` to jump to first/last, `Ctrl+U`/`Ctrl+D` to page up/down
|
|
123
|
+
- **Responsive columns** — job list columns (score, title, company, location, salary) adapt to terminal width on resize
|
|
124
|
+
- **Smart filtering** — passed and dismissed jobs are excluded server-side with 5x over-fetch, so you always get a full set of fresh results
|
|
125
|
+
- **Cached results** — job data is cached locally for 24 hours to minimize API calls; press `R` to force a fresh fetch
|
|
82
126
|
|
|
83
127
|
### Keyboard Shortcuts
|
|
84
128
|
|
|
@@ -95,15 +139,15 @@ npx @jsonresume/jobs
|
|
|
95
139
|
| `x` | Mark applied |
|
|
96
140
|
| `m` | Mark maybe |
|
|
97
141
|
| `p` | Mark passed |
|
|
98
|
-
| `v` | Toggle batch selection |
|
|
142
|
+
| `v` | Toggle batch selection (selected jobs shown with `●`) |
|
|
99
143
|
| `Tab` / `Shift+Tab` | Next / previous tab |
|
|
100
144
|
| `n` | Inline keyword search |
|
|
101
145
|
| `f` | Manage filters |
|
|
102
146
|
| `/` | Search profiles |
|
|
103
|
-
| `Space` | AI summary |
|
|
104
|
-
| `S` | AI batch review |
|
|
147
|
+
| `Space` | AI summary for current job |
|
|
148
|
+
| `S` | AI batch review of visible jobs |
|
|
105
149
|
| `e` | Export shortlist to markdown |
|
|
106
|
-
| `R` | Force refresh |
|
|
150
|
+
| `R` | Force refresh (bypass cache) |
|
|
107
151
|
| `?` | Help modal |
|
|
108
152
|
| `q` | Quit |
|
|
109
153
|
|
|
@@ -111,22 +155,60 @@ npx @jsonresume/jobs
|
|
|
111
155
|
|
|
112
156
|
| Key | Action |
|
|
113
157
|
|-----|--------|
|
|
114
|
-
| `j` / `k` | Navigate jobs (updates detail pane) |
|
|
115
|
-
| `J` / `K` | Scroll detail content |
|
|
158
|
+
| `j` / `k` | Navigate between jobs (updates detail pane) |
|
|
159
|
+
| `J` / `K` | Scroll detail content up / down |
|
|
116
160
|
| `o` | Open HN post in browser |
|
|
117
161
|
| `i` / `x` / `m` / `p` | Mark job state |
|
|
118
162
|
| `Space` | AI summary |
|
|
119
163
|
| `Esc` / `q` | Back to full list |
|
|
120
164
|
|
|
121
|
-
#### Filters
|
|
165
|
+
#### Filters Panel
|
|
122
166
|
|
|
123
167
|
| Key | Action |
|
|
124
168
|
|-----|--------|
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `Esc` | Close |
|
|
169
|
+
| `j` / `k` | Navigate filters |
|
|
170
|
+
| `Enter` | Edit filter value |
|
|
171
|
+
| `a` | Add filter (remote, salary, keyword, days) |
|
|
172
|
+
| `d` | Delete selected filter |
|
|
173
|
+
| `Esc` | Close panel |
|
|
174
|
+
|
|
175
|
+
#### Search Profiles Panel
|
|
176
|
+
|
|
177
|
+
| Key | Action |
|
|
178
|
+
|-----|--------|
|
|
179
|
+
| `j` / `k` | Navigate profiles |
|
|
180
|
+
| `Enter` | Switch to selected profile |
|
|
181
|
+
| `n` | Create new search profile |
|
|
182
|
+
| `d` | Delete selected profile |
|
|
183
|
+
| `Esc` | Close panel |
|
|
184
|
+
|
|
185
|
+
### Job List Columns
|
|
186
|
+
|
|
187
|
+
In full-width list view, columns are:
|
|
188
|
+
|
|
189
|
+
| Column | Description |
|
|
190
|
+
|--------|-------------|
|
|
191
|
+
| Score | Cosine similarity (0.00–1.00) between your resume and the job |
|
|
192
|
+
| AI | LLM rerank score (1–10), shown only when a search profile triggers reranking |
|
|
193
|
+
| Title | Job title |
|
|
194
|
+
| Company | Company name |
|
|
195
|
+
| Location | City, country code, remote status |
|
|
196
|
+
| Salary | Parsed salary or `—` if not listed |
|
|
197
|
+
| Status | State icon: ⭐ interested, 📨 applied, ? maybe, ✗ passed |
|
|
198
|
+
|
|
199
|
+
In split-pane detail view, the left pane shows a compact list with just score, title, and status.
|
|
200
|
+
|
|
201
|
+
### Tab Views
|
|
202
|
+
|
|
203
|
+
| Tab | Shows |
|
|
204
|
+
|-----|-------|
|
|
205
|
+
| All | All jobs from the current search (excludes passed/dismissed) |
|
|
206
|
+
| Interested | Jobs you marked with `i` |
|
|
207
|
+
| Applied | Jobs you marked with `x` |
|
|
208
|
+
| Maybe | Jobs you marked with `m` |
|
|
209
|
+
| Passed | Jobs you marked with `p` |
|
|
210
|
+
|
|
211
|
+
All tabs always appear in the header with their current count. Marking a job moves it between tabs instantly.
|
|
130
212
|
|
|
131
213
|
## CLI Commands
|
|
132
214
|
|
|
@@ -140,6 +222,7 @@ npx @jsonresume/jobs detail 181420 # Full job details
|
|
|
140
222
|
npx @jsonresume/jobs mark 181420 interested # Mark a job
|
|
141
223
|
npx @jsonresume/jobs me # Your resume summary
|
|
142
224
|
npx @jsonresume/jobs update ./resume.json # Update your resume
|
|
225
|
+
npx @jsonresume/jobs logout # Remove saved API key
|
|
143
226
|
npx @jsonresume/jobs help # All options
|
|
144
227
|
```
|
|
145
228
|
|
|
@@ -150,7 +233,7 @@ npx @jsonresume/jobs help # All options
|
|
|
150
233
|
| *(default)* | Launch interactive TUI |
|
|
151
234
|
| `search` | Find matching jobs (table output) |
|
|
152
235
|
| `detail <id>` | Show full details for a job |
|
|
153
|
-
| `mark <id> <state>` | Set job state
|
|
236
|
+
| `mark <id> <state>` | Set job state (see states below) |
|
|
154
237
|
| `me` | Show your resume summary |
|
|
155
238
|
| `update <file>` | Upload a new version of your resume |
|
|
156
239
|
| `logout` | Remove saved API key |
|
|
@@ -176,8 +259,8 @@ npx @jsonresume/jobs help # All options
|
|
|
176
259
|
| `interested` | ⭐ | You want this job |
|
|
177
260
|
| `applied` | 📨 | You've applied |
|
|
178
261
|
| `maybe` | ? | Considering it |
|
|
179
|
-
| `not_interested` | ✗ | Not for you |
|
|
180
|
-
| `dismissed` | 👁 | Hide from results |
|
|
262
|
+
| `not_interested` | ✗ | Not for you (hidden from future searches) |
|
|
263
|
+
| `dismissed` | 👁 | Hide from results (hidden from future searches) |
|
|
181
264
|
|
|
182
265
|
## How Ranking Works
|
|
183
266
|
|
|
@@ -191,7 +274,7 @@ Job postings from HN's monthly "Who is Hiring?" threads are parsed by GPT into s
|
|
|
191
274
|
|
|
192
275
|
### Stage 2: Vector Similarity Search
|
|
193
276
|
|
|
194
|
-
Your resume embedding is compared against all job embeddings using cosine similarity via [pgvector](https://github.com/pgvector/pgvector). The top ~500 candidates are retrieved in ~200ms. This is purely semantic — it finds jobs that "sound like" your resume. Jobs you've already passed on are excluded server-side so you always get fresh results.
|
|
277
|
+
Your resume embedding is compared against all job embeddings using cosine similarity via [pgvector](https://github.com/pgvector/pgvector). The top ~500 candidates are retrieved in ~200ms. This is purely semantic — it finds jobs that "sound like" your resume. Jobs you've already passed on or dismissed are excluded server-side (with 5x over-fetch to compensate) so you always get fresh results.
|
|
195
278
|
|
|
196
279
|
### Stage 3: Custom Search Profiles
|
|
197
280
|
|
|
@@ -207,11 +290,11 @@ For custom searches, the top 30 vector results are re-scored by `gpt-4.1-mini`.
|
|
|
207
290
|
|
|
208
291
|
The final score blends both signals: `0.4 × vector_score + 0.6 × llm_score`. This lets the LLM override semantic similarity — a job that's a great vector match but contradicts your preferences gets pushed down.
|
|
209
292
|
|
|
210
|
-
In the TUI, this runs as a two-pass load: jobs appear instantly from vector search, then reshuffle when reranking finishes in the background.
|
|
293
|
+
In the TUI, this runs as a two-pass load: jobs appear instantly from vector search, then reshuffle when reranking finishes in the background. The status bar shows "reranking..." while this is in progress.
|
|
211
294
|
|
|
212
295
|
### Stage 5: Client-side Filtering
|
|
213
296
|
|
|
214
|
-
After server-side ranking, the TUI applies local filters (remote only, minimum salary, keyword, days). Filters are persisted per search profile
|
|
297
|
+
After server-side ranking, the TUI applies local filters (remote only, minimum salary, keyword, days). Filters are persisted per search profile to `~/.jsonresume/filters.json`, so switching profiles restores each one's filters automatically.
|
|
215
298
|
|
|
216
299
|
## Claude Code Skill
|
|
217
300
|
|
|
@@ -246,11 +329,16 @@ The skill interviews you about what you're looking for, runs multiple searches,
|
|
|
246
329
|
|
|
247
330
|
## Data Storage
|
|
248
331
|
|
|
332
|
+
All local data is stored under `~/.jsonresume/`:
|
|
333
|
+
|
|
249
334
|
| Path | Contents |
|
|
250
335
|
|------|----------|
|
|
251
|
-
| `~/.jsonresume/config.json` | API key and username |
|
|
336
|
+
| `~/.jsonresume/config.json` | API key and username (registry mode) |
|
|
252
337
|
| `~/.jsonresume/filters.json` | Saved filter presets per search profile |
|
|
253
|
-
| `~/.jsonresume/cache/` | Cached job results (auto-expires) |
|
|
338
|
+
| `~/.jsonresume/cache/` | Cached job results (auto-expires after 24 hours) |
|
|
339
|
+
| `~/.jsonresume/local-marks.json` | Job marks in local mode |
|
|
340
|
+
|
|
341
|
+
The export command writes `job-hunt-YYYY-MM-DD.md` to your current working directory.
|
|
254
342
|
|
|
255
343
|
## Contributing
|
|
256
344
|
|
|
@@ -263,6 +351,26 @@ pnpm install
|
|
|
263
351
|
node packages/job-search/bin/cli.js help
|
|
264
352
|
```
|
|
265
353
|
|
|
354
|
+
The TUI is built with [React Ink](https://github.com/vadimdemedes/ink) v6 using a `h()` helper (no JSX). Key source files:
|
|
355
|
+
|
|
356
|
+
| File | Purpose |
|
|
357
|
+
|------|---------|
|
|
358
|
+
| `bin/cli.js` | Entry point, CLI command routing |
|
|
359
|
+
| `src/auth.js` | Interactive login flow, API key management |
|
|
360
|
+
| `src/tui/App.js` | Main TUI orchestrator, view state, layout |
|
|
361
|
+
| `src/tui/Header.js` | Title bar, tab bar, filter pills |
|
|
362
|
+
| `src/tui/JobList.js` | Responsive job table with flexbox columns |
|
|
363
|
+
| `src/tui/JobDetail.js` | Full job detail view (standalone and split-pane) |
|
|
364
|
+
| `src/tui/StatusBar.js` | Key hints, loading state, toasts |
|
|
365
|
+
| `src/tui/useJobs.js` | Job fetching, caching, tab filtering |
|
|
366
|
+
| `src/tui/useAI.js` | AI summary and batch review integration |
|
|
367
|
+
| `src/tui/useSearches.js` | Search profile CRUD |
|
|
368
|
+
| `src/filters.js` | Persistent filter storage per search profile |
|
|
369
|
+
| `src/export.js` | Markdown export |
|
|
370
|
+
| `src/localApi.js` | API client for local mode (no registry account) |
|
|
371
|
+
| `src/localState.js` | Local mark storage for local mode |
|
|
372
|
+
| `src/cache.js` | Local result caching with TTL |
|
|
373
|
+
|
|
266
374
|
See the repo root [CLAUDE.md](../../CLAUDE.md) for code standards and contribution guidelines.
|
|
267
375
|
|
|
268
376
|
## License
|
package/bin/cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Just run: npx @jsonresume/jobs
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
const VERSION = '0.
|
|
9
|
+
const VERSION = '0.10.0';
|
|
10
10
|
|
|
11
11
|
const BASE_URL =
|
|
12
12
|
getArg('--base-url') ||
|
|
@@ -25,6 +25,37 @@ function hasFlag(name) {
|
|
|
25
25
|
return process.argv.includes(name);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// ── Local resume detection ────────────────────────────────
|
|
29
|
+
|
|
30
|
+
async function findLocalResume() {
|
|
31
|
+
const explicitPath = getArg('--resume');
|
|
32
|
+
if (explicitPath) {
|
|
33
|
+
const fs = await import('node:fs');
|
|
34
|
+
const path = await import('node:path');
|
|
35
|
+
const resolved = path.resolve(explicitPath);
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(fs.readFileSync(resolved, 'utf-8'));
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error(`Error reading resume at ${resolved}: ${err.message}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Auto-detect resume.json in CWD
|
|
45
|
+
const fs = await import('node:fs');
|
|
46
|
+
const path = await import('node:path');
|
|
47
|
+
const candidates = ['resume.json', 'Resume.json'];
|
|
48
|
+
for (const name of candidates) {
|
|
49
|
+
const filePath = path.resolve(name);
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
52
|
+
const resume = JSON.parse(raw);
|
|
53
|
+
if (resume.basics) return resume;
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
28
59
|
// ── API client ─────────────────────────────────────────────
|
|
29
60
|
|
|
30
61
|
let _apiKey;
|
|
@@ -87,10 +118,12 @@ function stateIcon(state) {
|
|
|
87
118
|
|
|
88
119
|
async function cmdSearch() {
|
|
89
120
|
const params = new URLSearchParams();
|
|
90
|
-
|
|
91
|
-
params.set('
|
|
121
|
+
const topArg = parseInt(getArg('--top')) || 20;
|
|
122
|
+
params.set('top', String(Math.min(Math.max(1, topArg), 100)));
|
|
123
|
+
params.set('days', String(parseInt(getArg('--days')) || 30));
|
|
92
124
|
if (hasFlag('--remote')) params.set('remote', 'true');
|
|
93
|
-
|
|
125
|
+
const minSalary = parseInt(getArg('--min-salary'));
|
|
126
|
+
if (minSalary > 0) params.set('min_salary', String(minSalary));
|
|
94
127
|
if (getArg('--search')) params.set('search', getArg('--search'));
|
|
95
128
|
|
|
96
129
|
const { jobs } = await api(`/jobs?${params}`);
|
|
@@ -288,6 +321,12 @@ jsonresume-jobs v${VERSION} — Search HN "Who is Hiring" jobs matched to your J
|
|
|
288
321
|
|
|
289
322
|
QUICK START
|
|
290
323
|
npx @jsonresume/jobs Launch the interactive TUI (logs in automatically)
|
|
324
|
+
npx @jsonresume/jobs --resume resume.json Use a local resume (no account needed)
|
|
325
|
+
|
|
326
|
+
LOCAL MODE
|
|
327
|
+
If a resume.json file exists in the current directory, or you pass --resume,
|
|
328
|
+
the TUI launches without requiring a registry account. Job matches are fetched
|
|
329
|
+
from the server using your resume, and marks are saved locally.
|
|
291
330
|
|
|
292
331
|
COMMANDS
|
|
293
332
|
(default) Interactive TUI with AI features
|
|
@@ -316,6 +355,7 @@ MARK STATES
|
|
|
316
355
|
dismissed Hide from results
|
|
317
356
|
|
|
318
357
|
GLOBAL OPTIONS
|
|
358
|
+
--resume <path> Use a local resume file (skips registry auth)
|
|
319
359
|
--json Output raw JSON (for piping / Claude Code)
|
|
320
360
|
--base-url URL API base URL (default: https://registry.jsonresume.org)
|
|
321
361
|
--feedback "reason" Add a reason when marking (with mark command)
|
|
@@ -326,7 +366,8 @@ ENVIRONMENT
|
|
|
326
366
|
JSONRESUME_BASE_URL API base URL override
|
|
327
367
|
|
|
328
368
|
EXAMPLES
|
|
329
|
-
npx @jsonresume/jobs # TUI
|
|
369
|
+
npx @jsonresume/jobs # TUI (registry)
|
|
370
|
+
npx @jsonresume/jobs --resume ./resume.json # TUI (local)
|
|
330
371
|
npx @jsonresume/jobs search --remote --min-salary 150 # CLI search
|
|
331
372
|
npx @jsonresume/jobs detail 181420 # Job details
|
|
332
373
|
npx @jsonresume/jobs mark 181420 interested --feedback "great" # Mark job
|
|
@@ -366,7 +407,23 @@ async function main() {
|
|
|
366
407
|
case 'tui':
|
|
367
408
|
case '':
|
|
368
409
|
default: {
|
|
369
|
-
//
|
|
410
|
+
// Check for local resume first
|
|
411
|
+
const localResume = await findLocalResume();
|
|
412
|
+
if (localResume) {
|
|
413
|
+
console.error(
|
|
414
|
+
`\n Using local resume: ${localResume.basics?.name || 'Unknown'}`
|
|
415
|
+
);
|
|
416
|
+
console.error(' Marks will be saved locally.\n');
|
|
417
|
+
const { createLocalApiClient } = await import('../src/localApi.js');
|
|
418
|
+
const localApi = createLocalApiClient({
|
|
419
|
+
baseUrl: BASE_URL,
|
|
420
|
+
resume: localResume,
|
|
421
|
+
});
|
|
422
|
+
const { default: runTUI } = await import('../src/tui/App.js');
|
|
423
|
+
return runTUI({ baseUrl: BASE_URL, apiClient: localApi });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Registry mode
|
|
370
427
|
const apiKey = await getApiKey();
|
|
371
428
|
const { default: runTUI } = await import('../src/tui/App.js');
|
|
372
429
|
return runTUI({ baseUrl: BASE_URL, apiKey });
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -40,7 +40,7 @@ export function createApiClient({ baseUrl, apiKey }) {
|
|
|
40
40
|
markJob: (id, state, feedback) =>
|
|
41
41
|
request(`/jobs/${id}`, {
|
|
42
42
|
method: 'PUT',
|
|
43
|
-
body: JSON.stringify({ state, feedback: feedback ||
|
|
43
|
+
body: JSON.stringify({ state, feedback: feedback || undefined }),
|
|
44
44
|
}),
|
|
45
45
|
fetchMe: () => request('/me'),
|
|
46
46
|
|
package/src/formatters.js
CHANGED
|
@@ -39,26 +39,6 @@ export function truncate(str, len) {
|
|
|
39
39
|
return str.length > len ? str.slice(0, len - 1) + '…' : str;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
* Render a score as a sparkline bar: ████░░░░ 0.72
|
|
44
|
-
* Uses Unicode block characters for sub-character precision.
|
|
45
|
-
*/
|
|
46
|
-
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
|
47
|
-
|
|
48
|
-
export function scoreBar(score, width = 6) {
|
|
49
|
-
if (typeof score !== 'number' || isNaN(score)) return '░'.repeat(width);
|
|
50
|
-
const clamped = Math.max(0, Math.min(1, score));
|
|
51
|
-
const filled = clamped * width;
|
|
52
|
-
const full = Math.floor(filled);
|
|
53
|
-
const frac = Math.round((filled - full) * 8);
|
|
54
|
-
const empty = width - full - (frac > 0 ? 1 : 0);
|
|
55
|
-
return (
|
|
56
|
-
'█'.repeat(full) +
|
|
57
|
-
(frac > 0 ? BLOCKS[frac] : '') +
|
|
58
|
-
'░'.repeat(Math.max(0, empty))
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
42
|
export function stateLabel(state) {
|
|
63
43
|
const labels = {
|
|
64
44
|
interested: 'Interested',
|
package/src/localApi.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { getMarks, setMark } from './localState.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BASE_URL = 'https://registry.jsonresume.org';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates an API client for local mode — uses a resume JSON object
|
|
7
|
+
* instead of registry auth. Marks are stored locally.
|
|
8
|
+
*/
|
|
9
|
+
export function createLocalApiClient({ baseUrl, resume }) {
|
|
10
|
+
const base = baseUrl || DEFAULT_BASE_URL;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
fetchJobs: async (params = {}) => {
|
|
14
|
+
const body = {
|
|
15
|
+
resume,
|
|
16
|
+
top: params.top || 50,
|
|
17
|
+
days: params.days || 30,
|
|
18
|
+
};
|
|
19
|
+
if (params.remote) body.remote = true;
|
|
20
|
+
if (params.minSalary) body.min_salary = params.minSalary;
|
|
21
|
+
if (params.search) body.search = params.search;
|
|
22
|
+
|
|
23
|
+
const res = await fetch(`${base}/api/v1/jobs`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
const text = await res.text();
|
|
29
|
+
let data;
|
|
30
|
+
try {
|
|
31
|
+
data = JSON.parse(text);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(`Non-JSON response: ${res.status}`);
|
|
34
|
+
}
|
|
35
|
+
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
36
|
+
|
|
37
|
+
// Overlay local marks onto results
|
|
38
|
+
const marks = getMarks();
|
|
39
|
+
const jobs = (data.jobs || []).map((j) => ({
|
|
40
|
+
...j,
|
|
41
|
+
state: marks[String(j.id)] || j.state || null,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
return { jobs };
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
fetchJobDetail: async (id) => {
|
|
48
|
+
// In local mode we don't have a detail endpoint — return
|
|
49
|
+
// a stub so JobDetail falls back to the job data it already has.
|
|
50
|
+
return { id };
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
markJob: async (id, state, feedback) => {
|
|
54
|
+
setMark(id, state);
|
|
55
|
+
return { id, state, feedback };
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
fetchMe: async () => ({ resume, username: 'local' }),
|
|
59
|
+
|
|
60
|
+
// Search profiles not supported in local mode
|
|
61
|
+
listSearches: async () => ({ searches: [] }),
|
|
62
|
+
createSearch: async () => {
|
|
63
|
+
throw new Error('Search profiles require a registry account');
|
|
64
|
+
},
|
|
65
|
+
updateSearch: async () => {
|
|
66
|
+
throw new Error('Search profiles require a registry account');
|
|
67
|
+
},
|
|
68
|
+
deleteSearch: async () => {
|
|
69
|
+
throw new Error('Search profiles require a registry account');
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const DIR = join(homedir(), '.jsonresume');
|
|
6
|
+
const FILE = join(DIR, 'local-marks.json');
|
|
7
|
+
|
|
8
|
+
function ensureDir() {
|
|
9
|
+
try {
|
|
10
|
+
mkdirSync(DIR, { recursive: true });
|
|
11
|
+
} catch {}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function load() {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(FILE, 'utf-8'));
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function save(data) {
|
|
23
|
+
ensureDir();
|
|
24
|
+
writeFileSync(FILE, JSON.stringify(data, null, 2));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Get all marks as { jobId: state } */
|
|
28
|
+
export function getMarks() {
|
|
29
|
+
return load();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Set a mark for a job */
|
|
33
|
+
export function setMark(jobId, state) {
|
|
34
|
+
const marks = load();
|
|
35
|
+
marks[String(jobId)] = state;
|
|
36
|
+
save(marks);
|
|
37
|
+
}
|
package/src/tui/App.js
CHANGED
|
@@ -36,17 +36,18 @@ const TAB_LABELS = {
|
|
|
36
36
|
function InlineSearch({ query, onChange, onSubmit }) {
|
|
37
37
|
return h(
|
|
38
38
|
Box,
|
|
39
|
-
{ paddingX: 1 },
|
|
40
|
-
h(Text, { color: 'yellow', bold: true }, 'Find:
|
|
41
|
-
h(TextInput, { value: query, onChange, onSubmit })
|
|
39
|
+
{ paddingX: 1, gap: 1 },
|
|
40
|
+
h(Text, { color: 'yellow', bold: true }, 'Find:'),
|
|
41
|
+
h(TextInput, { value: query, onChange, onSubmit }),
|
|
42
|
+
h(Text, { dimColor: true }, ' Enter to apply, Esc to clear')
|
|
42
43
|
);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
function App({ baseUrl, apiKey }) {
|
|
46
|
+
function App({ baseUrl, apiKey, apiClient }) {
|
|
46
47
|
const { exit } = useApp();
|
|
47
48
|
const api = useMemo(
|
|
48
|
-
() => createApiClient({ baseUrl, apiKey }),
|
|
49
|
-
[baseUrl, apiKey]
|
|
49
|
+
() => apiClient || createApiClient({ baseUrl, apiKey }),
|
|
50
|
+
[baseUrl, apiKey, apiClient]
|
|
50
51
|
);
|
|
51
52
|
|
|
52
53
|
// View: 'list' | 'detail' | 'filters' | 'searches' | 'ai' | 'help'
|
|
@@ -106,7 +107,18 @@ function App({ baseUrl, apiKey }) {
|
|
|
106
107
|
const jobs = useMemo(() => {
|
|
107
108
|
if (!appliedQuery) return rawJobs;
|
|
108
109
|
const q = appliedQuery.toLowerCase();
|
|
109
|
-
return rawJobs.filter((j) =>
|
|
110
|
+
return rawJobs.filter((j) => {
|
|
111
|
+
const fields = [
|
|
112
|
+
j.title,
|
|
113
|
+
j.company,
|
|
114
|
+
j.description,
|
|
115
|
+
j.remote,
|
|
116
|
+
j.location?.city,
|
|
117
|
+
j.location?.countryCode,
|
|
118
|
+
...(j.skills || []).map((s) => s.name || s),
|
|
119
|
+
];
|
|
120
|
+
return fields.some((f) => f && String(f).toLowerCase().includes(q));
|
|
121
|
+
});
|
|
110
122
|
}, [rawJobs, appliedQuery]);
|
|
111
123
|
|
|
112
124
|
useEffect(() => {
|
|
@@ -215,7 +227,7 @@ function App({ baseUrl, apiKey }) {
|
|
|
215
227
|
const handleExport = () => {
|
|
216
228
|
try {
|
|
217
229
|
const filename = exportShortlist(allJobs);
|
|
218
|
-
showToast(`
|
|
230
|
+
showToast(`Saved ./${filename}`, 'export');
|
|
219
231
|
} catch (err) {
|
|
220
232
|
showToast(`Export failed: ${err.message}`, 'error');
|
|
221
233
|
}
|
|
@@ -389,6 +401,6 @@ function App({ baseUrl, apiKey }) {
|
|
|
389
401
|
);
|
|
390
402
|
}
|
|
391
403
|
|
|
392
|
-
export default function runTUI({ baseUrl, apiKey }) {
|
|
393
|
-
render(h(App, { baseUrl, apiKey }));
|
|
404
|
+
export default function runTUI({ baseUrl, apiKey, apiClient }) {
|
|
405
|
+
render(h(App, { baseUrl, apiKey, apiClient }));
|
|
394
406
|
}
|
package/src/tui/FilterManager.js
CHANGED
|
@@ -124,7 +124,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
|
|
|
124
124
|
...next[editIdx],
|
|
125
125
|
value:
|
|
126
126
|
filter.type === 'minSalary' || filter.type === 'days'
|
|
127
|
-
? parseInt(val) ||
|
|
127
|
+
? parseInt(val) || 0
|
|
128
128
|
: val,
|
|
129
129
|
};
|
|
130
130
|
onUpdate({ ...filterState, active: next });
|
|
@@ -132,11 +132,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
|
|
|
132
132
|
},
|
|
133
133
|
})
|
|
134
134
|
),
|
|
135
|
-
h(
|
|
136
|
-
Text,
|
|
137
|
-
{ dimColor: true, marginTop: 1 },
|
|
138
|
-
'Enter to save, Ctrl+C to cancel'
|
|
139
|
-
)
|
|
135
|
+
h(Text, { dimColor: true, marginTop: 1 }, 'Enter to save, Esc to cancel')
|
|
140
136
|
);
|
|
141
137
|
}
|
|
142
138
|
|
|
@@ -165,7 +161,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
|
|
|
165
161
|
if (val.trim()) {
|
|
166
162
|
const value =
|
|
167
163
|
ft.type === 'minSalary' || ft.type === 'days'
|
|
168
|
-
? parseInt(val) ||
|
|
164
|
+
? parseInt(val) || 0
|
|
169
165
|
: val;
|
|
170
166
|
onUpdate({
|
|
171
167
|
...filterState,
|
package/src/tui/Header.js
CHANGED
|
@@ -42,8 +42,6 @@ export default function Header({
|
|
|
42
42
|
const tabElements = tabs.map((t) => {
|
|
43
43
|
const active = t === tab;
|
|
44
44
|
const count = counts[t] || 0;
|
|
45
|
-
if (count === 0 && !active && t !== 'all') return null;
|
|
46
|
-
|
|
47
45
|
const label = `${tabLabels[t]} ${count}`;
|
|
48
46
|
|
|
49
47
|
if (active) {
|
|
@@ -64,7 +62,7 @@ export default function Header({
|
|
|
64
62
|
);
|
|
65
63
|
});
|
|
66
64
|
|
|
67
|
-
const tabRow = h(Box, { paddingX: 1 }, ...tabElements
|
|
65
|
+
const tabRow = h(Box, { paddingX: 1 }, ...tabElements);
|
|
68
66
|
|
|
69
67
|
// ── Filter pills (only if active) ────────────────
|
|
70
68
|
const tags = [];
|
package/src/tui/HelpModal.js
CHANGED
|
@@ -7,10 +7,12 @@ const SECTIONS = [
|
|
|
7
7
|
keys: [
|
|
8
8
|
['j / ↓', 'Move down'],
|
|
9
9
|
['k / ↑', 'Move up'],
|
|
10
|
-
['
|
|
10
|
+
['g / G', 'Jump to first / last'],
|
|
11
|
+
['Ctrl+U / D', 'Page up / page down'],
|
|
12
|
+
['Enter', 'Open split-pane detail view'],
|
|
11
13
|
['Esc / q', 'Back / quit'],
|
|
12
|
-
['Tab', 'Next
|
|
13
|
-
['Shift+Tab', 'Previous
|
|
14
|
+
['Tab', 'Next tab'],
|
|
15
|
+
['Shift+Tab', 'Previous tab'],
|
|
14
16
|
],
|
|
15
17
|
},
|
|
16
18
|
{
|
|
@@ -20,28 +22,31 @@ const SECTIONS = [
|
|
|
20
22
|
['x', 'Mark applied'],
|
|
21
23
|
['m', 'Mark maybe'],
|
|
22
24
|
['p', 'Mark passed'],
|
|
25
|
+
['v', 'Toggle batch selection'],
|
|
23
26
|
],
|
|
24
27
|
},
|
|
25
28
|
{
|
|
26
29
|
title: 'Search & Filter',
|
|
27
30
|
keys: [
|
|
31
|
+
['n', 'Inline keyword search'],
|
|
28
32
|
['/', 'Search profiles'],
|
|
29
33
|
['f', 'Manage filters'],
|
|
30
|
-
['
|
|
34
|
+
['e', 'Export shortlist to markdown'],
|
|
35
|
+
['R', 'Force refresh (bypass cache)'],
|
|
31
36
|
],
|
|
32
37
|
},
|
|
33
38
|
{
|
|
34
|
-
title: '
|
|
39
|
+
title: 'Detail View',
|
|
35
40
|
keys: [
|
|
36
|
-
['
|
|
37
|
-
['
|
|
41
|
+
['J / K', 'Scroll detail content'],
|
|
42
|
+
['o', 'Open HN post in browser'],
|
|
38
43
|
],
|
|
39
44
|
},
|
|
40
45
|
{
|
|
41
|
-
title: '
|
|
46
|
+
title: 'AI Features (requires OPENAI_API_KEY)',
|
|
42
47
|
keys: [
|
|
43
|
-
['
|
|
44
|
-
['
|
|
48
|
+
['Space', 'AI summary of current job'],
|
|
49
|
+
['S', 'AI batch review of visible jobs'],
|
|
45
50
|
],
|
|
46
51
|
},
|
|
47
52
|
];
|
|
@@ -68,7 +73,7 @@ export default function HelpModal({ onClose }) {
|
|
|
68
73
|
h(
|
|
69
74
|
Box,
|
|
70
75
|
{ justifyContent: 'center', marginBottom: 1 },
|
|
71
|
-
h(Text, { bold: true, color: 'cyan' }, '
|
|
76
|
+
h(Text, { bold: true, color: 'cyan' }, 'Keyboard Shortcuts')
|
|
72
77
|
),
|
|
73
78
|
...SECTIONS.flatMap((section) => [
|
|
74
79
|
h(
|
package/src/tui/JobDetail.js
CHANGED
|
@@ -23,7 +23,8 @@ export default function JobDetail({
|
|
|
23
23
|
api
|
|
24
24
|
.fetchJobDetail(job.id)
|
|
25
25
|
.then((d) => {
|
|
26
|
-
|
|
26
|
+
// If the detail response only has an id (local mode), use the job data we already have
|
|
27
|
+
setDetail(d?.title ? d : null);
|
|
27
28
|
setLoading(false);
|
|
28
29
|
})
|
|
29
30
|
.catch(() => setLoading(false));
|
package/src/tui/SearchManager.js
CHANGED
|
@@ -73,7 +73,7 @@ export default function SearchManager({
|
|
|
73
73
|
h(
|
|
74
74
|
Text,
|
|
75
75
|
{ color: 'magenta' },
|
|
76
|
-
' Creating
|
|
76
|
+
' Creating profile — generating a custom embedding from your resume + search prompt…'
|
|
77
77
|
)
|
|
78
78
|
)
|
|
79
79
|
);
|
|
@@ -110,7 +110,7 @@ export default function SearchManager({
|
|
|
110
110
|
h(
|
|
111
111
|
Text,
|
|
112
112
|
{ dimColor: true, marginTop: 1 },
|
|
113
|
-
'Enter to continue,
|
|
113
|
+
'Enter to continue, Esc to cancel'
|
|
114
114
|
)
|
|
115
115
|
);
|
|
116
116
|
}
|
|
@@ -153,7 +153,7 @@ export default function SearchManager({
|
|
|
153
153
|
h(
|
|
154
154
|
Text,
|
|
155
155
|
{ dimColor: true, marginTop: 1 },
|
|
156
|
-
'Enter to create,
|
|
156
|
+
'Enter to create, Esc to cancel'
|
|
157
157
|
)
|
|
158
158
|
);
|
|
159
159
|
}
|
package/src/tui/StatusBar.js
CHANGED
|
@@ -21,6 +21,10 @@ const KEYS = {
|
|
|
21
21
|
['space', 'AI'],
|
|
22
22
|
['esc', 'back'],
|
|
23
23
|
],
|
|
24
|
+
search: [
|
|
25
|
+
['esc', 'clear search'],
|
|
26
|
+
['enter', 'apply'],
|
|
27
|
+
],
|
|
24
28
|
filters: [
|
|
25
29
|
['j/k', 'nav'],
|
|
26
30
|
['enter', 'edit'],
|
|
@@ -67,14 +71,21 @@ export default function StatusBar({
|
|
|
67
71
|
h(Text, { dimColor: true }, '─'.repeat(Math.max(10, cols - 2)))
|
|
68
72
|
);
|
|
69
73
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
{
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
const rightParts = [];
|
|
75
|
+
if (loading)
|
|
76
|
+
rightParts.push(h(Text, { key: 'l', color: 'yellow' }, 'loading…'));
|
|
77
|
+
if (reranking)
|
|
78
|
+
rightParts.push(h(Text, { key: 'r', color: 'magenta' }, 'reranking…'));
|
|
79
|
+
rightParts.push(
|
|
80
|
+
h(Text, { key: 'c', dimColor: true }, `${jobCount}/${totalCount}`)
|
|
77
81
|
);
|
|
82
|
+
if (!aiEnabled) {
|
|
83
|
+
rightParts.push(
|
|
84
|
+
h(Text, { key: 'ai', dimColor: true }, 'set OPENAI_API_KEY for AI')
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const rightInfo = h(Box, { gap: 1 }, ...rightParts);
|
|
78
89
|
|
|
79
90
|
const content = toast
|
|
80
91
|
? h(Box, { paddingX: 1, justifyContent: 'space-between' }, toast, rightInfo)
|
package/src/tui/useAI.js
CHANGED
|
@@ -11,7 +11,9 @@ export function useAI(resume) {
|
|
|
11
11
|
const summarizeJob = useCallback(
|
|
12
12
|
async (job) => {
|
|
13
13
|
if (!hasKey) {
|
|
14
|
-
setError(
|
|
14
|
+
setError(
|
|
15
|
+
'AI requires OPENAI_API_KEY — run: export OPENAI_API_KEY=sk-...'
|
|
16
|
+
);
|
|
15
17
|
return;
|
|
16
18
|
}
|
|
17
19
|
setLoading(true);
|
|
@@ -57,7 +59,9 @@ export function useAI(resume) {
|
|
|
57
59
|
const batchReview = useCallback(
|
|
58
60
|
async (jobs) => {
|
|
59
61
|
if (!hasKey) {
|
|
60
|
-
setError(
|
|
62
|
+
setError(
|
|
63
|
+
'AI requires OPENAI_API_KEY — run: export OPENAI_API_KEY=sk-...'
|
|
64
|
+
);
|
|
61
65
|
return;
|
|
62
66
|
}
|
|
63
67
|
setLoading(true);
|
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
|
-
}
|