@jsonresume/jobs 0.7.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/src/tui/App.js ADDED
@@ -0,0 +1,395 @@
1
+ import React, { useState, useEffect, useMemo, useCallback } from 'react';
2
+ import { render, Box, Text, useInput, useApp } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import { h } from './h.js';
5
+ import { createApiClient } from '../api.js';
6
+ import {
7
+ loadFilters,
8
+ saveFilters,
9
+ getFiltersForSearch,
10
+ setFiltersForSearch,
11
+ } from '../filters.js';
12
+ import { exportShortlist } from '../export.js';
13
+ import { useJobs } from './useJobs.js';
14
+ import { useAI } from './useAI.js';
15
+ import { useSearches } from './useSearches.js';
16
+ import { useToast } from './Toast.js';
17
+ import Toast from './Toast.js';
18
+ import Header from './Header.js';
19
+ import JobList from './JobList.js';
20
+ import JobDetail from './JobDetail.js';
21
+ import FilterManager from './FilterManager.js';
22
+ import SearchManager from './SearchManager.js';
23
+ import StatusBar from './StatusBar.js';
24
+ import AIPanel from './AIPanel.js';
25
+ import HelpModal from './HelpModal.js';
26
+
27
+ const TABS = ['all', 'interested', 'applied', 'maybe', 'passed'];
28
+ const TAB_LABELS = {
29
+ all: 'All Jobs',
30
+ interested: '⭐ Interested',
31
+ applied: '📨 Applied',
32
+ maybe: '? Maybe',
33
+ passed: '✗ Passed',
34
+ };
35
+
36
+ function InlineSearch({ query, onChange, onSubmit }) {
37
+ return h(
38
+ Box,
39
+ { paddingX: 1 },
40
+ h(Text, { color: 'yellow', bold: true }, 'Find: '),
41
+ h(TextInput, { value: query, onChange, onSubmit })
42
+ );
43
+ }
44
+
45
+ function App({ baseUrl, apiKey }) {
46
+ const { exit } = useApp();
47
+ const api = useMemo(
48
+ () => createApiClient({ baseUrl, apiKey }),
49
+ [baseUrl, apiKey]
50
+ );
51
+
52
+ // View: 'list' | 'detail' | 'filters' | 'searches' | 'ai' | 'help'
53
+ const [view, setView] = useState('list');
54
+ const [tab, setTab] = useState('all');
55
+ const [selectedJob, setSelectedJob] = useState(null);
56
+ const [cursor, setCursor] = useState(0);
57
+ const [resume, setResume] = useState(null);
58
+
59
+ // Inline search
60
+ const [inlineSearch, setInlineSearch] = useState(false);
61
+ const [searchQuery, setSearchQuery] = useState('');
62
+ const [appliedQuery, setAppliedQuery] = useState('');
63
+
64
+ // Active search profile
65
+ const [activeSearchId, setActiveSearchId] = useState(null);
66
+
67
+ // Persistent filters
68
+ const [filterStore, setFilterStore] = useState(() => loadFilters());
69
+ const activeFilters = useMemo(
70
+ () => getFiltersForSearch(filterStore, activeSearchId),
71
+ [filterStore, activeSearchId]
72
+ );
73
+ const updateFilters = useCallback(
74
+ (newActive) => {
75
+ setFilterStore((prev) => {
76
+ const next = setFiltersForSearch(prev, activeSearchId, newActive);
77
+ saveFilters(next);
78
+ return next;
79
+ });
80
+ },
81
+ [activeSearchId]
82
+ );
83
+ const filterState = useMemo(
84
+ () => ({ active: activeFilters }),
85
+ [activeFilters]
86
+ );
87
+ const persistFilters = useCallback(
88
+ (s) => updateFilters(s.active),
89
+ [updateFilters]
90
+ );
91
+
92
+ const {
93
+ jobs: rawJobs,
94
+ allJobs,
95
+ loading,
96
+ reranking,
97
+ error,
98
+ markJob,
99
+ forceRefresh,
100
+ } = useJobs(api, activeFilters, tab, activeSearchId);
101
+ const ai = useAI(resume);
102
+ const searchesHook = useSearches(api);
103
+ const { toast, show: showToast } = useToast();
104
+
105
+ // Apply inline search filter
106
+ const jobs = useMemo(() => {
107
+ if (!appliedQuery) return rawJobs;
108
+ const q = appliedQuery.toLowerCase();
109
+ return rawJobs.filter((j) => JSON.stringify(j).toLowerCase().includes(q));
110
+ }, [rawJobs, appliedQuery]);
111
+
112
+ useEffect(() => {
113
+ api
114
+ .fetchMe()
115
+ .then((d) => setResume(d.resume))
116
+ .catch(() => {});
117
+ }, [api]);
118
+
119
+ // Update selectedJob when cursor moves in detail view
120
+ useEffect(() => {
121
+ if (view === 'detail' && jobs[cursor]) {
122
+ setSelectedJob(jobs[cursor]);
123
+ }
124
+ }, [cursor, view]);
125
+
126
+ // Inline search escape handler
127
+ useInput(
128
+ (input, key) => {
129
+ if (key.escape) {
130
+ setInlineSearch(false);
131
+ setSearchQuery('');
132
+ setAppliedQuery('');
133
+ }
134
+ },
135
+ { isActive: inlineSearch }
136
+ );
137
+
138
+ // Main input handler
139
+ useInput(
140
+ (input, key) => {
141
+ if (view === 'filters' || view === 'searches' || view === 'help') return;
142
+ if (inlineSearch) return;
143
+
144
+ if (input === 'q' && view === 'list') exit();
145
+ if (input === 'q' && view === 'detail') setView('list');
146
+ if (input === 'R' && (view === 'list' || view === 'detail')) {
147
+ forceRefresh();
148
+ showToast('Refreshing…', 'info');
149
+ }
150
+ if (input === 'f' && (view === 'list' || view === 'detail'))
151
+ setView('filters');
152
+ if (input === '/' && (view === 'list' || view === 'detail'))
153
+ setView('searches');
154
+ if (input === '?' && (view === 'list' || view === 'detail'))
155
+ setView('help');
156
+ if (input === 'n' && view === 'list') {
157
+ setInlineSearch(true);
158
+ setSearchQuery('');
159
+ }
160
+
161
+ // Enter toggles detail panel
162
+ if (key.return && view === 'list' && jobs[cursor]) {
163
+ setSelectedJob(jobs[cursor]);
164
+ setView('detail');
165
+ }
166
+ if (key.escape && view === 'detail') setView('list');
167
+
168
+ if (key.escape && view === 'ai') {
169
+ ai.clear();
170
+ setView(selectedJob ? 'detail' : 'list');
171
+ }
172
+
173
+ if (key.tab && (view === 'list' || view === 'detail')) {
174
+ const idx = TABS.indexOf(tab);
175
+ setTab(TABS[(idx + 1) % TABS.length]);
176
+ setCursor(0);
177
+ }
178
+ if (key.shift && key.tab && (view === 'list' || view === 'detail')) {
179
+ const idx = TABS.indexOf(tab);
180
+ setTab(TABS[(idx - 1 + TABS.length) % TABS.length]);
181
+ setCursor(0);
182
+ }
183
+ },
184
+ { isActive: view !== 'filters' && view !== 'searches' && view !== 'help' }
185
+ );
186
+
187
+ const handleSelect = (job) => {
188
+ setSelectedJob(job);
189
+ setView('detail');
190
+ };
191
+
192
+ const handleMark = async (id, state) => {
193
+ await markJob(id, state);
194
+ const job = allJobs.find((j) => j.id === id);
195
+ const title = job ? job.title : `#${id}`;
196
+ const labels = {
197
+ interested: 'interested',
198
+ applied: 'applied',
199
+ maybe: 'maybe',
200
+ not_interested: 'passed',
201
+ };
202
+ showToast(`${title} → ${labels[state] || state}`, state);
203
+ };
204
+
205
+ const handleAISummary = (job) => {
206
+ setSelectedJob(job);
207
+ setView('ai');
208
+ ai.summarizeJob(job);
209
+ };
210
+ const handleAIBatch = (visibleJobs) => {
211
+ setView('ai');
212
+ ai.batchReview(visibleJobs);
213
+ };
214
+ const handleBack = () => setView('list');
215
+ const handleExport = () => {
216
+ try {
217
+ const filename = exportShortlist(allJobs);
218
+ showToast(`Exported to ${filename}`, 'export');
219
+ } catch (err) {
220
+ showToast(`Export failed: ${err.message}`, 'error');
221
+ }
222
+ };
223
+
224
+ const handleCreateSearch = async (name, prompt) => {
225
+ const search = await searchesHook.create(name, prompt);
226
+ setActiveSearchId(search.id);
227
+ };
228
+ const handleDeleteSearch = async (id) => {
229
+ await searchesHook.remove(id);
230
+ if (activeSearchId === id) setActiveSearchId(null);
231
+ };
232
+ const handleSwitchSearch = (id) => {
233
+ setActiveSearchId(id);
234
+ setCursor(0);
235
+ };
236
+
237
+ const activeSearch = activeSearchId
238
+ ? searchesHook.searches.find((s) => s.id === activeSearchId)
239
+ : null;
240
+
241
+ const counts = {
242
+ all: allJobs.length,
243
+ interested: allJobs.filter((j) => j.state === 'interested').length,
244
+ applied: allJobs.filter((j) => j.state === 'applied').length,
245
+ maybe: allJobs.filter((j) => j.state === 'maybe').length,
246
+ passed: allJobs.filter((j) => j.state === 'not_interested').length,
247
+ };
248
+
249
+ const toastEl = toast ? h(Toast, { toast }) : null;
250
+
251
+ // ── Layout ──────────────────────────────────────────
252
+
253
+ const header = h(Header, {
254
+ tab,
255
+ tabs: TABS,
256
+ tabLabels: TAB_LABELS,
257
+ counts,
258
+ filters: activeFilters,
259
+ searchName: activeSearch?.name || null,
260
+ appliedQuery,
261
+ });
262
+
263
+ const statusBar = h(StatusBar, {
264
+ view: inlineSearch ? 'search' : view,
265
+ jobCount: jobs.length,
266
+ totalCount: allJobs.length,
267
+ loading,
268
+ reranking,
269
+ error,
270
+ aiEnabled: ai.hasKey,
271
+ searchName: activeSearch?.name || null,
272
+ toast: toastEl,
273
+ });
274
+
275
+ // Split-pane: compact list on left, detail on right
276
+ if (view === 'detail' && selectedJob) {
277
+ return h(
278
+ Box,
279
+ { flexDirection: 'column', height: process.stdout.rows || 40 },
280
+ header,
281
+ h(
282
+ Box,
283
+ { flexGrow: 1, flexDirection: 'row' },
284
+ // Left pane: compact job list
285
+ h(
286
+ Box,
287
+ {
288
+ flexDirection: 'column',
289
+ width: '40%',
290
+ borderStyle: 'single',
291
+ borderColor: 'gray',
292
+ borderRight: true,
293
+ borderLeft: false,
294
+ borderTop: false,
295
+ borderBottom: false,
296
+ },
297
+ h(JobList, {
298
+ jobs,
299
+ cursor,
300
+ tab,
301
+ onCursorChange: setCursor,
302
+ onSelect: handleSelect,
303
+ onMark: handleMark,
304
+ isActive: true,
305
+ compact: true,
306
+ reservedRows: 8,
307
+ })
308
+ ),
309
+ // Right pane: job detail
310
+ h(
311
+ Box,
312
+ { flexDirection: 'column', width: '60%' },
313
+ h(JobDetail, {
314
+ job: selectedJob,
315
+ api,
316
+ onBack: handleBack,
317
+ onMark: handleMark,
318
+ onAISummary: handleAISummary,
319
+ isActive: false,
320
+ isPanel: true,
321
+ })
322
+ )
323
+ ),
324
+ statusBar
325
+ );
326
+ }
327
+
328
+ // Full-width list view
329
+ return h(
330
+ Box,
331
+ { flexDirection: 'column', height: process.stdout.rows || 40 },
332
+ header,
333
+ view === 'list'
334
+ ? h(JobList, {
335
+ jobs,
336
+ cursor,
337
+ tab,
338
+ onCursorChange: setCursor,
339
+ onSelect: handleSelect,
340
+ onMark: handleMark,
341
+ onAISummary: handleAISummary,
342
+ onAIBatch: handleAIBatch,
343
+ onExport: handleExport,
344
+ isActive: !inlineSearch,
345
+ reservedRows: 10,
346
+ })
347
+ : null,
348
+ view === 'list' && inlineSearch
349
+ ? h(InlineSearch, {
350
+ query: searchQuery,
351
+ onChange: setSearchQuery,
352
+ onSubmit: () => {
353
+ setAppliedQuery(searchQuery);
354
+ setInlineSearch(false);
355
+ setCursor(0);
356
+ },
357
+ })
358
+ : null,
359
+ view === 'filters'
360
+ ? h(FilterManager, {
361
+ filterState,
362
+ onUpdate: persistFilters,
363
+ onClose: () => setView('list'),
364
+ })
365
+ : null,
366
+ view === 'searches'
367
+ ? h(SearchManager, {
368
+ searches: searchesHook.searches,
369
+ activeSearchId,
370
+ onSwitch: handleSwitchSearch,
371
+ onCreate: handleCreateSearch,
372
+ onDelete: handleDeleteSearch,
373
+ onClose: () => setView('list'),
374
+ })
375
+ : null,
376
+ view === 'ai'
377
+ ? h(AIPanel, {
378
+ text: ai.text,
379
+ loading: ai.loading,
380
+ error: ai.error,
381
+ onDismiss: () => {
382
+ ai.clear();
383
+ setView(selectedJob ? 'detail' : 'list');
384
+ },
385
+ isActive: true,
386
+ })
387
+ : null,
388
+ view === 'help' ? h(HelpModal, { onClose: () => setView('list') }) : null,
389
+ statusBar
390
+ );
391
+ }
392
+
393
+ export default function runTUI({ baseUrl, apiKey }) {
394
+ render(h(App, { baseUrl, apiKey }));
395
+ }
@@ -0,0 +1,263 @@
1
+ import { useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import { h } from './h.js';
5
+
6
+ const FILTER_TYPES = [
7
+ { type: 'remote', label: 'Remote only', hasValue: false },
8
+ {
9
+ type: 'search',
10
+ label: 'Keyword search',
11
+ hasValue: true,
12
+ placeholder: 'e.g. react, python',
13
+ },
14
+ {
15
+ type: 'minSalary',
16
+ label: 'Minimum salary ($k)',
17
+ hasValue: true,
18
+ placeholder: 'e.g. 150',
19
+ },
20
+ {
21
+ type: 'days',
22
+ label: 'Posted within (days)',
23
+ hasValue: true,
24
+ placeholder: 'e.g. 14',
25
+ },
26
+ ];
27
+
28
+ export default function FilterManager({ filterState, onUpdate, onClose }) {
29
+ const [mode, setMode] = useState('list'); // list | add | edit
30
+ const [cursor, setCursor] = useState(0);
31
+ const [addCursor, setAddCursor] = useState(0);
32
+ const [editValue, setEditValue] = useState('');
33
+ const [editIdx, setEditIdx] = useState(-1);
34
+
35
+ const active = filterState.active || [];
36
+
37
+ useInput(
38
+ (input, key) => {
39
+ if (mode === 'list') {
40
+ if (key.escape) {
41
+ onClose();
42
+ return;
43
+ }
44
+ if (key.upArrow || input === 'k') setCursor((c) => Math.max(0, c - 1));
45
+ if (key.downArrow || input === 'j')
46
+ setCursor((c) => Math.min(active.length - 1, c + 1));
47
+ if (input === 'a') {
48
+ setMode('add');
49
+ setAddCursor(0);
50
+ return;
51
+ }
52
+ if (input === 'd' && active[cursor]) {
53
+ const next = [...active];
54
+ next.splice(cursor, 1);
55
+ onUpdate({ ...filterState, active: next });
56
+ setCursor((c) => Math.min(c, next.length - 1));
57
+ return;
58
+ }
59
+ if (key.return && active[cursor]?.hasValue !== false) {
60
+ setEditIdx(cursor);
61
+ setEditValue(String(active[cursor]?.value || ''));
62
+ setMode('edit');
63
+ }
64
+ }
65
+
66
+ if (mode === 'add') {
67
+ if (key.escape) {
68
+ setMode('list');
69
+ return;
70
+ }
71
+ if (key.upArrow) setAddCursor((c) => Math.max(0, c - 1));
72
+ if (key.downArrow)
73
+ setAddCursor((c) => Math.min(FILTER_TYPES.length - 1, c + 1));
74
+ if (key.return) {
75
+ const ft = FILTER_TYPES[addCursor];
76
+ if (!ft.hasValue) {
77
+ // Toggle filter (e.g. remote)
78
+ const exists = active.findIndex((f) => f.type === ft.type);
79
+ if (exists >= 0) {
80
+ const next = [...active];
81
+ next.splice(exists, 1);
82
+ onUpdate({ ...filterState, active: next });
83
+ } else {
84
+ onUpdate({
85
+ ...filterState,
86
+ active: [...active, { type: ft.type, value: true }],
87
+ });
88
+ }
89
+ setMode('list');
90
+ } else {
91
+ setEditIdx(-1);
92
+ setEditValue('');
93
+ setMode('editNew');
94
+ }
95
+ }
96
+ }
97
+ },
98
+ { isActive: mode === 'list' || mode === 'add' }
99
+ );
100
+
101
+ // Edit mode for existing filter
102
+ if (mode === 'edit') {
103
+ const filter = active[editIdx];
104
+ return h(
105
+ Box,
106
+ {
107
+ flexDirection: 'column',
108
+ borderStyle: 'double',
109
+ borderColor: 'yellow',
110
+ padding: 1,
111
+ marginX: 2,
112
+ },
113
+ h(Text, { bold: true, color: 'yellow' }, `Edit: ${filter.type}`),
114
+ h(
115
+ Box,
116
+ { marginTop: 1 },
117
+ h(Text, null, 'Value: '),
118
+ h(TextInput, {
119
+ value: editValue,
120
+ onChange: setEditValue,
121
+ onSubmit: (val) => {
122
+ const next = [...active];
123
+ next[editIdx] = {
124
+ ...next[editIdx],
125
+ value:
126
+ filter.type === 'minSalary' || filter.type === 'days'
127
+ ? parseInt(val) || val
128
+ : val,
129
+ };
130
+ onUpdate({ ...filterState, active: next });
131
+ setMode('list');
132
+ },
133
+ })
134
+ ),
135
+ h(
136
+ Text,
137
+ { dimColor: true, marginTop: 1 },
138
+ 'Enter to save, Ctrl+C to cancel'
139
+ )
140
+ );
141
+ }
142
+
143
+ // Edit mode for new filter
144
+ if (mode === 'editNew') {
145
+ const ft = FILTER_TYPES[addCursor];
146
+ return h(
147
+ Box,
148
+ {
149
+ flexDirection: 'column',
150
+ borderStyle: 'double',
151
+ borderColor: 'green',
152
+ padding: 1,
153
+ marginX: 2,
154
+ },
155
+ h(Text, { bold: true, color: 'green' }, `New filter: ${ft.label}`),
156
+ h(
157
+ Box,
158
+ { marginTop: 1 },
159
+ h(Text, null, 'Value: '),
160
+ h(TextInput, {
161
+ value: editValue,
162
+ placeholder: ft.placeholder || '',
163
+ onChange: setEditValue,
164
+ onSubmit: (val) => {
165
+ if (val.trim()) {
166
+ const value =
167
+ ft.type === 'minSalary' || ft.type === 'days'
168
+ ? parseInt(val) || val
169
+ : val;
170
+ onUpdate({
171
+ ...filterState,
172
+ active: [...active, { type: ft.type, value, hasValue: true }],
173
+ });
174
+ }
175
+ setMode('list');
176
+ },
177
+ })
178
+ ),
179
+ h(
180
+ Text,
181
+ { dimColor: true, marginTop: 1 },
182
+ `${ft.placeholder || ''} Enter to save`
183
+ )
184
+ );
185
+ }
186
+
187
+ // Add mode - pick filter type
188
+ if (mode === 'add') {
189
+ return h(
190
+ Box,
191
+ {
192
+ flexDirection: 'column',
193
+ borderStyle: 'double',
194
+ borderColor: 'green',
195
+ padding: 1,
196
+ marginX: 2,
197
+ },
198
+ h(Text, { bold: true, color: 'green' }, 'Add Filter'),
199
+ h(Text, null, ''),
200
+ ...FILTER_TYPES.map((ft, i) => {
201
+ const selected = i === addCursor;
202
+ const isActive = active.some((f) => f.type === ft.type);
203
+ return h(
204
+ Box,
205
+ { key: ft.type },
206
+ h(
207
+ Text,
208
+ {
209
+ inverse: selected,
210
+ color: isActive ? 'green' : selected ? 'white' : undefined,
211
+ },
212
+ `${selected ? '▸ ' : ' '}${ft.label}${isActive ? ' ✓' : ''}`
213
+ )
214
+ );
215
+ }),
216
+ h(Text, { dimColor: true, marginTop: 1 }, 'enter:select esc:back')
217
+ );
218
+ }
219
+
220
+ // List mode - show active filters
221
+ return h(
222
+ Box,
223
+ {
224
+ flexDirection: 'column',
225
+ borderStyle: 'double',
226
+ borderColor: 'yellow',
227
+ padding: 1,
228
+ marginX: 2,
229
+ },
230
+ h(Text, { bold: true, color: 'yellow' }, '⚙ Filter Manager'),
231
+ h(Text, null, ''),
232
+ active.length === 0
233
+ ? h(Text, { dimColor: true }, 'No active filters. Press a to add one.')
234
+ : null,
235
+ ...active.map((f, i) => {
236
+ const selected = i === cursor;
237
+ const label =
238
+ f.type === 'remote'
239
+ ? 'Remote only'
240
+ : f.type === 'search'
241
+ ? `Search: "${f.value}"`
242
+ : f.type === 'minSalary'
243
+ ? `Salary ≥ $${f.value}k`
244
+ : f.type === 'days'
245
+ ? `Posted within ${f.value} days`
246
+ : `${f.type}: ${f.value}`;
247
+ return h(
248
+ Box,
249
+ { key: i },
250
+ h(
251
+ Text,
252
+ {
253
+ inverse: selected,
254
+ color: selected ? 'white' : 'yellow',
255
+ },
256
+ `${selected ? '▸ ' : ' '}${label}`
257
+ )
258
+ );
259
+ }),
260
+ h(Text, null, ''),
261
+ h(Text, { dimColor: true }, 'a:add d:delete enter:edit esc:close')
262
+ );
263
+ }