@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/README.md +270 -0
- package/bin/cli.js +380 -0
- package/package.json +47 -0
- package/skills/jsonresume-hunt/SKILL.md +208 -0
- package/src/api.js +61 -0
- package/src/auth.js +115 -0
- package/src/cache.js +48 -0
- package/src/export.js +65 -0
- package/src/filters.js +59 -0
- package/src/formatters.js +71 -0
- package/src/tui/AIPanel.js +37 -0
- package/src/tui/App.js +395 -0
- package/src/tui/FilterManager.js +263 -0
- package/src/tui/Header.js +87 -0
- package/src/tui/HelpModal.js +94 -0
- package/src/tui/JobDetail.js +170 -0
- package/src/tui/JobList.js +382 -0
- package/src/tui/PreviewPane.js +68 -0
- package/src/tui/SearchManager.js +207 -0
- package/src/tui/StatusBar.js +108 -0
- package/src/tui/Toast.js +56 -0
- package/src/tui/h.js +2 -0
- package/src/tui/useAI.js +107 -0
- package/src/tui/useJobs.js +168 -0
- package/src/tui/useSearches.js +56 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import { h } from './h.js';
|
|
3
|
+
|
|
4
|
+
const KEYS = {
|
|
5
|
+
list: [
|
|
6
|
+
['jk', 'nav'],
|
|
7
|
+
['enter', 'open'],
|
|
8
|
+
['i', '⭐'],
|
|
9
|
+
['x', '📨'],
|
|
10
|
+
['m', '?'],
|
|
11
|
+
['p', '✗'],
|
|
12
|
+
['v', 'select'],
|
|
13
|
+
['space', 'AI'],
|
|
14
|
+
['f', 'filter'],
|
|
15
|
+
['/', 'search'],
|
|
16
|
+
['e', 'export'],
|
|
17
|
+
['?', 'help'],
|
|
18
|
+
['q', 'quit'],
|
|
19
|
+
],
|
|
20
|
+
detail: [
|
|
21
|
+
['jk', 'nav'],
|
|
22
|
+
['JK', 'scroll detail'],
|
|
23
|
+
['i', '⭐'],
|
|
24
|
+
['x', '📨'],
|
|
25
|
+
['m', '?'],
|
|
26
|
+
['p', '✗'],
|
|
27
|
+
['o', 'open'],
|
|
28
|
+
['space', 'AI'],
|
|
29
|
+
['esc', 'back'],
|
|
30
|
+
['q', 'close'],
|
|
31
|
+
],
|
|
32
|
+
filters: [
|
|
33
|
+
['jk', 'nav'],
|
|
34
|
+
['enter', 'edit'],
|
|
35
|
+
['a', 'add'],
|
|
36
|
+
['d', 'delete'],
|
|
37
|
+
['esc', 'close'],
|
|
38
|
+
],
|
|
39
|
+
searches: [
|
|
40
|
+
['jk', 'nav'],
|
|
41
|
+
['enter', 'switch'],
|
|
42
|
+
['n', 'new'],
|
|
43
|
+
['d', 'delete'],
|
|
44
|
+
['esc', 'close'],
|
|
45
|
+
],
|
|
46
|
+
ai: [['esc', 'dismiss']],
|
|
47
|
+
help: [['?/esc', 'close']],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export default function StatusBar({
|
|
51
|
+
view,
|
|
52
|
+
jobCount,
|
|
53
|
+
totalCount,
|
|
54
|
+
loading,
|
|
55
|
+
reranking,
|
|
56
|
+
error,
|
|
57
|
+
aiEnabled,
|
|
58
|
+
searchName,
|
|
59
|
+
toast,
|
|
60
|
+
}) {
|
|
61
|
+
const keys = KEYS[view] || KEYS.list;
|
|
62
|
+
|
|
63
|
+
const keyElements = keys.map(([key, label], i) =>
|
|
64
|
+
h(
|
|
65
|
+
Box,
|
|
66
|
+
{ key: i, marginRight: 1 },
|
|
67
|
+
h(Text, { bold: true, color: 'cyan' }, key),
|
|
68
|
+
h(Text, { dimColor: true }, `:${label}`)
|
|
69
|
+
)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return h(
|
|
73
|
+
Box,
|
|
74
|
+
{
|
|
75
|
+
flexDirection: 'column',
|
|
76
|
+
borderStyle: 'single',
|
|
77
|
+
borderColor: 'gray',
|
|
78
|
+
paddingX: 1,
|
|
79
|
+
marginTop: 0,
|
|
80
|
+
},
|
|
81
|
+
toast
|
|
82
|
+
? h(
|
|
83
|
+
Box,
|
|
84
|
+
{ justifyContent: 'space-between' },
|
|
85
|
+
toast,
|
|
86
|
+
h(
|
|
87
|
+
Box,
|
|
88
|
+
{ gap: 1 },
|
|
89
|
+
h(Text, { dimColor: true }, `${jobCount}/${totalCount} jobs`)
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
: h(
|
|
93
|
+
Box,
|
|
94
|
+
{ justifyContent: 'space-between' },
|
|
95
|
+
h(Box, { flexWrap: 'wrap', gap: 0 }, ...keyElements),
|
|
96
|
+
h(
|
|
97
|
+
Box,
|
|
98
|
+
{ gap: 1 },
|
|
99
|
+
loading ? h(Text, { color: 'yellow' }, '⏳') : null,
|
|
100
|
+
reranking ? h(Text, { color: 'magenta' }, '🧠 reranking…') : null,
|
|
101
|
+
h(Text, { dimColor: true }, `${jobCount}/${totalCount} jobs`),
|
|
102
|
+
searchName ? h(Text, { color: 'magenta' }, '🔍') : null,
|
|
103
|
+
aiEnabled ? null : h(Text, { color: 'gray' }, '(no AI)')
|
|
104
|
+
)
|
|
105
|
+
),
|
|
106
|
+
error ? h(Text, { color: 'red' }, `Error: ${error}`) : null
|
|
107
|
+
);
|
|
108
|
+
}
|
package/src/tui/Toast.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { h } from './h.js';
|
|
4
|
+
|
|
5
|
+
const ICONS = {
|
|
6
|
+
interested: '⭐',
|
|
7
|
+
applied: '📨',
|
|
8
|
+
maybe: '❓',
|
|
9
|
+
not_interested: '✗',
|
|
10
|
+
dismissed: '👁',
|
|
11
|
+
success: '✓',
|
|
12
|
+
error: '✗',
|
|
13
|
+
info: 'ℹ',
|
|
14
|
+
export: '📄',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const COLORS = {
|
|
18
|
+
interested: 'green',
|
|
19
|
+
applied: 'cyan',
|
|
20
|
+
maybe: 'yellow',
|
|
21
|
+
not_interested: 'red',
|
|
22
|
+
dismissed: 'gray',
|
|
23
|
+
success: 'green',
|
|
24
|
+
error: 'red',
|
|
25
|
+
info: 'blue',
|
|
26
|
+
export: 'green',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function useToast(timeout = 2500) {
|
|
30
|
+
const [toast, setToast] = useState(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!toast) return;
|
|
34
|
+
const timer = setTimeout(() => setToast(null), timeout);
|
|
35
|
+
return () => clearTimeout(timer);
|
|
36
|
+
}, [toast, timeout]);
|
|
37
|
+
|
|
38
|
+
const show = (message, type = 'info') => {
|
|
39
|
+
setToast({ message, type, time: Date.now() });
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return { toast, show };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function Toast({ toast }) {
|
|
46
|
+
if (!toast) return null;
|
|
47
|
+
|
|
48
|
+
const icon = ICONS[toast.type] || ICONS.info;
|
|
49
|
+
const color = COLORS[toast.type] || 'blue';
|
|
50
|
+
|
|
51
|
+
return h(
|
|
52
|
+
Box,
|
|
53
|
+
{ paddingX: 1 },
|
|
54
|
+
h(Text, { color, bold: true }, `${icon} ${toast.message}`)
|
|
55
|
+
);
|
|
56
|
+
}
|
package/src/tui/h.js
ADDED
package/src/tui/useAI.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import { generateText } from 'ai';
|
|
3
|
+
import { openai } from '@ai-sdk/openai';
|
|
4
|
+
|
|
5
|
+
export function useAI(resume) {
|
|
6
|
+
const [text, setText] = useState('');
|
|
7
|
+
const [loading, setLoading] = useState(false);
|
|
8
|
+
const [error, setError] = useState(null);
|
|
9
|
+
const hasKey = Boolean(process.env.OPENAI_API_KEY);
|
|
10
|
+
|
|
11
|
+
const summarizeJob = useCallback(
|
|
12
|
+
async (job) => {
|
|
13
|
+
if (!hasKey) {
|
|
14
|
+
setError('Set OPENAI_API_KEY to enable AI features');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
setLoading(true);
|
|
18
|
+
setError(null);
|
|
19
|
+
setText('');
|
|
20
|
+
try {
|
|
21
|
+
const resumeText = [
|
|
22
|
+
resume?.basics?.label,
|
|
23
|
+
resume?.basics?.summary,
|
|
24
|
+
...(resume?.skills || []).map(
|
|
25
|
+
(s) => `${s.name}: ${(s.keywords || []).join(', ')}`
|
|
26
|
+
),
|
|
27
|
+
]
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.join('\n');
|
|
30
|
+
|
|
31
|
+
const jobText = [
|
|
32
|
+
`Title: ${job.title}`,
|
|
33
|
+
`Company: ${job.company}`,
|
|
34
|
+
`Salary: ${job.salary || 'Not listed'}`,
|
|
35
|
+
`Remote: ${job.remote || 'Not specified'}`,
|
|
36
|
+
`Description: ${job.description || ''}`,
|
|
37
|
+
`Skills: ${(job.skills || []).map((s) => s.name).join(', ')}`,
|
|
38
|
+
].join('\n');
|
|
39
|
+
|
|
40
|
+
const { text: result } = await generateText({
|
|
41
|
+
model: openai('gpt-4o-mini'),
|
|
42
|
+
system:
|
|
43
|
+
'You are a career advisor. Given a job posting and resume, provide a concise fit analysis in 4-5 bullet points. Cover: why it fits, skill gaps, salary assessment, and remote compatibility. Be direct and opinionated.',
|
|
44
|
+
prompt: `Resume:\n${resumeText}\n\nJob:\n${jobText}`,
|
|
45
|
+
maxTokens: 400,
|
|
46
|
+
});
|
|
47
|
+
setText(result);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
setError(err.message);
|
|
50
|
+
} finally {
|
|
51
|
+
setLoading(false);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
[resume, hasKey]
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const batchReview = useCallback(
|
|
58
|
+
async (jobs) => {
|
|
59
|
+
if (!hasKey) {
|
|
60
|
+
setError('Set OPENAI_API_KEY to enable AI features');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setLoading(true);
|
|
64
|
+
setError(null);
|
|
65
|
+
setText('');
|
|
66
|
+
try {
|
|
67
|
+
const resumeText = [resume?.basics?.label, resume?.basics?.summary]
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.join('\n');
|
|
70
|
+
|
|
71
|
+
const jobsList = jobs
|
|
72
|
+
.slice(0, 15)
|
|
73
|
+
.map(
|
|
74
|
+
(j, i) =>
|
|
75
|
+
`${i + 1}. [${j.similarity}] ${j.title} at ${j.company} | ${
|
|
76
|
+
j.salary || 'no salary'
|
|
77
|
+
} | ${j.remote || 'no remote info'}`
|
|
78
|
+
)
|
|
79
|
+
.join('\n');
|
|
80
|
+
|
|
81
|
+
const { text: result } = await generateText({
|
|
82
|
+
model: openai('gpt-4o-mini'),
|
|
83
|
+
system:
|
|
84
|
+
'You are a career advisor. Rank these jobs by fit for the candidate. For each, give a 1-line verdict. Be direct.',
|
|
85
|
+
prompt: `Resume:\n${resumeText}\n\nJobs:\n${jobsList}`,
|
|
86
|
+
maxTokens: 600,
|
|
87
|
+
});
|
|
88
|
+
setText(result);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
setError(err.message);
|
|
91
|
+
} finally {
|
|
92
|
+
setLoading(false);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
[resume, hasKey]
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
text,
|
|
100
|
+
loading,
|
|
101
|
+
error,
|
|
102
|
+
hasKey,
|
|
103
|
+
summarizeJob,
|
|
104
|
+
batchReview,
|
|
105
|
+
clear: () => setText(''),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
2
|
+
import { getCached, setCache, updateCachedJob } from '../cache.js';
|
|
3
|
+
|
|
4
|
+
export function useJobs(api, activeFilters, tab, searchId) {
|
|
5
|
+
const [allJobs, setAllJobs] = useState([]);
|
|
6
|
+
const [loading, setLoading] = useState(true);
|
|
7
|
+
const [reranking, setReranking] = useState(false);
|
|
8
|
+
const [error, setError] = useState(null);
|
|
9
|
+
const rerankAbort = useRef(null);
|
|
10
|
+
|
|
11
|
+
// Build API params from active filters array
|
|
12
|
+
const params = useMemo(() => {
|
|
13
|
+
const p = { top: 100, days: 30 };
|
|
14
|
+
if (searchId) p.searchId = searchId;
|
|
15
|
+
for (const f of activeFilters || []) {
|
|
16
|
+
if (f.type === 'days') p.days = Number(f.value) || 30;
|
|
17
|
+
if (f.type === 'remote') p.remote = true;
|
|
18
|
+
if (f.type === 'minSalary') p.minSalary = Number(f.value) || 0;
|
|
19
|
+
if (f.type === 'search') p.search = f.value || '';
|
|
20
|
+
}
|
|
21
|
+
return p;
|
|
22
|
+
}, [activeFilters, searchId]);
|
|
23
|
+
|
|
24
|
+
const paramsKey = JSON.stringify(params);
|
|
25
|
+
|
|
26
|
+
const fetchJobs = useCallback(
|
|
27
|
+
async (force) => {
|
|
28
|
+
// Cancel any pending rerank
|
|
29
|
+
if (rerankAbort.current) {
|
|
30
|
+
rerankAbort.current.cancelled = true;
|
|
31
|
+
rerankAbort.current = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setLoading(true);
|
|
35
|
+
setReranking(false);
|
|
36
|
+
setError(null);
|
|
37
|
+
|
|
38
|
+
if (!force) {
|
|
39
|
+
// Check for reranked cache first, then plain cache
|
|
40
|
+
const cachedReranked = getCached({ ...params, rerank: true });
|
|
41
|
+
if (cachedReranked) {
|
|
42
|
+
setAllJobs(cachedReranked);
|
|
43
|
+
setLoading(false);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const cached = getCached(params);
|
|
47
|
+
if (cached) {
|
|
48
|
+
setAllJobs(cached);
|
|
49
|
+
setLoading(false);
|
|
50
|
+
// Still kick off rerank in background if eligible
|
|
51
|
+
if (searchId) {
|
|
52
|
+
startRerank(cached);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// Pass 1: fast fetch without reranking
|
|
60
|
+
const { jobs: data } = await api.fetchJobs({
|
|
61
|
+
...params,
|
|
62
|
+
rerank: false,
|
|
63
|
+
});
|
|
64
|
+
const result = data || [];
|
|
65
|
+
setCache(params, result);
|
|
66
|
+
setAllJobs(result);
|
|
67
|
+
setLoading(false);
|
|
68
|
+
|
|
69
|
+
// Pass 2: rerank in background if using a custom search
|
|
70
|
+
if (searchId && result.length > 0) {
|
|
71
|
+
startRerank(result);
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
setError(err.message);
|
|
75
|
+
setLoading(false);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
[api, paramsKey]
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
function startRerank(currentJobs) {
|
|
82
|
+
const token = { cancelled: false };
|
|
83
|
+
rerankAbort.current = token;
|
|
84
|
+
setReranking(true);
|
|
85
|
+
|
|
86
|
+
api
|
|
87
|
+
.fetchJobs({ ...params, rerank: true })
|
|
88
|
+
.then(({ jobs: reranked }) => {
|
|
89
|
+
if (token.cancelled) return;
|
|
90
|
+
const result = reranked || [];
|
|
91
|
+
setCache({ ...params, rerank: true }, result);
|
|
92
|
+
// Merge rerank data into existing jobs (preserve any local state changes)
|
|
93
|
+
setAllJobs((prev) => {
|
|
94
|
+
const stateMap = {};
|
|
95
|
+
prev.forEach((j) => {
|
|
96
|
+
if (j.state) stateMap[j.id] = j.state;
|
|
97
|
+
});
|
|
98
|
+
return result.map((j) => ({
|
|
99
|
+
...j,
|
|
100
|
+
state: stateMap[j.id] || j.state,
|
|
101
|
+
}));
|
|
102
|
+
});
|
|
103
|
+
})
|
|
104
|
+
.catch(() => {})
|
|
105
|
+
.finally(() => {
|
|
106
|
+
if (!token.cancelled) setReranking(false);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
fetchJobs(false);
|
|
112
|
+
}, [fetchJobs]);
|
|
113
|
+
|
|
114
|
+
// Filter by tab (job state) + client-side filters
|
|
115
|
+
const jobs = useMemo(() => {
|
|
116
|
+
let filtered = allJobs;
|
|
117
|
+
|
|
118
|
+
for (const f of activeFilters || []) {
|
|
119
|
+
if (f.type === 'remote') {
|
|
120
|
+
filtered = filtered.filter(
|
|
121
|
+
(j) => j.remote === 'Full' || /remote/i.test(j.location || '')
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (f.type === 'minSalary' && f.value) {
|
|
125
|
+
filtered = filtered.filter(
|
|
126
|
+
(j) => !j.salary_usd || j.salary_usd >= Number(f.value) * 1000
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (f.type === 'search' && f.value) {
|
|
130
|
+
const q = f.value.toLowerCase();
|
|
131
|
+
filtered = filtered.filter((j) =>
|
|
132
|
+
JSON.stringify(j).toLowerCase().includes(q)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (tab === 'interested')
|
|
138
|
+
return filtered.filter((j) => j.state === 'interested');
|
|
139
|
+
if (tab === 'applied') return filtered.filter((j) => j.state === 'applied');
|
|
140
|
+
if (tab === 'maybe') return filtered.filter((j) => j.state === 'maybe');
|
|
141
|
+
if (tab === 'passed')
|
|
142
|
+
return filtered.filter((j) => j.state === 'not_interested');
|
|
143
|
+
// "All" tab: hide passed and dismissed jobs
|
|
144
|
+
return filtered.filter(
|
|
145
|
+
(j) => j.state !== 'not_interested' && j.state !== 'dismissed'
|
|
146
|
+
);
|
|
147
|
+
}, [allJobs, activeFilters, tab]);
|
|
148
|
+
|
|
149
|
+
const markJob = useCallback(
|
|
150
|
+
async (id, state, feedback) => {
|
|
151
|
+
setAllJobs((prev) =>
|
|
152
|
+
prev.map((j) => (j.id === id ? { ...j, state } : j))
|
|
153
|
+
);
|
|
154
|
+
updateCachedJob(params, id, { state });
|
|
155
|
+
updateCachedJob({ ...params, rerank: true }, id, { state });
|
|
156
|
+
try {
|
|
157
|
+
await api.markJob(id, state, feedback);
|
|
158
|
+
} catch {
|
|
159
|
+
fetchJobs(false);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
[api, fetchJobs, paramsKey]
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const forceRefresh = useCallback(() => fetchJobs(true), [fetchJobs]);
|
|
166
|
+
|
|
167
|
+
return { jobs, allJobs, loading, reranking, error, markJob, forceRefresh };
|
|
168
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useSearches(api) {
|
|
4
|
+
const [searches, setSearches] = useState([]);
|
|
5
|
+
const [loading, setLoading] = useState(false);
|
|
6
|
+
|
|
7
|
+
const fetch = useCallback(async () => {
|
|
8
|
+
setLoading(true);
|
|
9
|
+
try {
|
|
10
|
+
const { searches: data } = await api.listSearches();
|
|
11
|
+
setSearches(data || []);
|
|
12
|
+
} catch {
|
|
13
|
+
// Silently fail — searches are optional
|
|
14
|
+
} finally {
|
|
15
|
+
setLoading(false);
|
|
16
|
+
}
|
|
17
|
+
}, [api]);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
fetch();
|
|
21
|
+
}, [fetch]);
|
|
22
|
+
|
|
23
|
+
const create = useCallback(
|
|
24
|
+
async (name, prompt) => {
|
|
25
|
+
try {
|
|
26
|
+
const { search } = await api.createSearch(name, prompt);
|
|
27
|
+
setSearches((prev) => [search, ...prev]);
|
|
28
|
+
return search;
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
[api]
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const remove = useCallback(
|
|
37
|
+
async (id) => {
|
|
38
|
+
try {
|
|
39
|
+
await api.deleteSearch(id);
|
|
40
|
+
setSearches((prev) => prev.filter((s) => s.id !== id));
|
|
41
|
+
} catch {}
|
|
42
|
+
},
|
|
43
|
+
[api]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const updateFilters = useCallback(
|
|
47
|
+
async (id, filters) => {
|
|
48
|
+
try {
|
|
49
|
+
await api.updateSearch(id, { filters });
|
|
50
|
+
} catch {}
|
|
51
|
+
},
|
|
52
|
+
[api]
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return { searches, loading, create, remove, updateFilters, refetch: fetch };
|
|
56
|
+
}
|