@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.
@@ -0,0 +1,87 @@
1
+ import { Box, Text } from 'ink';
2
+ import { h } from './h.js';
3
+
4
+ const FILTER_LABELS = {
5
+ remote: (f) => `Remote: ${f.value}`,
6
+ search: (f) => `Search: "${f.value}"`,
7
+ minSalary: (f) => `Salary β‰₯ $${f.value}k`,
8
+ days: (f) => `Last ${f.value} days`,
9
+ };
10
+
11
+ export default function Header({
12
+ tab,
13
+ tabs,
14
+ tabLabels,
15
+ counts,
16
+ filters,
17
+ searchName,
18
+ appliedQuery,
19
+ }) {
20
+ const tabElements = tabs.map((t) => {
21
+ const active = t === tab;
22
+ const count = counts[t] || 0;
23
+ const label = `${tabLabels[t]} (${count})`;
24
+ return h(
25
+ Box,
26
+ { key: t, marginRight: 1 },
27
+ h(
28
+ Text,
29
+ {
30
+ bold: active,
31
+ color: active ? 'cyan' : 'gray',
32
+ underline: active,
33
+ },
34
+ ` ${label} `
35
+ )
36
+ );
37
+ });
38
+
39
+ const filterTags = (filters || []).map((f, i) => {
40
+ const label = FILTER_LABELS[f.type]?.(f) || `${f.type}: ${f.value}`;
41
+ return h(Text, { key: i, color: 'yellow' }, ` [${label}] `);
42
+ });
43
+
44
+ const hasFilters = filterTags.length > 0 || appliedQuery;
45
+
46
+ return h(
47
+ Box,
48
+ { flexDirection: 'column', marginBottom: 0 },
49
+ h(
50
+ Box,
51
+ {
52
+ paddingX: 1,
53
+ borderStyle: 'single',
54
+ borderColor: 'cyan',
55
+ borderBottom: false,
56
+ },
57
+ h(Text, { bold: true, color: 'cyan' }, '⚑ '),
58
+ h(Text, { bold: true, color: 'white' }, 'JSON Resume Job Search'),
59
+ searchName ? h(Text, { color: 'magenta' }, ` πŸ” ${searchName}`) : null,
60
+ h(Text, { color: 'gray' }, ' '),
61
+ h(Text, { dimColor: true }, 'tab:sections /:searches ?:help')
62
+ ),
63
+ h(Box, { paddingX: 1, gap: 0 }, ...tabElements),
64
+ hasFilters
65
+ ? h(
66
+ Box,
67
+ { paddingX: 1 },
68
+ filterTags.length > 0
69
+ ? h(Text, { dimColor: true }, 'Filters:')
70
+ : null,
71
+ ...filterTags,
72
+ appliedQuery
73
+ ? h(Text, { color: 'yellow' }, ` [Find: "${appliedQuery}"] `)
74
+ : null,
75
+ h(Text, { dimColor: true }, ' f:manage')
76
+ )
77
+ : h(
78
+ Box,
79
+ { paddingX: 1 },
80
+ h(
81
+ Text,
82
+ { dimColor: true },
83
+ 'No filters active f:add n:quick search'
84
+ )
85
+ )
86
+ );
87
+ }
@@ -0,0 +1,94 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ import { h } from './h.js';
3
+
4
+ const SECTIONS = [
5
+ {
6
+ title: 'Navigation',
7
+ keys: [
8
+ ['j / ↓', 'Move down'],
9
+ ['k / ↑', 'Move up'],
10
+ ['Enter', 'Open job details'],
11
+ ['Esc / q', 'Back / quit'],
12
+ ['Tab', 'Next section tab'],
13
+ ['Shift+Tab', 'Previous section tab'],
14
+ ],
15
+ },
16
+ {
17
+ title: 'Job Actions',
18
+ keys: [
19
+ ['i', 'Mark interested'],
20
+ ['x', 'Mark applied'],
21
+ ['m', 'Mark maybe'],
22
+ ['p', 'Mark passed'],
23
+ ],
24
+ },
25
+ {
26
+ title: 'Search & Filter',
27
+ keys: [
28
+ ['/', 'Search profiles'],
29
+ ['f', 'Manage filters'],
30
+ ['R', 'Force refresh'],
31
+ ],
32
+ },
33
+ {
34
+ title: 'AI Features',
35
+ keys: [
36
+ ['Space', 'AI summary of job'],
37
+ ['S', 'AI batch review'],
38
+ ],
39
+ },
40
+ {
41
+ title: 'Detail View',
42
+ keys: [
43
+ ['o', 'Open HN post in browser'],
44
+ ['e', 'Export shortlist to markdown'],
45
+ ],
46
+ },
47
+ ];
48
+
49
+ export default function HelpModal({ onClose }) {
50
+ useInput((input, key) => {
51
+ if (key.escape || input === '?' || input === 'q') onClose();
52
+ });
53
+
54
+ const cols = process.stdout.columns || 80;
55
+ const width = Math.min(60, cols - 4);
56
+
57
+ return h(
58
+ Box,
59
+ {
60
+ flexDirection: 'column',
61
+ borderStyle: 'round',
62
+ borderColor: 'cyan',
63
+ paddingX: 2,
64
+ paddingY: 1,
65
+ width,
66
+ alignSelf: 'center',
67
+ },
68
+ h(
69
+ Box,
70
+ { justifyContent: 'center', marginBottom: 1 },
71
+ h(Text, { bold: true, color: 'cyan' }, '⌨ Keyboard Shortcuts')
72
+ ),
73
+ ...SECTIONS.flatMap((section) => [
74
+ h(
75
+ Box,
76
+ { key: `h-${section.title}`, marginTop: 1 },
77
+ h(Text, { bold: true, color: 'yellow' }, section.title)
78
+ ),
79
+ ...section.keys.map(([key, desc]) =>
80
+ h(
81
+ Box,
82
+ { key: `k-${key}` },
83
+ h(Box, { width: 16 }, h(Text, { color: 'cyan', bold: true }, key)),
84
+ h(Text, { dimColor: true }, desc)
85
+ )
86
+ ),
87
+ ]),
88
+ h(
89
+ Box,
90
+ { marginTop: 1, justifyContent: 'center' },
91
+ h(Text, { dimColor: true }, 'Press ? or Esc to close')
92
+ )
93
+ );
94
+ }
@@ -0,0 +1,170 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { h } from './h.js';
5
+ import { stateIcon, formatSalary, formatLocation } from '../formatters.js';
6
+
7
+ export default function JobDetail({
8
+ job,
9
+ api,
10
+ onBack,
11
+ onMark,
12
+ onAISummary,
13
+ isActive,
14
+ isPanel,
15
+ }) {
16
+ const [detail, setDetail] = useState(null);
17
+ const [loading, setLoading] = useState(true);
18
+ const [scroll, setScroll] = useState(0);
19
+
20
+ useEffect(() => {
21
+ setLoading(true);
22
+ setScroll(0);
23
+ api
24
+ .fetchJobDetail(job.id)
25
+ .then((d) => {
26
+ setDetail(d);
27
+ setLoading(false);
28
+ })
29
+ .catch(() => setLoading(false));
30
+ }, [job.id, api]);
31
+
32
+ useInput(
33
+ (input, key) => {
34
+ if (!isPanel) {
35
+ if (key.escape || input === 'q') {
36
+ onBack();
37
+ return;
38
+ }
39
+ }
40
+ if (key.upArrow || input === 'K') setScroll((s) => Math.max(0, s - 1));
41
+ if (key.downArrow || input === 'J') setScroll((s) => s + 1);
42
+ if (input === 'i') onMark(job.id, 'interested');
43
+ if (input === 'x') onMark(job.id, 'applied');
44
+ if (input === 'm') onMark(job.id, 'maybe');
45
+ if (input === 'p') onMark(job.id, 'not_interested');
46
+ if (input === ' ') onAISummary(job);
47
+ if (input === 'o' && (detail?.url || job.url)) {
48
+ import('child_process').then(({ exec }) => {
49
+ const url = detail?.url || job.url;
50
+ const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
51
+ exec(`${cmd} "${url}"`);
52
+ });
53
+ }
54
+ },
55
+ { isActive }
56
+ );
57
+
58
+ if (loading) {
59
+ return h(
60
+ Box,
61
+ { flexDirection: 'column', paddingX: 2, paddingY: 1 },
62
+ h(Box, null, h(Spinner, { type: 'dots' }), h(Text, null, ' Loading…'))
63
+ );
64
+ }
65
+
66
+ const d = detail || job;
67
+ const state = d.state || job.state;
68
+ const loc = formatLocation(d.location, d.remote);
69
+ const sal = formatSalary(d.salary, d.salary_usd);
70
+
71
+ const lines = [];
72
+
73
+ // Title
74
+ lines.push({ text: d.title || job.title, bold: true, color: 'white' });
75
+ lines.push({ text: `at ${d.company || job.company}`, color: 'cyan' });
76
+ lines.push({ text: '' });
77
+
78
+ // Meta as key-value pairs
79
+ const meta = [
80
+ ['πŸ“ Location', loc],
81
+ ['πŸ’° Salary', sal],
82
+ ['πŸ“‹ Type', d.type || 'β€”'],
83
+ ['πŸ“Š Experience', d.experience || 'β€”'],
84
+ ['πŸ“… Posted', d.posted_at || 'β€”'],
85
+ [
86
+ '🎯 Match',
87
+ typeof d.similarity === 'number' ? `${d.similarity.toFixed(3)}` : 'β€”',
88
+ ],
89
+ ];
90
+ if (d.rerank_score) meta.push(['🧠 AI Score', `${d.rerank_score}/10`]);
91
+ if (d.combined_score) meta.push(['πŸ“ˆ Combined', d.combined_score.toFixed(3)]);
92
+ meta.push(['πŸ“Œ Status', state ? `${stateIcon(state)} ${state}` : 'unmarked']);
93
+
94
+ for (const [label, value] of meta) {
95
+ lines.push({ text: ` ${label.padEnd(14)} ${value}` });
96
+ }
97
+ lines.push({ text: '' });
98
+
99
+ if (d.url || job.url) {
100
+ lines.push({ text: ` πŸ”— ${d.url || job.url}`, color: 'blue' });
101
+ lines.push({ text: '' });
102
+ }
103
+
104
+ // Skills
105
+ if (d.skills?.length) {
106
+ lines.push({ text: 'Skills', bold: true, color: 'yellow' });
107
+ const skillNames = d.skills.map((s) => s.name || s).join(' Β· ');
108
+ lines.push({ text: ` ${skillNames}` });
109
+ lines.push({ text: '' });
110
+ }
111
+
112
+ // Description
113
+ if (d.description) {
114
+ lines.push({ text: 'Description', bold: true, color: 'yellow' });
115
+ for (const l of d.description.split('\n')) {
116
+ lines.push({ text: ` ${l}` });
117
+ }
118
+ lines.push({ text: '' });
119
+ }
120
+
121
+ // Qualifications
122
+ if (d.qualifications?.length) {
123
+ lines.push({ text: 'Qualifications', bold: true, color: 'yellow' });
124
+ for (const q of d.qualifications) {
125
+ lines.push({ text: ` β€’ ${q}` });
126
+ }
127
+ lines.push({ text: '' });
128
+ }
129
+
130
+ // Responsibilities
131
+ if (d.responsibilities?.length) {
132
+ lines.push({ text: 'Responsibilities', bold: true, color: 'yellow' });
133
+ for (const r of d.responsibilities) {
134
+ lines.push({ text: ` β€’ ${r}` });
135
+ }
136
+ lines.push({ text: '' });
137
+ }
138
+
139
+ const maxRows = Math.max((process.stdout.rows || 30) - (isPanel ? 6 : 8), 8);
140
+ const visible = lines.slice(scroll, scroll + maxRows);
141
+ const hasMore = lines.length > maxRows;
142
+
143
+ return h(
144
+ Box,
145
+ { flexDirection: 'column', paddingX: 2, paddingY: isPanel ? 0 : 1 },
146
+ ...visible.map((line, i) =>
147
+ h(
148
+ Text,
149
+ {
150
+ key: i,
151
+ wrap: 'wrap',
152
+ bold: line.bold || false,
153
+ color: line.color || undefined,
154
+ dimColor: line.dimColor || false,
155
+ },
156
+ line.text
157
+ )
158
+ ),
159
+ hasMore
160
+ ? h(
161
+ Text,
162
+ { dimColor: true },
163
+ ` J/K scroll detail (${scroll + 1}–${Math.min(
164
+ scroll + maxRows,
165
+ lines.length
166
+ )}/${lines.length})`
167
+ )
168
+ : null
169
+ );
170
+ }