@accounter/scraper-app 0.0.1
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 +90 -0
- package/docs/plan.md +76 -0
- package/index.html +12 -0
- package/package.json +40 -0
- package/src/env.template +2 -0
- package/src/server/__tests__/accounts-routes.test.ts +133 -0
- package/src/server/__tests__/check-accounts.test.ts +305 -0
- package/src/server/__tests__/filter-payload.test.ts +193 -0
- package/src/server/__tests__/graphql-client.integration.test.ts +98 -0
- package/src/server/__tests__/graphql-client.test.ts +508 -0
- package/src/server/__tests__/healthz.test.ts +22 -0
- package/src/server/__tests__/history.test.ts +111 -0
- package/src/server/__tests__/otp-manager.test.ts +132 -0
- package/src/server/__tests__/scrape-runner.test.ts +144 -0
- package/src/server/__tests__/settings-routes.test.ts +117 -0
- package/src/server/__tests__/sources-routes.test.ts +149 -0
- package/src/server/__tests__/validate-payload.test.ts +193 -0
- package/src/server/__tests__/vault-routes.test.ts +174 -0
- package/src/server/__tests__/vault.test.ts +33 -0
- package/src/server/__tests__/websocket.test.ts +151 -0
- package/src/server/account-discovery.ts +49 -0
- package/src/server/accounts-routes.ts +74 -0
- package/src/server/check-accounts.ts +79 -0
- package/src/server/filter-payload.ts +145 -0
- package/src/server/graphql/client.ts +103 -0
- package/src/server/graphql/mutations.ts +518 -0
- package/src/server/history-routes.ts +11 -0
- package/src/server/history.ts +53 -0
- package/src/server/index.ts +40 -0
- package/src/server/otp-manager.ts +63 -0
- package/src/server/payload-schemas/amex.schema.ts +2 -0
- package/src/server/payload-schemas/cal.schema.ts +27 -0
- package/src/server/payload-schemas/currency-rates.schema.ts +11 -0
- package/src/server/payload-schemas/discount.schema.ts +26 -0
- package/src/server/payload-schemas/isracard.schema.ts +58 -0
- package/src/server/payload-schemas/max.schema.ts +27 -0
- package/src/server/payload-schemas/poalim-foreign.schema.ts +30 -0
- package/src/server/payload-schemas/poalim-ils.schema.ts +31 -0
- package/src/server/payload-schemas/poalim-swift.schema.ts +21 -0
- package/src/server/scrape-runner.ts +165 -0
- package/src/server/scrapers/__tests__/amex.test.ts +142 -0
- package/src/server/scrapers/__tests__/cal.test.ts +135 -0
- package/src/server/scrapers/__tests__/currency-rates.test.ts +105 -0
- package/src/server/scrapers/__tests__/discount.test.ts +160 -0
- package/src/server/scrapers/__tests__/isracard.test.ts +142 -0
- package/src/server/scrapers/__tests__/max.test.ts +115 -0
- package/src/server/scrapers/__tests__/poalim.test.ts +154 -0
- package/src/server/scrapers/amex.ts +63 -0
- package/src/server/scrapers/cal.ts +56 -0
- package/src/server/scrapers/currency-rates.ts +64 -0
- package/src/server/scrapers/discount.ts +62 -0
- package/src/server/scrapers/isracard.ts +68 -0
- package/src/server/scrapers/max.ts +32 -0
- package/src/server/scrapers/poalim.ts +103 -0
- package/src/server/settings-routes.ts +27 -0
- package/src/server/sources-routes.ts +182 -0
- package/src/server/validate-payload.ts +74 -0
- package/src/server/vault-routes.ts +99 -0
- package/src/server/vault-store.ts +42 -0
- package/src/server/vault.ts +216 -0
- package/src/server/websocket.ts +454 -0
- package/src/shared/source-types.ts +10 -0
- package/src/shared/types.ts +20 -0
- package/src/shared/ws-protocol.ts +177 -0
- package/src/test-setup.ts +6 -0
- package/src/ui/__tests__/accounts-tab.test.tsx +134 -0
- package/src/ui/__tests__/config.test.tsx +99 -0
- package/src/ui/__tests__/history.test.tsx +94 -0
- package/src/ui/__tests__/run.test.tsx +195 -0
- package/src/ui/__tests__/settings-tab.test.tsx +79 -0
- package/src/ui/__tests__/sources-tab.test.tsx +139 -0
- package/src/ui/__tests__/vault-setup.test.tsx +105 -0
- package/src/ui/__tests__/vault-unlock.test.tsx +78 -0
- package/src/ui/app.tsx +109 -0
- package/src/ui/components/error-boundary.tsx +54 -0
- package/src/ui/components/otp-modal.tsx +82 -0
- package/src/ui/components/skeleton.tsx +58 -0
- package/src/ui/components/task-row.tsx +241 -0
- package/src/ui/contexts/vault-context.tsx +77 -0
- package/src/ui/lib/api.ts +117 -0
- package/src/ui/lib/ws.ts +137 -0
- package/src/ui/main.tsx +9 -0
- package/src/ui/screens/config/accounts-tab.tsx +185 -0
- package/src/ui/screens/config/config.tsx +163 -0
- package/src/ui/screens/config/settings-tab.tsx +167 -0
- package/src/ui/screens/config/source-forms.tsx +518 -0
- package/src/ui/screens/config/source-types.ts +91 -0
- package/src/ui/screens/config/sources-tab.tsx +176 -0
- package/src/ui/screens/history.tsx +234 -0
- package/src/ui/screens/run.tsx +266 -0
- package/src/ui/screens/vault-setup.tsx +120 -0
- package/src/ui/screens/vault-unlock.tsx +38 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +24 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState, type ReactElement } from 'react';
|
|
2
|
+
import { AccountRecord } from '../../../server/vault.js';
|
|
3
|
+
import { deleteAccount, fetchAccounts, updateStatus } from '../../lib/api.js';
|
|
4
|
+
import { SOURCE_LABELS } from './source-types.js';
|
|
5
|
+
|
|
6
|
+
type SourceType = 'poalim' | 'discount' | 'isracard' | 'amex' | 'cal' | 'max';
|
|
7
|
+
type AccountStatus = 'accepted' | 'ignored' | 'pending';
|
|
8
|
+
|
|
9
|
+
const STATUS_COLORS: Record<AccountStatus, string> = {
|
|
10
|
+
accepted: '#2a7a2a',
|
|
11
|
+
ignored: '#999',
|
|
12
|
+
pending: '#b85c00',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function StatusBadge({ status }: { status: AccountStatus }): ReactElement {
|
|
16
|
+
return (
|
|
17
|
+
<span
|
|
18
|
+
style={{
|
|
19
|
+
padding: '2px 8px',
|
|
20
|
+
borderRadius: 4,
|
|
21
|
+
fontSize: '0.8em',
|
|
22
|
+
color: '#fff',
|
|
23
|
+
background: STATUS_COLORS[status],
|
|
24
|
+
}}
|
|
25
|
+
>
|
|
26
|
+
{status}
|
|
27
|
+
</span>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type AccountRowProps = {
|
|
32
|
+
account: AccountRecord;
|
|
33
|
+
onStatusChange(id: string, status: 'accepted' | 'ignored'): void;
|
|
34
|
+
onDelete(id: string): void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function AccountRow({ account, onStatusChange, onDelete }: AccountRowProps): ReactElement {
|
|
38
|
+
const label = account.branchNumber
|
|
39
|
+
? `${account.accountNumber} / branch ${account.branchNumber}`
|
|
40
|
+
: account.accountNumber;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<li
|
|
44
|
+
style={{
|
|
45
|
+
padding: '8px 0',
|
|
46
|
+
borderBottom: '1px solid #eee',
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
50
|
+
<span style={{ flex: 1 }}>{label}</span>
|
|
51
|
+
<StatusBadge status={account.status} />
|
|
52
|
+
<select
|
|
53
|
+
aria-label={`Status for ${label}`}
|
|
54
|
+
value={account.status}
|
|
55
|
+
onChange={e => {
|
|
56
|
+
const v = e.target.value as 'accepted' | 'ignored';
|
|
57
|
+
if (v === 'accepted' || v === 'ignored') onStatusChange(account.id, v);
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<option value="pending">pending</option>
|
|
61
|
+
<option value="accepted">accepted</option>
|
|
62
|
+
<option value="ignored">ignored</option>
|
|
63
|
+
</select>
|
|
64
|
+
<button
|
|
65
|
+
aria-label={`Delete ${label}`}
|
|
66
|
+
onClick={() => onDelete(account.id)}
|
|
67
|
+
style={{
|
|
68
|
+
color: '#c00',
|
|
69
|
+
background: 'none',
|
|
70
|
+
border: 'none',
|
|
71
|
+
cursor: 'pointer',
|
|
72
|
+
padding: '0 4px',
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
✕
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
{account.status === 'pending' && (
|
|
79
|
+
<p style={{ margin: '4px 0 0', fontSize: '0.85em', color: '#b85c00' }}>
|
|
80
|
+
Visit the Accounter client to set up this account.
|
|
81
|
+
</p>
|
|
82
|
+
)}
|
|
83
|
+
</li>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function AccountsTab(): ReactElement {
|
|
88
|
+
const [accounts, setAccounts] = useState<(AccountRecord & { nickname?: string })[]>([]);
|
|
89
|
+
const [error, setError] = useState<string | null>(null);
|
|
90
|
+
const [pendingOnly, setPendingOnly] = useState(false);
|
|
91
|
+
|
|
92
|
+
const load = useCallback(async () => {
|
|
93
|
+
try {
|
|
94
|
+
setAccounts(await fetchAccounts());
|
|
95
|
+
} catch {
|
|
96
|
+
setError('Failed to load accounts');
|
|
97
|
+
}
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
void load();
|
|
102
|
+
}, [load]);
|
|
103
|
+
|
|
104
|
+
async function handleStatusChange(id: string, status: 'accepted' | 'ignored') {
|
|
105
|
+
try {
|
|
106
|
+
setAccounts(await updateStatus(id, status));
|
|
107
|
+
} catch {
|
|
108
|
+
setError('Failed to update account');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function handleDelete(id: string) {
|
|
113
|
+
try {
|
|
114
|
+
setAccounts(await deleteAccount(id));
|
|
115
|
+
} catch {
|
|
116
|
+
setError('Failed to delete account');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const grouped = useMemo(
|
|
121
|
+
() =>
|
|
122
|
+
accounts.reduce<Record<string, (AccountRecord & { nickname?: string })[]>>((acc, a) => {
|
|
123
|
+
const key = `${a.sourceType}:${a.nickname ?? a.sourceId}`;
|
|
124
|
+
acc[key] ||= [];
|
|
125
|
+
acc[key].push(a);
|
|
126
|
+
return acc;
|
|
127
|
+
}, {}),
|
|
128
|
+
[accounts],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const pending = useMemo(() => accounts.filter(a => a.status === 'pending'), [accounts]);
|
|
132
|
+
|
|
133
|
+
const sections = useMemo(() => {
|
|
134
|
+
if (pendingOnly) {
|
|
135
|
+
return [{ key: 'pending', label: 'Pending accounts', accounts: pending }];
|
|
136
|
+
}
|
|
137
|
+
return Object.entries(grouped).map(([key, accs]) => {
|
|
138
|
+
const [sourceType, sourceId] = key.split(':') as [SourceType, string];
|
|
139
|
+
return {
|
|
140
|
+
key,
|
|
141
|
+
label: `${SOURCE_LABELS[sourceType]} (${sourceId})`,
|
|
142
|
+
accounts: accs,
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
}, [pendingOnly, pending, grouped]);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div>
|
|
149
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
|
150
|
+
<h2 style={{ margin: 0 }}>Account Records</h2>
|
|
151
|
+
{pending.length > 0 && (
|
|
152
|
+
<button onClick={() => setPendingOnly(p => !p)} style={{ fontSize: '0.85em' }}>
|
|
153
|
+
{pendingOnly ? 'Show all' : `Classify ${pending.length} pending`}
|
|
154
|
+
</button>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{error && (
|
|
159
|
+
<p role="alert" style={{ color: 'red' }}>
|
|
160
|
+
{error}
|
|
161
|
+
</p>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{accounts.length === 0 && (
|
|
165
|
+
<p>No account records discovered yet. Run a scrape to populate this list.</p>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{sections.map(section => (
|
|
169
|
+
<section key={section.key} style={{ marginBottom: 20 }}>
|
|
170
|
+
<h3 style={{ marginBottom: 6 }}>{section.label}</h3>
|
|
171
|
+
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
|
172
|
+
{section.accounts.map(a => (
|
|
173
|
+
<AccountRow
|
|
174
|
+
key={a.id}
|
|
175
|
+
account={a}
|
|
176
|
+
onStatusChange={(id, status) => void handleStatusChange(id, status)}
|
|
177
|
+
onDelete={id => void handleDelete(id)}
|
|
178
|
+
/>
|
|
179
|
+
))}
|
|
180
|
+
</ul>
|
|
181
|
+
</section>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useEffect, useState, type ChangeEvent, type ReactElement } from 'react';
|
|
2
|
+
import { loadSettings, saveSettings, testConnection } from '../../lib/api.js';
|
|
3
|
+
import { AccountsTab } from './accounts-tab.js';
|
|
4
|
+
import { SettingsTab } from './settings-tab.js';
|
|
5
|
+
import { SourcesTab } from './sources-tab.js';
|
|
6
|
+
|
|
7
|
+
type TabId = 'sources' | 'accounts' | 'settings';
|
|
8
|
+
|
|
9
|
+
const TABS: { id: TabId; label: string }[] = [
|
|
10
|
+
{ id: 'sources', label: 'Credentials' },
|
|
11
|
+
{ id: 'accounts', label: 'Accounts' },
|
|
12
|
+
{ id: 'settings', label: 'Settings' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
type TestResult = { ok: true; latencyMs: number } | { ok: false; error: string };
|
|
16
|
+
|
|
17
|
+
function ConnectionBar(): ReactElement {
|
|
18
|
+
const [serverUrl, setServerUrl] = useState('');
|
|
19
|
+
const [apiKey, setApiKey] = useState('');
|
|
20
|
+
const [testing, setTesting] = useState(false);
|
|
21
|
+
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
loadSettings().then(s => {
|
|
25
|
+
setServerUrl(s.serverUrl ?? '');
|
|
26
|
+
setApiKey(s.apiKey ?? '');
|
|
27
|
+
});
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
function handleUrlBlur(e: ChangeEvent<HTMLInputElement>) {
|
|
31
|
+
setTestResult(null);
|
|
32
|
+
void saveSettings({ serverUrl: e.target.value || undefined });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function handleKeyBlur(e: ChangeEvent<HTMLInputElement>) {
|
|
36
|
+
setTestResult(null);
|
|
37
|
+
void saveSettings({ apiKey: e.target.value || undefined });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function handleTestConnection() {
|
|
41
|
+
setTesting(true);
|
|
42
|
+
setTestResult(null);
|
|
43
|
+
try {
|
|
44
|
+
const result = await testConnection();
|
|
45
|
+
setTestResult(
|
|
46
|
+
result.ok
|
|
47
|
+
? { ok: true, latencyMs: result.latencyMs ?? 0 }
|
|
48
|
+
: { ok: false, error: result.error ?? 'Unknown error' },
|
|
49
|
+
);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
52
|
+
} finally {
|
|
53
|
+
setTesting(false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
style={{
|
|
60
|
+
padding: '12px 0 16px',
|
|
61
|
+
marginBottom: 16,
|
|
62
|
+
borderBottom: '1px solid #ddd',
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<h3 style={{ margin: '0 0 10px' }}>Server connection</h3>
|
|
66
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|
67
|
+
<div>
|
|
68
|
+
<label
|
|
69
|
+
htmlFor="conn-server-url"
|
|
70
|
+
style={{ display: 'block', marginBottom: 4, fontSize: '0.9em' }}
|
|
71
|
+
>
|
|
72
|
+
Server URL
|
|
73
|
+
</label>
|
|
74
|
+
<input
|
|
75
|
+
id="conn-server-url"
|
|
76
|
+
type="text"
|
|
77
|
+
value={serverUrl}
|
|
78
|
+
onChange={e => setServerUrl(e.target.value)}
|
|
79
|
+
onBlur={handleUrlBlur}
|
|
80
|
+
placeholder="https://your-accounter-server"
|
|
81
|
+
style={{ padding: '4px 8px', width: 260 }}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
<div>
|
|
85
|
+
<label
|
|
86
|
+
htmlFor="conn-api-key"
|
|
87
|
+
style={{ display: 'block', marginBottom: 4, fontSize: '0.9em' }}
|
|
88
|
+
>
|
|
89
|
+
API Key
|
|
90
|
+
</label>
|
|
91
|
+
<input
|
|
92
|
+
id="conn-api-key"
|
|
93
|
+
type="password"
|
|
94
|
+
value={apiKey}
|
|
95
|
+
onChange={e => setApiKey(e.target.value)}
|
|
96
|
+
onBlur={handleKeyBlur}
|
|
97
|
+
placeholder="your-api-key"
|
|
98
|
+
style={{ padding: '4px 8px', width: 200 }}
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
<button type="button" onClick={() => void handleTestConnection()} disabled={testing}>
|
|
102
|
+
{testing ? 'Testing…' : 'Test connection'}
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
{testResult && (
|
|
106
|
+
<p
|
|
107
|
+
role="status"
|
|
108
|
+
style={{
|
|
109
|
+
margin: '8px 0 0',
|
|
110
|
+
fontSize: '0.85em',
|
|
111
|
+
color: testResult.ok ? '#15803d' : '#b91c1c',
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
{testResult.ok ? `✓ Connected (${testResult.latencyMs} ms)` : `✕ ${testResult.error}`}
|
|
115
|
+
</p>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function Config(): ReactElement {
|
|
122
|
+
const [active, setActive] = useState<TabId>('sources');
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div>
|
|
126
|
+
<nav
|
|
127
|
+
role="tablist"
|
|
128
|
+
style={{ display: 'flex', gap: 4, borderBottom: '2px solid #ddd', marginBottom: 20 }}
|
|
129
|
+
>
|
|
130
|
+
{TABS.map(t => (
|
|
131
|
+
<button
|
|
132
|
+
key={t.id}
|
|
133
|
+
role="tab"
|
|
134
|
+
aria-selected={active === t.id}
|
|
135
|
+
onClick={() => setActive(t.id)}
|
|
136
|
+
style={{
|
|
137
|
+
padding: '8px 16px',
|
|
138
|
+
border: 'none',
|
|
139
|
+
background: 'none',
|
|
140
|
+
cursor: 'pointer',
|
|
141
|
+
borderBottom: active === t.id ? '2px solid #333' : '2px solid transparent',
|
|
142
|
+
fontWeight: active === t.id ? 'bold' : 'normal',
|
|
143
|
+
marginBottom: -2,
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{t.label}
|
|
147
|
+
</button>
|
|
148
|
+
))}
|
|
149
|
+
</nav>
|
|
150
|
+
|
|
151
|
+
<div role="tabpanel">
|
|
152
|
+
{active === 'sources' && (
|
|
153
|
+
<>
|
|
154
|
+
<ConnectionBar />
|
|
155
|
+
<SourcesTab />
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
{active === 'accounts' && <AccountsTab />}
|
|
159
|
+
{active === 'settings' && <SettingsTab />}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useEffect, useState, type ChangeEvent, type ReactElement } from 'react';
|
|
2
|
+
import type { Settings } from '../../../server/vault.js';
|
|
3
|
+
import { getVaultPath, loadSettings, saveSettings } from '../../lib/api.js';
|
|
4
|
+
|
|
5
|
+
export function SettingsTab(): ReactElement {
|
|
6
|
+
const [settings, setSettings] = useState<Settings | null>(null);
|
|
7
|
+
const [vaultPath, setVaultPath] = useState<string>('');
|
|
8
|
+
const [copied, setCopied] = useState(false);
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
loadSettings()
|
|
13
|
+
.then(s => setSettings(s))
|
|
14
|
+
.catch(() => setError('Failed to load settings'));
|
|
15
|
+
getVaultPath()
|
|
16
|
+
.then(r => setVaultPath(r.path))
|
|
17
|
+
.catch(() => setVaultPath(''));
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
function handleCopyPath() {
|
|
21
|
+
void navigator.clipboard.writeText(vaultPath).then(() => {
|
|
22
|
+
setCopied(true);
|
|
23
|
+
setTimeout(() => setCopied(false), 2000);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function autoSave(patch: Partial<Settings>) {
|
|
28
|
+
try {
|
|
29
|
+
const updated = await saveSettings(patch);
|
|
30
|
+
setSettings(updated);
|
|
31
|
+
} catch {
|
|
32
|
+
setError('Failed to save settings');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function handleToggle(key: 'showBrowser' | 'fetchBankOfIsraelRates' | 'concurrentScraping') {
|
|
37
|
+
return (e: ChangeEvent<HTMLInputElement>) => {
|
|
38
|
+
const value = e.target.checked;
|
|
39
|
+
setSettings(s => (s ? { ...s, [key]: value } : s));
|
|
40
|
+
void autoSave({ [key]: value });
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function handleNumberBlur(e: ChangeEvent<HTMLInputElement>) {
|
|
45
|
+
const parsed = e.target.value ? parseInt(e.target.value, 10) : undefined;
|
|
46
|
+
if (parsed !== undefined && Number.isNaN(parsed)) return;
|
|
47
|
+
// Fall back to current value when field is cleared; server default takes over on next load
|
|
48
|
+
const value = parsed ?? settings?.defaultDateRangeMonths ?? 3;
|
|
49
|
+
setSettings(s => (s ? { ...s, defaultDateRangeMonths: value } : s));
|
|
50
|
+
void autoSave({ defaultDateRangeMonths: parsed });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function handleTextBlur(key: 'historyFilePath') {
|
|
54
|
+
return (e: ChangeEvent<HTMLInputElement>) => {
|
|
55
|
+
const value = e.target.value || settings?.[key] || './history.json';
|
|
56
|
+
setSettings(s => (s ? { ...s, [key]: value } : s));
|
|
57
|
+
void autoSave({ [key]: e.target.value || undefined });
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!settings) return <p>{error ?? 'Loading…'}</p>;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div>
|
|
65
|
+
<h2>Settings</h2>
|
|
66
|
+
|
|
67
|
+
{error && (
|
|
68
|
+
<p role="alert" style={{ color: 'red' }}>
|
|
69
|
+
{error}
|
|
70
|
+
</p>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
<fieldset style={{ border: 'none', padding: 0, margin: 0 }}>
|
|
74
|
+
<legend style={{ fontWeight: 'bold', marginBottom: 12 }}>Scraper options</legend>
|
|
75
|
+
|
|
76
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
|
77
|
+
<input
|
|
78
|
+
id="showBrowser"
|
|
79
|
+
type="checkbox"
|
|
80
|
+
checked={settings.showBrowser}
|
|
81
|
+
onChange={handleToggle('showBrowser')}
|
|
82
|
+
/>
|
|
83
|
+
Show browser during scraping
|
|
84
|
+
</label>
|
|
85
|
+
|
|
86
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
|
87
|
+
<input
|
|
88
|
+
id="fetchBankOfIsraelRates"
|
|
89
|
+
type="checkbox"
|
|
90
|
+
checked={settings.fetchBankOfIsraelRates}
|
|
91
|
+
onChange={handleToggle('fetchBankOfIsraelRates')}
|
|
92
|
+
/>
|
|
93
|
+
Fetch Bank of Israel exchange rates
|
|
94
|
+
</label>
|
|
95
|
+
|
|
96
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
|
97
|
+
<input
|
|
98
|
+
id="concurrentScraping"
|
|
99
|
+
type="checkbox"
|
|
100
|
+
checked={settings.concurrentScraping}
|
|
101
|
+
onChange={handleToggle('concurrentScraping')}
|
|
102
|
+
/>
|
|
103
|
+
Scrape sources concurrently
|
|
104
|
+
</label>
|
|
105
|
+
|
|
106
|
+
<div style={{ marginBottom: 10 }}>
|
|
107
|
+
<label htmlFor="defaultDateRangeMonths" style={{ display: 'block', marginBottom: 4 }}>
|
|
108
|
+
Default date range (months)
|
|
109
|
+
</label>
|
|
110
|
+
<input
|
|
111
|
+
id="defaultDateRangeMonths"
|
|
112
|
+
type="number"
|
|
113
|
+
min={1}
|
|
114
|
+
defaultValue={settings.defaultDateRangeMonths ?? ''}
|
|
115
|
+
onBlur={handleNumberBlur}
|
|
116
|
+
style={{ padding: '4px 8px', width: 80 }}
|
|
117
|
+
placeholder="e.g. 6"
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div style={{ marginBottom: 10 }}>
|
|
122
|
+
<label htmlFor="historyFilePath" style={{ display: 'block', marginBottom: 4 }}>
|
|
123
|
+
History file path
|
|
124
|
+
</label>
|
|
125
|
+
<input
|
|
126
|
+
id="historyFilePath"
|
|
127
|
+
type="text"
|
|
128
|
+
defaultValue={settings.historyFilePath ?? ''}
|
|
129
|
+
onBlur={handleTextBlur('historyFilePath')}
|
|
130
|
+
style={{ padding: '4px 8px', width: '100%', maxWidth: 400 }}
|
|
131
|
+
placeholder="/path/to/history.json"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
</fieldset>
|
|
135
|
+
|
|
136
|
+
{vaultPath && (
|
|
137
|
+
<fieldset style={{ border: 'none', padding: 0, margin: '20px 0 0' }}>
|
|
138
|
+
<legend style={{ fontWeight: 'bold', marginBottom: 12 }}>Vault file</legend>
|
|
139
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
|
140
|
+
<input
|
|
141
|
+
readOnly
|
|
142
|
+
value={vaultPath}
|
|
143
|
+
style={{
|
|
144
|
+
padding: '4px 8px',
|
|
145
|
+
width: '100%',
|
|
146
|
+
maxWidth: 360,
|
|
147
|
+
color: '#555',
|
|
148
|
+
background: '#f9fafb',
|
|
149
|
+
}}
|
|
150
|
+
aria-label="Vault file path"
|
|
151
|
+
/>
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
onClick={handleCopyPath}
|
|
155
|
+
style={{ padding: '4px 12px', whiteSpace: 'nowrap' }}
|
|
156
|
+
>
|
|
157
|
+
{copied ? '✓ Copied' : 'Copy path'}
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
<p style={{ margin: 0, fontSize: '0.85em', color: '#6b7280' }}>
|
|
161
|
+
Back up this file to preserve your credentials.
|
|
162
|
+
</p>
|
|
163
|
+
</fieldset>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|