tangled
alpha
login
or
join now
indexx.dev
/
tweets2bsky
forked from
j4ck.xyz/tweets2bsky
0
fork
atom
A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
0
fork
atom
overview
issues
pulls
pipelines
feat: add settings collapses and account onboarding sheet
jack
3 weeks ago
f7b5720c
955ca3ba
+620
-192
1 changed file
expand all
collapse all
unified
split
web
src
App.tsx
+620
-192
web/src/App.tsx
···
3
3
AlertTriangle,
4
4
ArrowUpRight,
5
5
Bot,
6
6
+
ChevronLeft,
6
7
ChevronDown,
8
8
+
ChevronRight,
7
9
Clock3,
8
10
Download,
9
11
Folder,
···
27
29
Upload,
28
30
UserRound,
29
31
Users,
32
32
+
X,
30
33
} from 'lucide-react';
31
34
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
32
35
import { Badge } from './components/ui/badge';
···
39
42
type ThemeMode = 'system' | 'light' | 'dark';
40
43
type AuthView = 'login' | 'register';
41
44
type DashboardTab = 'overview' | 'accounts' | 'posts' | 'activity' | 'settings';
45
45
+
type SettingsSection = 'twitter' | 'ai' | 'data';
42
46
43
47
type AppState = 'idle' | 'checking' | 'backfilling' | 'pacing' | 'processing';
44
48
···
213
217
const DEFAULT_GROUP_NAME = 'Ungrouped';
214
218
const DEFAULT_GROUP_EMOJI = '📁';
215
219
const DEFAULT_GROUP_KEY = 'ungrouped';
220
220
+
const TAB_PATHS: Record<DashboardTab, string> = {
221
221
+
overview: '/',
222
222
+
accounts: '/accounts',
223
223
+
posts: '/posts',
224
224
+
activity: '/activity',
225
225
+
settings: '/settings',
226
226
+
};
227
227
+
const ADD_ACCOUNT_STEP_COUNT = 4;
228
228
+
const ADD_ACCOUNT_STEPS = ['Owner', 'Sources', 'Bluesky', 'Confirm'] as const;
216
229
217
230
const selectClassName =
218
231
'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
···
297
310
return `https://x.com/${normalizeTwitterUsername(twitterUsername)}/status/${twitterId}`;
298
311
}
299
312
313
313
+
function normalizePath(pathname: string): string {
314
314
+
const normalized = pathname.replace(/\/+$/, '');
315
315
+
return normalized.length === 0 ? '/' : normalized;
316
316
+
}
317
317
+
318
318
+
function getTabFromPath(pathname: string): DashboardTab | null {
319
319
+
const normalized = normalizePath(pathname);
320
320
+
const entry = (Object.entries(TAB_PATHS) as Array<[DashboardTab, string]>).find(([, path]) => path === normalized);
321
321
+
return entry ? entry[0] : null;
322
322
+
}
323
323
+
300
324
function addTwitterUsernames(current: string[], value: string): string[] {
301
325
const candidates = value
302
326
.split(/[\s,]+/)
···
403
427
const [status, setStatus] = useState<StatusResponse | null>(null);
404
428
const [countdown, setCountdown] = useState('--');
405
429
const [activeTab, setActiveTab] = useState<DashboardTab>(() => {
430
430
+
const fromPath = getTabFromPath(window.location.pathname);
431
431
+
if (fromPath) {
432
432
+
return fromPath;
433
433
+
}
434
434
+
406
435
const saved = localStorage.getItem('dashboard-tab');
407
436
if (
408
437
saved === 'overview' ||
···
426
455
const [editTwitterInput, setEditTwitterInput] = useState('');
427
456
const [newGroupName, setNewGroupName] = useState('');
428
457
const [newGroupEmoji, setNewGroupEmoji] = useState(DEFAULT_GROUP_EMOJI);
458
458
+
const [isAddAccountSheetOpen, setIsAddAccountSheetOpen] = useState(false);
459
459
+
const [addAccountStep, setAddAccountStep] = useState(1);
460
460
+
const [settingsSectionOverrides, setSettingsSectionOverrides] = useState<Partial<Record<SettingsSection, boolean>>>({});
429
461
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Record<string, boolean>>(() => {
430
462
const raw = localStorage.getItem('accounts-collapsed-groups');
431
463
if (!raw) return {};
···
474
506
setEditTwitterUsers([]);
475
507
setNewGroupName('');
476
508
setNewGroupEmoji(DEFAULT_GROUP_EMOJI);
509
509
+
setIsAddAccountSheetOpen(false);
510
510
+
setAddAccountStep(1);
511
511
+
setSettingsSectionOverrides({});
477
512
setAuthView('login');
478
513
}, []);
479
514
···
622
657
}, [activeTab]);
623
658
624
659
useEffect(() => {
660
660
+
const expectedPath = TAB_PATHS[activeTab];
661
661
+
const currentPath = normalizePath(window.location.pathname);
662
662
+
if (currentPath !== expectedPath) {
663
663
+
window.history.pushState({ tab: activeTab }, '', expectedPath);
664
664
+
}
665
665
+
}, [activeTab]);
666
666
+
667
667
+
useEffect(() => {
668
668
+
const onPopState = () => {
669
669
+
const tabFromPath = getTabFromPath(window.location.pathname);
670
670
+
if (tabFromPath) {
671
671
+
setActiveTab(tabFromPath);
672
672
+
} else {
673
673
+
setActiveTab('overview');
674
674
+
}
675
675
+
};
676
676
+
677
677
+
window.addEventListener('popstate', onPopState);
678
678
+
return () => {
679
679
+
window.removeEventListener('popstate', onPopState);
680
680
+
};
681
681
+
}, []);
682
682
+
683
683
+
useEffect(() => {
625
684
localStorage.setItem('accounts-collapsed-groups', JSON.stringify(collapsedGroupKeys));
626
685
}, [collapsedGroupKeys]);
627
686
···
714
773
}
715
774
};
716
775
}, []);
776
776
+
777
777
+
useEffect(() => {
778
778
+
if (!isAddAccountSheetOpen) {
779
779
+
return;
780
780
+
}
781
781
+
782
782
+
const onKeyDown = (event: KeyboardEvent) => {
783
783
+
if (event.key === 'Escape') {
784
784
+
closeAddAccountSheet();
785
785
+
}
786
786
+
};
787
787
+
788
788
+
window.addEventListener('keydown', onKeyDown);
789
789
+
return () => {
790
790
+
window.removeEventListener('keydown', onKeyDown);
791
791
+
};
792
792
+
}, [isAddAccountSheetOpen]);
717
793
718
794
const pendingBackfills = status?.pendingBackfills ?? [];
719
795
const currentStatus = status?.currentStatus;
···
792
868
});
793
869
}, [groups, mappings]);
794
870
const groupOptionsByKey = useMemo(() => new Map(groupOptions.map((group) => [group.key, group])), [groupOptions]);
871
871
+
const reusableGroupOptions = useMemo(
872
872
+
() => groupOptions.filter((group) => group.key !== DEFAULT_GROUP_KEY),
873
873
+
[groupOptions],
874
874
+
);
795
875
const groupedMappings = useMemo(() => {
796
876
const groups = new Map<string, { key: string; name: string; emoji: string; mappings: AccountMapping[] }>();
797
877
for (const option of groupOptions) {
···
857
937
}),
858
938
[activityGroupFilter, recentActivity, resolveMappingForActivity],
859
939
);
940
940
+
const twitterConfigured = Boolean(twitterConfig.authToken && twitterConfig.ct0);
941
941
+
const aiConfigured = Boolean(aiConfig.apiKey);
942
942
+
const sectionDefaultExpanded = useMemo<Record<SettingsSection, boolean>>(
943
943
+
() => ({
944
944
+
twitter: !twitterConfigured,
945
945
+
ai: !aiConfigured,
946
946
+
data: false,
947
947
+
}),
948
948
+
[aiConfigured, twitterConfigured],
949
949
+
);
950
950
+
const isSettingsSectionExpanded = useCallback(
951
951
+
(section: SettingsSection) => settingsSectionOverrides[section] ?? sectionDefaultExpanded[section],
952
952
+
[sectionDefaultExpanded, settingsSectionOverrides],
953
953
+
);
954
954
+
const toggleSettingsSection = (section: SettingsSection) => {
955
955
+
setSettingsSectionOverrides((previous) => ({
956
956
+
...previous,
957
957
+
[section]: !(previous[section] ?? sectionDefaultExpanded[section]),
958
958
+
}));
959
959
+
};
860
960
861
961
useEffect(() => {
862
962
if (postsGroupFilter !== 'all' && !groupOptions.some((group) => group.key === postsGroupFilter)) {
···
1165
1265
}
1166
1266
};
1167
1267
1168
1168
-
const handleAddMapping = async (event: React.FormEvent<HTMLFormElement>) => {
1169
1169
-
event.preventDefault();
1268
1268
+
const resetAddAccountDraft = () => {
1269
1269
+
setNewMapping(defaultMappingForm());
1270
1270
+
setNewTwitterUsers([]);
1271
1271
+
setNewTwitterInput('');
1272
1272
+
setAddAccountStep(1);
1273
1273
+
};
1274
1274
+
1275
1275
+
const openAddAccountSheet = () => {
1276
1276
+
resetAddAccountDraft();
1277
1277
+
setIsAddAccountSheetOpen(true);
1278
1278
+
};
1279
1279
+
1280
1280
+
const closeAddAccountSheet = () => {
1281
1281
+
setIsAddAccountSheetOpen(false);
1282
1282
+
resetAddAccountDraft();
1283
1283
+
};
1284
1284
+
1285
1285
+
const applyGroupPresetToNewMapping = (groupKey: string) => {
1286
1286
+
const group = groupOptionsByKey.get(groupKey);
1287
1287
+
if (!group || group.key === DEFAULT_GROUP_KEY) {
1288
1288
+
return;
1289
1289
+
}
1290
1290
+
setNewMapping((previous) => ({
1291
1291
+
...previous,
1292
1292
+
groupName: group.name,
1293
1293
+
groupEmoji: group.emoji,
1294
1294
+
}));
1295
1295
+
};
1296
1296
+
1297
1297
+
const submitNewMapping = async () => {
1170
1298
if (!authHeaders) {
1171
1299
return;
1172
1300
}
···
1196
1324
setNewMapping(defaultMappingForm());
1197
1325
setNewTwitterUsers([]);
1198
1326
setNewTwitterInput('');
1327
1327
+
setIsAddAccountSheetOpen(false);
1328
1328
+
setAddAccountStep(1);
1199
1329
showNotice('success', 'Account mapping added.');
1200
1330
await fetchData();
1201
1331
} catch (error) {
···
1205
1335
}
1206
1336
};
1207
1337
1338
1338
+
const advanceAddAccountStep = () => {
1339
1339
+
if (addAccountStep === 1) {
1340
1340
+
if (!newMapping.owner.trim()) {
1341
1341
+
showNotice('error', 'Owner is required.');
1342
1342
+
return;
1343
1343
+
}
1344
1344
+
setAddAccountStep(2);
1345
1345
+
return;
1346
1346
+
}
1347
1347
+
1348
1348
+
if (addAccountStep === 2) {
1349
1349
+
if (newTwitterUsers.length === 0) {
1350
1350
+
showNotice('error', 'Add at least one Twitter username.');
1351
1351
+
return;
1352
1352
+
}
1353
1353
+
setAddAccountStep(3);
1354
1354
+
return;
1355
1355
+
}
1356
1356
+
1357
1357
+
if (addAccountStep === 3) {
1358
1358
+
if (!newMapping.bskyIdentifier.trim() || !newMapping.bskyPassword.trim()) {
1359
1359
+
showNotice('error', 'Bluesky identifier and app password are required.');
1360
1360
+
return;
1361
1361
+
}
1362
1362
+
setAddAccountStep(4);
1363
1363
+
}
1364
1364
+
};
1365
1365
+
1366
1366
+
const retreatAddAccountStep = () => {
1367
1367
+
setAddAccountStep((previous) => Math.max(1, previous - 1));
1368
1368
+
};
1369
1369
+
1208
1370
const startEditMapping = (mapping: AccountMapping) => {
1209
1371
setEditingMapping(mapping);
1210
1372
setEditForm({
···
1446
1608
{themeIcon}
1447
1609
<span className="ml-2 hidden sm:inline">{themeLabel}</span>
1448
1610
</Button>
1611
1611
+
{isAdmin ? (
1612
1612
+
<Button
1613
1613
+
size="sm"
1614
1614
+
variant="outline"
1615
1615
+
onClick={() => {
1616
1616
+
setActiveTab('settings');
1617
1617
+
openAddAccountSheet();
1618
1618
+
}}
1619
1619
+
>
1620
1620
+
<Plus className="mr-2 h-4 w-4" />
1621
1621
+
Add account
1622
1622
+
</Button>
1623
1623
+
) : null}
1449
1624
<Button size="sm" onClick={runNow}>
1450
1625
<Play className="mr-2 h-4 w-4" />
1451
1626
Run now
···
1628
1803
<CardTitle>Active Accounts</CardTitle>
1629
1804
<CardDescription>Organize mappings into folders and collapse/expand groups.</CardDescription>
1630
1805
</div>
1631
1631
-
<Badge variant="outline">{mappings.length} configured</Badge>
1806
1806
+
<div className="flex items-center gap-2">
1807
1807
+
{isAdmin ? (
1808
1808
+
<Button size="sm" variant="outline" onClick={openAddAccountSheet}>
1809
1809
+
<Plus className="mr-2 h-4 w-4" />
1810
1810
+
Add account
1811
1811
+
</Button>
1812
1812
+
) : null}
1813
1813
+
<Badge variant="outline">{mappings.length} configured</Badge>
1814
1814
+
</div>
1632
1815
</div>
1633
1816
</CardHeader>
1634
1817
<CardContent className="space-y-4 pt-0">
···
1668
1851
1669
1852
{groupedMappings.length === 0 ? (
1670
1853
<div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
1671
1671
-
No mappings yet. Add one from the settings panel.
1854
1854
+
No mappings yet.
1855
1855
+
{isAdmin ? (
1856
1856
+
<div className="mt-3">
1857
1857
+
<Button size="sm" variant="outline" onClick={openAddAccountSheet}>
1858
1858
+
<Plus className="mr-2 h-4 w-4" />
1859
1859
+
Create your first account
1860
1860
+
</Button>
1861
1861
+
</div>
1862
1862
+
) : null}
1672
1863
</div>
1673
1864
) : (
1674
1865
<div className="space-y-3">
···
2273
2464
<Settings2 className="h-4 w-4" />
2274
2465
Admin Settings
2275
2466
</CardTitle>
2276
2276
-
<CardDescription>Credentials, provider setup, and account onboarding.</CardDescription>
2467
2467
+
<CardDescription>Configured sections stay collapsed so adding accounts is one click.</CardDescription>
2277
2468
</CardHeader>
2278
2278
-
<CardContent className="space-y-8">
2279
2279
-
<form className="space-y-3" onSubmit={handleSaveTwitterConfig}>
2280
2280
-
<div className="flex items-center justify-between">
2281
2281
-
<h3 className="text-sm font-semibold">Twitter Credentials</h3>
2282
2282
-
<Badge variant={twitterConfig.authToken && twitterConfig.ct0 ? 'success' : 'outline'}>
2283
2283
-
{twitterConfig.authToken && twitterConfig.ct0 ? 'Configured' : 'Missing'}
2284
2284
-
</Badge>
2285
2285
-
</div>
2286
2286
-
<div className="space-y-2">
2287
2287
-
<Label htmlFor="authToken">Primary Auth Token</Label>
2288
2288
-
<Input
2289
2289
-
id="authToken"
2290
2290
-
value={twitterConfig.authToken}
2291
2291
-
onChange={(event) => {
2292
2292
-
setTwitterConfig((prev) => ({ ...prev, authToken: event.target.value }));
2293
2293
-
}}
2294
2294
-
required
2295
2295
-
/>
2296
2296
-
</div>
2297
2297
-
<div className="space-y-2">
2298
2298
-
<Label htmlFor="ct0">Primary CT0</Label>
2299
2299
-
<Input
2300
2300
-
id="ct0"
2301
2301
-
value={twitterConfig.ct0}
2302
2302
-
onChange={(event) => {
2303
2303
-
setTwitterConfig((prev) => ({ ...prev, ct0: event.target.value }));
2304
2304
-
}}
2305
2305
-
required
2306
2306
-
/>
2307
2307
-
</div>
2308
2308
-
2309
2309
-
<div className="grid gap-3 sm:grid-cols-2">
2310
2310
-
<div className="space-y-2">
2311
2311
-
<Label htmlFor="backupAuthToken">Backup Auth Token</Label>
2312
2312
-
<Input
2313
2313
-
id="backupAuthToken"
2314
2314
-
value={twitterConfig.backupAuthToken || ''}
2315
2315
-
onChange={(event) => {
2316
2316
-
setTwitterConfig((prev) => ({ ...prev, backupAuthToken: event.target.value }));
2317
2317
-
}}
2318
2318
-
/>
2319
2319
-
</div>
2320
2320
-
<div className="space-y-2">
2321
2321
-
<Label htmlFor="backupCt0">Backup CT0</Label>
2322
2322
-
<Input
2323
2323
-
id="backupCt0"
2324
2324
-
value={twitterConfig.backupCt0 || ''}
2325
2325
-
onChange={(event) => {
2326
2326
-
setTwitterConfig((prev) => ({ ...prev, backupCt0: event.target.value }));
2327
2327
-
}}
2328
2328
-
/>
2329
2329
-
</div>
2330
2330
-
</div>
2469
2469
+
<CardContent className="pt-0">
2470
2470
+
<Button className="w-full sm:w-auto" onClick={openAddAccountSheet}>
2471
2471
+
<Plus className="mr-2 h-4 w-4" />
2472
2472
+
Add Account
2473
2473
+
</Button>
2474
2474
+
</CardContent>
2475
2475
+
</Card>
2331
2476
2332
2332
-
<Button className="w-full" size="sm" type="submit" disabled={isBusy}>
2333
2333
-
<Save className="mr-2 h-4 w-4" />
2334
2334
-
Save Twitter Credentials
2335
2335
-
</Button>
2336
2336
-
</form>
2337
2337
-
2338
2338
-
<form className="space-y-3 border-t border-border pt-6" onSubmit={handleSaveAiConfig}>
2339
2339
-
<div className="flex items-center justify-between">
2340
2340
-
<h3 className="text-sm font-semibold">AI Settings</h3>
2341
2341
-
<Badge variant={aiConfig.apiKey ? 'success' : 'outline'}>
2342
2342
-
{aiConfig.apiKey ? 'Configured' : 'Optional'}
2343
2343
-
</Badge>
2344
2344
-
</div>
2345
2345
-
<div className="space-y-2">
2346
2346
-
<Label htmlFor="provider">Provider</Label>
2347
2347
-
<select
2348
2348
-
className={selectClassName}
2349
2349
-
id="provider"
2350
2350
-
value={aiConfig.provider}
2351
2351
-
onChange={(event) => {
2352
2352
-
setAiConfig((prev) => ({ ...prev, provider: event.target.value as AIConfig['provider'] }));
2353
2353
-
}}
2354
2354
-
>
2355
2355
-
<option value="gemini">Google Gemini</option>
2356
2356
-
<option value="openai">OpenAI / OpenRouter</option>
2357
2357
-
<option value="anthropic">Anthropic</option>
2358
2358
-
<option value="custom">Custom</option>
2359
2359
-
</select>
2360
2360
-
</div>
2361
2361
-
<div className="space-y-2">
2362
2362
-
<Label htmlFor="apiKey">API Key</Label>
2363
2363
-
<Input
2364
2364
-
id="apiKey"
2365
2365
-
type="password"
2366
2366
-
value={aiConfig.apiKey || ''}
2367
2367
-
onChange={(event) => {
2368
2368
-
setAiConfig((prev) => ({ ...prev, apiKey: event.target.value }));
2369
2369
-
}}
2370
2370
-
/>
2371
2371
-
</div>
2372
2372
-
{aiConfig.provider !== 'gemini' ? (
2373
2373
-
<>
2477
2477
+
<Card className="animate-slide-up">
2478
2478
+
<button
2479
2479
+
className="flex w-full items-center justify-between px-5 py-4 text-left"
2480
2480
+
onClick={() => toggleSettingsSection('twitter')}
2481
2481
+
type="button"
2482
2482
+
>
2483
2483
+
<div>
2484
2484
+
<p className="text-sm font-semibold">Twitter Credentials</p>
2485
2485
+
<p className="text-xs text-muted-foreground">Primary and backup cookie values.</p>
2486
2486
+
</div>
2487
2487
+
<div className="flex items-center gap-2">
2488
2488
+
<Badge variant={twitterConfigured ? 'success' : 'outline'}>
2489
2489
+
{twitterConfigured ? 'Configured' : 'Missing'}
2490
2490
+
</Badge>
2491
2491
+
<ChevronDown
2492
2492
+
className={cn(
2493
2493
+
'h-4 w-4 transition-transform duration-200',
2494
2494
+
isSettingsSectionExpanded('twitter') ? 'rotate-0' : '-rotate-90',
2495
2495
+
)}
2496
2496
+
/>
2497
2497
+
</div>
2498
2498
+
</button>
2499
2499
+
<div
2500
2500
+
className={cn(
2501
2501
+
'grid transition-[grid-template-rows,opacity] duration-300 ease-out',
2502
2502
+
isSettingsSectionExpanded('twitter') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
2503
2503
+
)}
2504
2504
+
>
2505
2505
+
<div className="min-h-0 overflow-hidden">
2506
2506
+
<CardContent className="space-y-3 border-t border-border/70 pt-4">
2507
2507
+
<form className="space-y-3" onSubmit={handleSaveTwitterConfig}>
2374
2508
<div className="space-y-2">
2375
2375
-
<Label htmlFor="model">Model ID</Label>
2509
2509
+
<Label htmlFor="authToken">Primary Auth Token</Label>
2376
2510
<Input
2377
2377
-
id="model"
2378
2378
-
value={aiConfig.model || ''}
2511
2511
+
id="authToken"
2512
2512
+
value={twitterConfig.authToken}
2379
2513
onChange={(event) => {
2380
2380
-
setAiConfig((prev) => ({ ...prev, model: event.target.value }));
2514
2514
+
setTwitterConfig((prev) => ({ ...prev, authToken: event.target.value }));
2381
2515
}}
2382
2382
-
placeholder="gpt-4o"
2516
2516
+
required
2383
2517
/>
2384
2518
</div>
2385
2519
<div className="space-y-2">
2386
2386
-
<Label htmlFor="baseUrl">Base URL</Label>
2520
2520
+
<Label htmlFor="ct0">Primary CT0</Label>
2387
2521
<Input
2388
2388
-
id="baseUrl"
2389
2389
-
value={aiConfig.baseUrl || ''}
2522
2522
+
id="ct0"
2523
2523
+
value={twitterConfig.ct0}
2390
2524
onChange={(event) => {
2391
2391
-
setAiConfig((prev) => ({ ...prev, baseUrl: event.target.value }));
2525
2525
+
setTwitterConfig((prev) => ({ ...prev, ct0: event.target.value }));
2392
2526
}}
2393
2393
-
placeholder="https://api.example.com/v1"
2527
2527
+
required
2394
2528
/>
2395
2529
</div>
2396
2396
-
</>
2397
2397
-
) : null}
2530
2530
+
2531
2531
+
<div className="grid gap-3 sm:grid-cols-2">
2532
2532
+
<div className="space-y-2">
2533
2533
+
<Label htmlFor="backupAuthToken">Backup Auth Token</Label>
2534
2534
+
<Input
2535
2535
+
id="backupAuthToken"
2536
2536
+
value={twitterConfig.backupAuthToken || ''}
2537
2537
+
onChange={(event) => {
2538
2538
+
setTwitterConfig((prev) => ({ ...prev, backupAuthToken: event.target.value }));
2539
2539
+
}}
2540
2540
+
/>
2541
2541
+
</div>
2542
2542
+
<div className="space-y-2">
2543
2543
+
<Label htmlFor="backupCt0">Backup CT0</Label>
2544
2544
+
<Input
2545
2545
+
id="backupCt0"
2546
2546
+
value={twitterConfig.backupCt0 || ''}
2547
2547
+
onChange={(event) => {
2548
2548
+
setTwitterConfig((prev) => ({ ...prev, backupCt0: event.target.value }));
2549
2549
+
}}
2550
2550
+
/>
2551
2551
+
</div>
2552
2552
+
</div>
2398
2553
2399
2399
-
<Button className="w-full" size="sm" type="submit" disabled={isBusy}>
2400
2400
-
<Bot className="mr-2 h-4 w-4" />
2401
2401
-
Save AI Settings
2402
2402
-
</Button>
2403
2403
-
</form>
2554
2554
+
<Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}>
2555
2555
+
<Save className="mr-2 h-4 w-4" />
2556
2556
+
Save Twitter Credentials
2557
2557
+
</Button>
2558
2558
+
</form>
2559
2559
+
</CardContent>
2560
2560
+
</div>
2561
2561
+
</div>
2562
2562
+
</Card>
2404
2563
2405
2405
-
<form className="space-y-3 border-t border-border pt-6" onSubmit={handleAddMapping}>
2406
2406
-
<h3 className="text-sm font-semibold">Add Account Mapping</h3>
2564
2564
+
<Card className="animate-slide-up">
2565
2565
+
<button
2566
2566
+
className="flex w-full items-center justify-between px-5 py-4 text-left"
2567
2567
+
onClick={() => toggleSettingsSection('ai')}
2568
2568
+
type="button"
2569
2569
+
>
2570
2570
+
<div>
2571
2571
+
<p className="text-sm font-semibold">AI Settings</p>
2572
2572
+
<p className="text-xs text-muted-foreground">Optional enrichment and rewrite provider config.</p>
2573
2573
+
</div>
2574
2574
+
<div className="flex items-center gap-2">
2575
2575
+
<Badge variant={aiConfigured ? 'success' : 'outline'}>
2576
2576
+
{aiConfigured ? 'Configured' : 'Optional'}
2577
2577
+
</Badge>
2578
2578
+
<ChevronDown
2579
2579
+
className={cn(
2580
2580
+
'h-4 w-4 transition-transform duration-200',
2581
2581
+
isSettingsSectionExpanded('ai') ? 'rotate-0' : '-rotate-90',
2582
2582
+
)}
2583
2583
+
/>
2584
2584
+
</div>
2585
2585
+
</button>
2586
2586
+
<div
2587
2587
+
className={cn(
2588
2588
+
'grid transition-[grid-template-rows,opacity] duration-300 ease-out',
2589
2589
+
isSettingsSectionExpanded('ai') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
2590
2590
+
)}
2591
2591
+
>
2592
2592
+
<div className="min-h-0 overflow-hidden">
2593
2593
+
<CardContent className="space-y-3 border-t border-border/70 pt-4">
2594
2594
+
<form className="space-y-3" onSubmit={handleSaveAiConfig}>
2595
2595
+
<div className="space-y-2">
2596
2596
+
<Label htmlFor="provider">Provider</Label>
2597
2597
+
<select
2598
2598
+
className={selectClassName}
2599
2599
+
id="provider"
2600
2600
+
value={aiConfig.provider}
2601
2601
+
onChange={(event) => {
2602
2602
+
setAiConfig((prev) => ({ ...prev, provider: event.target.value as AIConfig['provider'] }));
2603
2603
+
}}
2604
2604
+
>
2605
2605
+
<option value="gemini">Google Gemini</option>
2606
2606
+
<option value="openai">OpenAI / OpenRouter</option>
2607
2607
+
<option value="anthropic">Anthropic</option>
2608
2608
+
<option value="custom">Custom</option>
2609
2609
+
</select>
2610
2610
+
</div>
2611
2611
+
<div className="space-y-2">
2612
2612
+
<Label htmlFor="apiKey">API Key</Label>
2613
2613
+
<Input
2614
2614
+
id="apiKey"
2615
2615
+
type="password"
2616
2616
+
value={aiConfig.apiKey || ''}
2617
2617
+
onChange={(event) => {
2618
2618
+
setAiConfig((prev) => ({ ...prev, apiKey: event.target.value }));
2619
2619
+
}}
2620
2620
+
/>
2621
2621
+
</div>
2622
2622
+
{aiConfig.provider !== 'gemini' ? (
2623
2623
+
<>
2624
2624
+
<div className="space-y-2">
2625
2625
+
<Label htmlFor="model">Model ID</Label>
2626
2626
+
<Input
2627
2627
+
id="model"
2628
2628
+
value={aiConfig.model || ''}
2629
2629
+
onChange={(event) => {
2630
2630
+
setAiConfig((prev) => ({ ...prev, model: event.target.value }));
2631
2631
+
}}
2632
2632
+
placeholder="gpt-4o"
2633
2633
+
/>
2634
2634
+
</div>
2635
2635
+
<div className="space-y-2">
2636
2636
+
<Label htmlFor="baseUrl">Base URL</Label>
2637
2637
+
<Input
2638
2638
+
id="baseUrl"
2639
2639
+
value={aiConfig.baseUrl || ''}
2640
2640
+
onChange={(event) => {
2641
2641
+
setAiConfig((prev) => ({ ...prev, baseUrl: event.target.value }));
2642
2642
+
}}
2643
2643
+
placeholder="https://api.example.com/v1"
2644
2644
+
/>
2645
2645
+
</div>
2646
2646
+
</>
2647
2647
+
) : null}
2648
2648
+
2649
2649
+
<Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}>
2650
2650
+
<Bot className="mr-2 h-4 w-4" />
2651
2651
+
Save AI Settings
2652
2652
+
</Button>
2653
2653
+
</form>
2654
2654
+
</CardContent>
2655
2655
+
</div>
2656
2656
+
</div>
2657
2657
+
</Card>
2658
2658
+
2659
2659
+
<Card className="animate-slide-up">
2660
2660
+
<button
2661
2661
+
className="flex w-full items-center justify-between px-5 py-4 text-left"
2662
2662
+
onClick={() => toggleSettingsSection('data')}
2663
2663
+
type="button"
2664
2664
+
>
2665
2665
+
<div>
2666
2666
+
<p className="text-sm font-semibold">Data Management</p>
2667
2667
+
<p className="text-xs text-muted-foreground">Export/import mappings and provider settings.</p>
2668
2668
+
</div>
2669
2669
+
<ChevronDown
2670
2670
+
className={cn(
2671
2671
+
'h-4 w-4 transition-transform duration-200',
2672
2672
+
isSettingsSectionExpanded('data') ? 'rotate-0' : '-rotate-90',
2673
2673
+
)}
2674
2674
+
/>
2675
2675
+
</button>
2676
2676
+
<div
2677
2677
+
className={cn(
2678
2678
+
'grid transition-[grid-template-rows,opacity] duration-300 ease-out',
2679
2679
+
isSettingsSectionExpanded('data') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
2680
2680
+
)}
2681
2681
+
>
2682
2682
+
<div className="min-h-0 overflow-hidden">
2683
2683
+
<CardContent className="space-y-3 border-t border-border/70 pt-4">
2684
2684
+
<Button className="w-full sm:w-auto" variant="outline" onClick={handleExportConfig}>
2685
2685
+
<Download className="mr-2 h-4 w-4" />
2686
2686
+
Export configuration
2687
2687
+
</Button>
2688
2688
+
<input
2689
2689
+
ref={importInputRef}
2690
2690
+
className="hidden"
2691
2691
+
type="file"
2692
2692
+
accept="application/json,.json"
2693
2693
+
onChange={(event) => {
2694
2694
+
void handleImportConfig(event);
2695
2695
+
}}
2696
2696
+
/>
2697
2697
+
<Button
2698
2698
+
className="w-full sm:w-auto"
2699
2699
+
variant="outline"
2700
2700
+
onClick={() => {
2701
2701
+
importInputRef.current?.click();
2702
2702
+
}}
2703
2703
+
>
2704
2704
+
<Upload className="mr-2 h-4 w-4" />
2705
2705
+
Import configuration
2706
2706
+
</Button>
2707
2707
+
<p className="text-xs text-muted-foreground">
2708
2708
+
Imports preserve dashboard users and passwords while replacing mappings, provider keys, and
2709
2709
+
scheduler settings.
2710
2710
+
</p>
2711
2711
+
</CardContent>
2712
2712
+
</div>
2713
2713
+
</div>
2714
2714
+
</Card>
2715
2715
+
</section>
2716
2716
+
) : null
2717
2717
+
) : null}
2718
2718
+
2719
2719
+
{isAddAccountSheetOpen ? (
2720
2720
+
<div
2721
2721
+
className="fixed inset-0 z-50 flex items-end justify-center bg-black/55 p-0 backdrop-blur-sm sm:items-stretch sm:justify-end"
2722
2722
+
onClick={closeAddAccountSheet}
2723
2723
+
>
2724
2724
+
<aside
2725
2725
+
className="flex h-[95vh] w-full max-w-xl flex-col rounded-t-2xl border border-border/80 bg-card shadow-2xl sm:h-full sm:rounded-none sm:rounded-l-2xl"
2726
2726
+
onClick={(event) => event.stopPropagation()}
2727
2727
+
>
2728
2728
+
<div className="flex items-start justify-between border-b border-border/70 px-5 py-4">
2729
2729
+
<div>
2730
2730
+
<p className="text-xs uppercase tracking-[0.15em] text-muted-foreground">Add Account</p>
2731
2731
+
<h2 className="text-lg font-semibold">Create Crosspost Mapping</h2>
2732
2732
+
</div>
2733
2733
+
<Button variant="ghost" size="icon" onClick={closeAddAccountSheet} aria-label="Close add account flow">
2734
2734
+
<X className="h-4 w-4" />
2735
2735
+
</Button>
2736
2736
+
</div>
2737
2737
+
2738
2738
+
<div className="border-b border-border/70 px-5 py-3">
2739
2739
+
<div className="flex items-center gap-2">
2740
2740
+
{ADD_ACCOUNT_STEPS.map((label, index) => {
2741
2741
+
const step = index + 1;
2742
2742
+
const active = step === addAccountStep;
2743
2743
+
const complete = step < addAccountStep;
2744
2744
+
return (
2745
2745
+
<div key={label} className="flex min-w-0 flex-1 items-center gap-2">
2746
2746
+
<div
2747
2747
+
className={cn(
2748
2748
+
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold',
2749
2749
+
complete && 'border-foreground bg-foreground text-background',
2750
2750
+
active && 'border-foreground text-foreground',
2751
2751
+
!active && !complete && 'border-border text-muted-foreground',
2752
2752
+
)}
2753
2753
+
>
2754
2754
+
{step}
2755
2755
+
</div>
2756
2756
+
<span
2757
2757
+
className={cn(
2758
2758
+
'truncate text-xs',
2759
2759
+
active ? 'text-foreground' : complete ? 'text-foreground/90' : 'text-muted-foreground',
2760
2760
+
)}
2761
2761
+
>
2762
2762
+
{label}
2763
2763
+
</span>
2764
2764
+
{step < ADD_ACCOUNT_STEP_COUNT ? <div className="h-px flex-1 bg-border/70" /> : null}
2765
2765
+
</div>
2766
2766
+
);
2767
2767
+
})}
2768
2768
+
</div>
2769
2769
+
</div>
2770
2770
+
2771
2771
+
<div className="flex-1 overflow-y-auto px-5 py-4">
2772
2772
+
{addAccountStep === 1 ? (
2773
2773
+
<div className="space-y-4 animate-fade-in">
2774
2774
+
<div className="space-y-1">
2775
2775
+
<p className="text-sm font-semibold">Who owns this mapping?</p>
2776
2776
+
<p className="text-xs text-muted-foreground">Set a label so account rows stay easy to scan.</p>
2777
2777
+
</div>
2407
2778
<div className="space-y-2">
2408
2408
-
<Label htmlFor="owner">Owner</Label>
2779
2779
+
<Label htmlFor="add-account-owner">Owner</Label>
2409
2780
<Input
2410
2410
-
id="owner"
2781
2781
+
id="add-account-owner"
2411
2782
value={newMapping.owner}
2412
2783
onChange={(event) => {
2413
2413
-
setNewMapping((prev) => ({ ...prev, owner: event.target.value }));
2784
2784
+
setNewMapping((previous) => ({ ...previous, owner: event.target.value }));
2414
2785
}}
2415
2415
-
required
2786
2786
+
placeholder="jack"
2416
2787
/>
2417
2788
</div>
2789
2789
+
<div className="space-y-2">
2790
2790
+
<Label>Use Existing Folder (Optional)</Label>
2791
2791
+
{reusableGroupOptions.length === 0 ? (
2792
2792
+
<p className="rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground">
2793
2793
+
No folders yet. Create one below or from the Accounts tab.
2794
2794
+
</p>
2795
2795
+
) : (
2796
2796
+
<div className="flex flex-wrap gap-2">
2797
2797
+
{reusableGroupOptions.map((group) => {
2798
2798
+
const selected = getGroupKey(newMapping.groupName) === group.key;
2799
2799
+
return (
2800
2800
+
<button
2801
2801
+
key={`preset-group-${group.key}`}
2802
2802
+
className={cn(
2803
2803
+
'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs transition-colors',
2804
2804
+
selected
2805
2805
+
? 'border-foreground bg-foreground text-background'
2806
2806
+
: 'border-border bg-background text-foreground hover:bg-muted',
2807
2807
+
)}
2808
2808
+
onClick={() => applyGroupPresetToNewMapping(group.key)}
2809
2809
+
type="button"
2810
2810
+
>
2811
2811
+
<span>{group.emoji}</span>
2812
2812
+
<span>{group.name}</span>
2813
2813
+
</button>
2814
2814
+
);
2815
2815
+
})}
2816
2816
+
</div>
2817
2817
+
)}
2818
2818
+
</div>
2418
2819
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
2419
2820
<div className="space-y-2">
2420
2420
-
<Label htmlFor="groupName">Folder / Group Name</Label>
2821
2821
+
<Label htmlFor="add-account-group-name">Folder / Group Name (Optional)</Label>
2421
2822
<Input
2422
2422
-
id="groupName"
2823
2823
+
id="add-account-group-name"
2423
2824
value={newMapping.groupName}
2424
2825
onChange={(event) => {
2425
2425
-
setNewMapping((prev) => ({ ...prev, groupName: event.target.value }));
2826
2826
+
setNewMapping((previous) => ({ ...previous, groupName: event.target.value }));
2426
2827
}}
2427
2828
placeholder="Gaming, News, Sports..."
2428
2829
/>
2429
2830
</div>
2430
2831
<div className="space-y-2">
2431
2431
-
<Label htmlFor="groupEmoji">Emoji</Label>
2832
2832
+
<Label htmlFor="add-account-group-emoji">Emoji</Label>
2432
2833
<Input
2433
2433
-
id="groupEmoji"
2834
2834
+
id="add-account-group-emoji"
2434
2835
value={newMapping.groupEmoji}
2435
2836
onChange={(event) => {
2436
2436
-
setNewMapping((prev) => ({ ...prev, groupEmoji: event.target.value }));
2837
2837
+
setNewMapping((previous) => ({ ...previous, groupEmoji: event.target.value }));
2437
2838
}}
2438
2438
-
placeholder="📁"
2439
2839
maxLength={8}
2840
2840
+
placeholder={DEFAULT_GROUP_EMOJI}
2440
2841
/>
2441
2842
</div>
2442
2843
</div>
2844
2844
+
</div>
2845
2845
+
) : null}
2846
2846
+
2847
2847
+
{addAccountStep === 2 ? (
2848
2848
+
<div className="space-y-4 animate-fade-in">
2849
2849
+
<div className="space-y-1">
2850
2850
+
<p className="text-sm font-semibold">Choose Twitter sources</p>
2851
2851
+
<p className="text-xs text-muted-foreground">
2852
2852
+
Add one or many usernames. Press Enter or comma to add quickly.
2853
2853
+
</p>
2854
2854
+
</div>
2443
2855
<div className="space-y-2">
2444
2444
-
<Label htmlFor="twitterUsernames">Twitter Usernames</Label>
2856
2856
+
<Label htmlFor="add-account-twitter-usernames">Twitter Usernames</Label>
2445
2857
<div className="flex gap-2">
2446
2858
<Input
2447
2447
-
id="twitterUsernames"
2859
2859
+
id="add-account-twitter-usernames"
2448
2860
value={newTwitterInput}
2449
2861
onChange={(event) => {
2450
2862
setNewTwitterInput(event.target.value);
···
2455
2867
addNewTwitterUsername();
2456
2868
}
2457
2869
}}
2458
2458
-
placeholder="@accountname (press Enter to add)"
2870
2870
+
placeholder="@accountname"
2459
2871
/>
2460
2872
<Button
2461
2873
variant="outline"
2462
2462
-
size="sm"
2463
2874
type="button"
2464
2875
disabled={normalizeTwitterUsername(newTwitterInput).length === 0}
2465
2876
onClick={addNewTwitterUsername}
···
2467
2878
Add
2468
2879
</Button>
2469
2880
</div>
2470
2470
-
<p className="text-xs text-muted-foreground">Press Enter or comma to add multiple handles quickly.</p>
2471
2471
-
<div className="flex min-h-7 flex-wrap gap-2">
2472
2472
-
{newTwitterUsers.map((username) => (
2881
2881
+
</div>
2882
2882
+
<div className="flex min-h-7 flex-wrap gap-2">
2883
2883
+
{newTwitterUsers.length === 0 ? (
2884
2884
+
<p className="text-xs text-muted-foreground">No source usernames added yet.</p>
2885
2885
+
) : (
2886
2886
+
newTwitterUsers.map((username) => (
2473
2887
<Badge key={`new-${username}`} variant="secondary" className="gap-1 pr-1">
2474
2888
@{username}
2475
2889
<button
···
2481
2895
×
2482
2896
</button>
2483
2897
</Badge>
2484
2484
-
))}
2485
2485
-
</div>
2898
2898
+
))
2899
2899
+
)}
2900
2900
+
</div>
2901
2901
+
</div>
2902
2902
+
) : null}
2903
2903
+
2904
2904
+
{addAccountStep === 3 ? (
2905
2905
+
<div className="space-y-4 animate-fade-in">
2906
2906
+
<div className="space-y-1">
2907
2907
+
<p className="text-sm font-semibold">Target Bluesky account</p>
2908
2908
+
<p className="text-xs text-muted-foreground">
2909
2909
+
Use an app password for the destination account.
2910
2910
+
</p>
2486
2911
</div>
2487
2912
<div className="space-y-2">
2488
2488
-
<Label htmlFor="bskyIdentifier">Bluesky Identifier</Label>
2913
2913
+
<Label htmlFor="add-account-bsky-identifier">Bluesky Identifier</Label>
2489
2914
<Input
2490
2490
-
id="bskyIdentifier"
2915
2915
+
id="add-account-bsky-identifier"
2491
2916
value={newMapping.bskyIdentifier}
2492
2917
onChange={(event) => {
2493
2493
-
setNewMapping((prev) => ({ ...prev, bskyIdentifier: event.target.value }));
2918
2918
+
setNewMapping((previous) => ({ ...previous, bskyIdentifier: event.target.value }));
2494
2919
}}
2495
2495
-
required
2920
2920
+
placeholder="example.bsky.social"
2496
2921
/>
2497
2922
</div>
2498
2923
<div className="space-y-2">
2499
2499
-
<Label htmlFor="bskyPassword">Bluesky App Password</Label>
2924
2924
+
<Label htmlFor="add-account-bsky-password">Bluesky App Password</Label>
2500
2925
<Input
2501
2501
-
id="bskyPassword"
2926
2926
+
id="add-account-bsky-password"
2502
2927
type="password"
2503
2928
value={newMapping.bskyPassword}
2504
2929
onChange={(event) => {
2505
2505
-
setNewMapping((prev) => ({ ...prev, bskyPassword: event.target.value }));
2930
2930
+
setNewMapping((previous) => ({ ...previous, bskyPassword: event.target.value }));
2506
2931
}}
2507
2507
-
required
2508
2932
/>
2509
2933
</div>
2510
2934
<div className="space-y-2">
2511
2511
-
<Label htmlFor="bskyServiceUrl">Bluesky Service URL</Label>
2935
2935
+
<Label htmlFor="add-account-bsky-url">Bluesky Service URL</Label>
2512
2936
<Input
2513
2513
-
id="bskyServiceUrl"
2937
2937
+
id="add-account-bsky-url"
2514
2938
value={newMapping.bskyServiceUrl}
2515
2939
onChange={(event) => {
2516
2516
-
setNewMapping((prev) => ({ ...prev, bskyServiceUrl: event.target.value }));
2940
2940
+
setNewMapping((previous) => ({ ...previous, bskyServiceUrl: event.target.value }));
2517
2941
}}
2518
2942
placeholder="https://bsky.social"
2519
2943
/>
2520
2944
</div>
2945
2945
+
</div>
2946
2946
+
) : null}
2521
2947
2522
2522
-
<Button className="w-full" size="sm" type="submit" disabled={isBusy}>
2523
2523
-
<Plus className="mr-2 h-4 w-4" />
2524
2524
-
Add Mapping
2525
2525
-
</Button>
2526
2526
-
</form>
2527
2527
-
</CardContent>
2528
2528
-
</Card>
2948
2948
+
{addAccountStep === 4 ? (
2949
2949
+
<div className="space-y-4 animate-fade-in">
2950
2950
+
<div className="space-y-1">
2951
2951
+
<p className="text-sm font-semibold">Review and create</p>
2952
2952
+
<p className="text-xs text-muted-foreground">Confirm details before saving this mapping.</p>
2953
2953
+
</div>
2954
2954
+
<div className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm">
2955
2955
+
<p>
2956
2956
+
<span className="font-medium">Owner:</span> {newMapping.owner || '--'}
2957
2957
+
</p>
2958
2958
+
<p>
2959
2959
+
<span className="font-medium">Twitter Sources:</span>{' '}
2960
2960
+
{newTwitterUsers.length > 0 ? newTwitterUsers.map((username) => `@${username}`).join(', ') : '--'}
2961
2961
+
</p>
2962
2962
+
<p>
2963
2963
+
<span className="font-medium">Bluesky Target:</span> {newMapping.bskyIdentifier || '--'}
2964
2964
+
</p>
2965
2965
+
<p>
2966
2966
+
<span className="font-medium">Folder:</span>{' '}
2967
2967
+
{newMapping.groupName.trim()
2968
2968
+
? `${newMapping.groupEmoji.trim() || DEFAULT_GROUP_EMOJI} ${newMapping.groupName.trim()}`
2969
2969
+
: `${DEFAULT_GROUP_EMOJI} ${DEFAULT_GROUP_NAME}`}
2970
2970
+
</p>
2971
2971
+
</div>
2972
2972
+
</div>
2973
2973
+
) : null}
2974
2974
+
</div>
2529
2975
2530
2530
-
<Card className="animate-slide-up">
2531
2531
-
<CardHeader>
2532
2532
-
<CardTitle>Data Management</CardTitle>
2533
2533
-
<CardDescription>Export/import account and provider config without login credentials.</CardDescription>
2534
2534
-
</CardHeader>
2535
2535
-
<CardContent className="space-y-3">
2536
2536
-
<Button className="w-full" variant="outline" onClick={handleExportConfig}>
2537
2537
-
<Download className="mr-2 h-4 w-4" />
2538
2538
-
Export configuration
2976
2976
+
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-5 py-4">
2977
2977
+
<Button variant="outline" onClick={retreatAddAccountStep} disabled={addAccountStep === 1 || isBusy}>
2978
2978
+
<ChevronLeft className="mr-2 h-4 w-4" />
2979
2979
+
Back
2980
2980
+
</Button>
2981
2981
+
{addAccountStep < ADD_ACCOUNT_STEP_COUNT ? (
2982
2982
+
<Button onClick={advanceAddAccountStep}>
2983
2983
+
Next
2984
2984
+
<ChevronRight className="ml-2 h-4 w-4" />
2539
2985
</Button>
2540
2540
-
<input
2541
2541
-
ref={importInputRef}
2542
2542
-
className="hidden"
2543
2543
-
type="file"
2544
2544
-
accept="application/json,.json"
2545
2545
-
onChange={(event) => {
2546
2546
-
void handleImportConfig(event);
2547
2547
-
}}
2548
2548
-
/>
2549
2549
-
<Button
2550
2550
-
className="w-full"
2551
2551
-
variant="outline"
2552
2552
-
onClick={() => {
2553
2553
-
importInputRef.current?.click();
2554
2554
-
}}
2555
2555
-
>
2556
2556
-
<Upload className="mr-2 h-4 w-4" />
2557
2557
-
Import configuration
2986
2986
+
) : (
2987
2987
+
<Button onClick={() => void submitNewMapping()} disabled={isBusy}>
2988
2988
+
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
2989
2989
+
Create Account
2558
2990
</Button>
2559
2559
-
<p className="text-xs text-muted-foreground">
2560
2560
-
Imports preserve dashboard users and passwords while replacing mappings, provider keys, and scheduler
2561
2561
-
settings.
2562
2562
-
</p>
2563
2563
-
</CardContent>
2564
2564
-
</Card>
2565
2565
-
</section>
2566
2566
-
) : null
2991
2991
+
)}
2992
2992
+
</div>
2993
2993
+
</aside>
2994
2994
+
</div>
2567
2995
) : null}
2568
2996
2569
2997
{editingMapping ? (