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.

feat: add settings collapses and account onboarding sheet

jack f7b5720c 955ca3ba

+620 -192
+620 -192
web/src/App.tsx
··· 3 3 AlertTriangle, 4 4 ArrowUpRight, 5 5 Bot, 6 + ChevronLeft, 6 7 ChevronDown, 8 + ChevronRight, 7 9 Clock3, 8 10 Download, 9 11 Folder, ··· 27 29 Upload, 28 30 UserRound, 29 31 Users, 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 + 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 + const TAB_PATHS: Record<DashboardTab, string> = { 221 + overview: '/', 222 + accounts: '/accounts', 223 + posts: '/posts', 224 + activity: '/activity', 225 + settings: '/settings', 226 + }; 227 + const ADD_ACCOUNT_STEP_COUNT = 4; 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 + function normalizePath(pathname: string): string { 314 + const normalized = pathname.replace(/\/+$/, ''); 315 + return normalized.length === 0 ? '/' : normalized; 316 + } 317 + 318 + function getTabFromPath(pathname: string): DashboardTab | null { 319 + const normalized = normalizePath(pathname); 320 + const entry = (Object.entries(TAB_PATHS) as Array<[DashboardTab, string]>).find(([, path]) => path === normalized); 321 + return entry ? entry[0] : null; 322 + } 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 + const fromPath = getTabFromPath(window.location.pathname); 431 + if (fromPath) { 432 + return fromPath; 433 + } 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 + const [isAddAccountSheetOpen, setIsAddAccountSheetOpen] = useState(false); 459 + const [addAccountStep, setAddAccountStep] = useState(1); 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 + setIsAddAccountSheetOpen(false); 510 + setAddAccountStep(1); 511 + setSettingsSectionOverrides({}); 477 512 setAuthView('login'); 478 513 }, []); 479 514 ··· 622 657 }, [activeTab]); 623 658 624 659 useEffect(() => { 660 + const expectedPath = TAB_PATHS[activeTab]; 661 + const currentPath = normalizePath(window.location.pathname); 662 + if (currentPath !== expectedPath) { 663 + window.history.pushState({ tab: activeTab }, '', expectedPath); 664 + } 665 + }, [activeTab]); 666 + 667 + useEffect(() => { 668 + const onPopState = () => { 669 + const tabFromPath = getTabFromPath(window.location.pathname); 670 + if (tabFromPath) { 671 + setActiveTab(tabFromPath); 672 + } else { 673 + setActiveTab('overview'); 674 + } 675 + }; 676 + 677 + window.addEventListener('popstate', onPopState); 678 + return () => { 679 + window.removeEventListener('popstate', onPopState); 680 + }; 681 + }, []); 682 + 683 + useEffect(() => { 625 684 localStorage.setItem('accounts-collapsed-groups', JSON.stringify(collapsedGroupKeys)); 626 685 }, [collapsedGroupKeys]); 627 686 ··· 714 773 } 715 774 }; 716 775 }, []); 776 + 777 + useEffect(() => { 778 + if (!isAddAccountSheetOpen) { 779 + return; 780 + } 781 + 782 + const onKeyDown = (event: KeyboardEvent) => { 783 + if (event.key === 'Escape') { 784 + closeAddAccountSheet(); 785 + } 786 + }; 787 + 788 + window.addEventListener('keydown', onKeyDown); 789 + return () => { 790 + window.removeEventListener('keydown', onKeyDown); 791 + }; 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 + const reusableGroupOptions = useMemo( 872 + () => groupOptions.filter((group) => group.key !== DEFAULT_GROUP_KEY), 873 + [groupOptions], 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 + const twitterConfigured = Boolean(twitterConfig.authToken && twitterConfig.ct0); 941 + const aiConfigured = Boolean(aiConfig.apiKey); 942 + const sectionDefaultExpanded = useMemo<Record<SettingsSection, boolean>>( 943 + () => ({ 944 + twitter: !twitterConfigured, 945 + ai: !aiConfigured, 946 + data: false, 947 + }), 948 + [aiConfigured, twitterConfigured], 949 + ); 950 + const isSettingsSectionExpanded = useCallback( 951 + (section: SettingsSection) => settingsSectionOverrides[section] ?? sectionDefaultExpanded[section], 952 + [sectionDefaultExpanded, settingsSectionOverrides], 953 + ); 954 + const toggleSettingsSection = (section: SettingsSection) => { 955 + setSettingsSectionOverrides((previous) => ({ 956 + ...previous, 957 + [section]: !(previous[section] ?? sectionDefaultExpanded[section]), 958 + })); 959 + }; 860 960 861 961 useEffect(() => { 862 962 if (postsGroupFilter !== 'all' && !groupOptions.some((group) => group.key === postsGroupFilter)) { ··· 1165 1265 } 1166 1266 }; 1167 1267 1168 - const handleAddMapping = async (event: React.FormEvent<HTMLFormElement>) => { 1169 - event.preventDefault(); 1268 + const resetAddAccountDraft = () => { 1269 + setNewMapping(defaultMappingForm()); 1270 + setNewTwitterUsers([]); 1271 + setNewTwitterInput(''); 1272 + setAddAccountStep(1); 1273 + }; 1274 + 1275 + const openAddAccountSheet = () => { 1276 + resetAddAccountDraft(); 1277 + setIsAddAccountSheetOpen(true); 1278 + }; 1279 + 1280 + const closeAddAccountSheet = () => { 1281 + setIsAddAccountSheetOpen(false); 1282 + resetAddAccountDraft(); 1283 + }; 1284 + 1285 + const applyGroupPresetToNewMapping = (groupKey: string) => { 1286 + const group = groupOptionsByKey.get(groupKey); 1287 + if (!group || group.key === DEFAULT_GROUP_KEY) { 1288 + return; 1289 + } 1290 + setNewMapping((previous) => ({ 1291 + ...previous, 1292 + groupName: group.name, 1293 + groupEmoji: group.emoji, 1294 + })); 1295 + }; 1296 + 1297 + const submitNewMapping = async () => { 1170 1298 if (!authHeaders) { 1171 1299 return; 1172 1300 } ··· 1196 1324 setNewMapping(defaultMappingForm()); 1197 1325 setNewTwitterUsers([]); 1198 1326 setNewTwitterInput(''); 1327 + setIsAddAccountSheetOpen(false); 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 + const advanceAddAccountStep = () => { 1339 + if (addAccountStep === 1) { 1340 + if (!newMapping.owner.trim()) { 1341 + showNotice('error', 'Owner is required.'); 1342 + return; 1343 + } 1344 + setAddAccountStep(2); 1345 + return; 1346 + } 1347 + 1348 + if (addAccountStep === 2) { 1349 + if (newTwitterUsers.length === 0) { 1350 + showNotice('error', 'Add at least one Twitter username.'); 1351 + return; 1352 + } 1353 + setAddAccountStep(3); 1354 + return; 1355 + } 1356 + 1357 + if (addAccountStep === 3) { 1358 + if (!newMapping.bskyIdentifier.trim() || !newMapping.bskyPassword.trim()) { 1359 + showNotice('error', 'Bluesky identifier and app password are required.'); 1360 + return; 1361 + } 1362 + setAddAccountStep(4); 1363 + } 1364 + }; 1365 + 1366 + const retreatAddAccountStep = () => { 1367 + setAddAccountStep((previous) => Math.max(1, previous - 1)); 1368 + }; 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 + {isAdmin ? ( 1612 + <Button 1613 + size="sm" 1614 + variant="outline" 1615 + onClick={() => { 1616 + setActiveTab('settings'); 1617 + openAddAccountSheet(); 1618 + }} 1619 + > 1620 + <Plus className="mr-2 h-4 w-4" /> 1621 + Add account 1622 + </Button> 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 - <Badge variant="outline">{mappings.length} configured</Badge> 1806 + <div className="flex items-center gap-2"> 1807 + {isAdmin ? ( 1808 + <Button size="sm" variant="outline" onClick={openAddAccountSheet}> 1809 + <Plus className="mr-2 h-4 w-4" /> 1810 + Add account 1811 + </Button> 1812 + ) : null} 1813 + <Badge variant="outline">{mappings.length} configured</Badge> 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 - No mappings yet. Add one from the settings panel. 1854 + No mappings yet. 1855 + {isAdmin ? ( 1856 + <div className="mt-3"> 1857 + <Button size="sm" variant="outline" onClick={openAddAccountSheet}> 1858 + <Plus className="mr-2 h-4 w-4" /> 1859 + Create your first account 1860 + </Button> 1861 + </div> 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 - <CardDescription>Credentials, provider setup, and account onboarding.</CardDescription> 2467 + <CardDescription>Configured sections stay collapsed so adding accounts is one click.</CardDescription> 2277 2468 </CardHeader> 2278 - <CardContent className="space-y-8"> 2279 - <form className="space-y-3" onSubmit={handleSaveTwitterConfig}> 2280 - <div className="flex items-center justify-between"> 2281 - <h3 className="text-sm font-semibold">Twitter Credentials</h3> 2282 - <Badge variant={twitterConfig.authToken && twitterConfig.ct0 ? 'success' : 'outline'}> 2283 - {twitterConfig.authToken && twitterConfig.ct0 ? 'Configured' : 'Missing'} 2284 - </Badge> 2285 - </div> 2286 - <div className="space-y-2"> 2287 - <Label htmlFor="authToken">Primary Auth Token</Label> 2288 - <Input 2289 - id="authToken" 2290 - value={twitterConfig.authToken} 2291 - onChange={(event) => { 2292 - setTwitterConfig((prev) => ({ ...prev, authToken: event.target.value })); 2293 - }} 2294 - required 2295 - /> 2296 - </div> 2297 - <div className="space-y-2"> 2298 - <Label htmlFor="ct0">Primary CT0</Label> 2299 - <Input 2300 - id="ct0" 2301 - value={twitterConfig.ct0} 2302 - onChange={(event) => { 2303 - setTwitterConfig((prev) => ({ ...prev, ct0: event.target.value })); 2304 - }} 2305 - required 2306 - /> 2307 - </div> 2308 - 2309 - <div className="grid gap-3 sm:grid-cols-2"> 2310 - <div className="space-y-2"> 2311 - <Label htmlFor="backupAuthToken">Backup Auth Token</Label> 2312 - <Input 2313 - id="backupAuthToken" 2314 - value={twitterConfig.backupAuthToken || ''} 2315 - onChange={(event) => { 2316 - setTwitterConfig((prev) => ({ ...prev, backupAuthToken: event.target.value })); 2317 - }} 2318 - /> 2319 - </div> 2320 - <div className="space-y-2"> 2321 - <Label htmlFor="backupCt0">Backup CT0</Label> 2322 - <Input 2323 - id="backupCt0" 2324 - value={twitterConfig.backupCt0 || ''} 2325 - onChange={(event) => { 2326 - setTwitterConfig((prev) => ({ ...prev, backupCt0: event.target.value })); 2327 - }} 2328 - /> 2329 - </div> 2330 - </div> 2469 + <CardContent className="pt-0"> 2470 + <Button className="w-full sm:w-auto" onClick={openAddAccountSheet}> 2471 + <Plus className="mr-2 h-4 w-4" /> 2472 + Add Account 2473 + </Button> 2474 + </CardContent> 2475 + </Card> 2331 2476 2332 - <Button className="w-full" size="sm" type="submit" disabled={isBusy}> 2333 - <Save className="mr-2 h-4 w-4" /> 2334 - Save Twitter Credentials 2335 - </Button> 2336 - </form> 2337 - 2338 - <form className="space-y-3 border-t border-border pt-6" onSubmit={handleSaveAiConfig}> 2339 - <div className="flex items-center justify-between"> 2340 - <h3 className="text-sm font-semibold">AI Settings</h3> 2341 - <Badge variant={aiConfig.apiKey ? 'success' : 'outline'}> 2342 - {aiConfig.apiKey ? 'Configured' : 'Optional'} 2343 - </Badge> 2344 - </div> 2345 - <div className="space-y-2"> 2346 - <Label htmlFor="provider">Provider</Label> 2347 - <select 2348 - className={selectClassName} 2349 - id="provider" 2350 - value={aiConfig.provider} 2351 - onChange={(event) => { 2352 - setAiConfig((prev) => ({ ...prev, provider: event.target.value as AIConfig['provider'] })); 2353 - }} 2354 - > 2355 - <option value="gemini">Google Gemini</option> 2356 - <option value="openai">OpenAI / OpenRouter</option> 2357 - <option value="anthropic">Anthropic</option> 2358 - <option value="custom">Custom</option> 2359 - </select> 2360 - </div> 2361 - <div className="space-y-2"> 2362 - <Label htmlFor="apiKey">API Key</Label> 2363 - <Input 2364 - id="apiKey" 2365 - type="password" 2366 - value={aiConfig.apiKey || ''} 2367 - onChange={(event) => { 2368 - setAiConfig((prev) => ({ ...prev, apiKey: event.target.value })); 2369 - }} 2370 - /> 2371 - </div> 2372 - {aiConfig.provider !== 'gemini' ? ( 2373 - <> 2477 + <Card className="animate-slide-up"> 2478 + <button 2479 + className="flex w-full items-center justify-between px-5 py-4 text-left" 2480 + onClick={() => toggleSettingsSection('twitter')} 2481 + type="button" 2482 + > 2483 + <div> 2484 + <p className="text-sm font-semibold">Twitter Credentials</p> 2485 + <p className="text-xs text-muted-foreground">Primary and backup cookie values.</p> 2486 + </div> 2487 + <div className="flex items-center gap-2"> 2488 + <Badge variant={twitterConfigured ? 'success' : 'outline'}> 2489 + {twitterConfigured ? 'Configured' : 'Missing'} 2490 + </Badge> 2491 + <ChevronDown 2492 + className={cn( 2493 + 'h-4 w-4 transition-transform duration-200', 2494 + isSettingsSectionExpanded('twitter') ? 'rotate-0' : '-rotate-90', 2495 + )} 2496 + /> 2497 + </div> 2498 + </button> 2499 + <div 2500 + className={cn( 2501 + 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 2502 + isSettingsSectionExpanded('twitter') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 2503 + )} 2504 + > 2505 + <div className="min-h-0 overflow-hidden"> 2506 + <CardContent className="space-y-3 border-t border-border/70 pt-4"> 2507 + <form className="space-y-3" onSubmit={handleSaveTwitterConfig}> 2374 2508 <div className="space-y-2"> 2375 - <Label htmlFor="model">Model ID</Label> 2509 + <Label htmlFor="authToken">Primary Auth Token</Label> 2376 2510 <Input 2377 - id="model" 2378 - value={aiConfig.model || ''} 2511 + id="authToken" 2512 + value={twitterConfig.authToken} 2379 2513 onChange={(event) => { 2380 - setAiConfig((prev) => ({ ...prev, model: event.target.value })); 2514 + setTwitterConfig((prev) => ({ ...prev, authToken: event.target.value })); 2381 2515 }} 2382 - placeholder="gpt-4o" 2516 + required 2383 2517 /> 2384 2518 </div> 2385 2519 <div className="space-y-2"> 2386 - <Label htmlFor="baseUrl">Base URL</Label> 2520 + <Label htmlFor="ct0">Primary CT0</Label> 2387 2521 <Input 2388 - id="baseUrl" 2389 - value={aiConfig.baseUrl || ''} 2522 + id="ct0" 2523 + value={twitterConfig.ct0} 2390 2524 onChange={(event) => { 2391 - setAiConfig((prev) => ({ ...prev, baseUrl: event.target.value })); 2525 + setTwitterConfig((prev) => ({ ...prev, ct0: event.target.value })); 2392 2526 }} 2393 - placeholder="https://api.example.com/v1" 2527 + required 2394 2528 /> 2395 2529 </div> 2396 - </> 2397 - ) : null} 2530 + 2531 + <div className="grid gap-3 sm:grid-cols-2"> 2532 + <div className="space-y-2"> 2533 + <Label htmlFor="backupAuthToken">Backup Auth Token</Label> 2534 + <Input 2535 + id="backupAuthToken" 2536 + value={twitterConfig.backupAuthToken || ''} 2537 + onChange={(event) => { 2538 + setTwitterConfig((prev) => ({ ...prev, backupAuthToken: event.target.value })); 2539 + }} 2540 + /> 2541 + </div> 2542 + <div className="space-y-2"> 2543 + <Label htmlFor="backupCt0">Backup CT0</Label> 2544 + <Input 2545 + id="backupCt0" 2546 + value={twitterConfig.backupCt0 || ''} 2547 + onChange={(event) => { 2548 + setTwitterConfig((prev) => ({ ...prev, backupCt0: event.target.value })); 2549 + }} 2550 + /> 2551 + </div> 2552 + </div> 2398 2553 2399 - <Button className="w-full" size="sm" type="submit" disabled={isBusy}> 2400 - <Bot className="mr-2 h-4 w-4" /> 2401 - Save AI Settings 2402 - </Button> 2403 - </form> 2554 + <Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}> 2555 + <Save className="mr-2 h-4 w-4" /> 2556 + Save Twitter Credentials 2557 + </Button> 2558 + </form> 2559 + </CardContent> 2560 + </div> 2561 + </div> 2562 + </Card> 2404 2563 2405 - <form className="space-y-3 border-t border-border pt-6" onSubmit={handleAddMapping}> 2406 - <h3 className="text-sm font-semibold">Add Account Mapping</h3> 2564 + <Card className="animate-slide-up"> 2565 + <button 2566 + className="flex w-full items-center justify-between px-5 py-4 text-left" 2567 + onClick={() => toggleSettingsSection('ai')} 2568 + type="button" 2569 + > 2570 + <div> 2571 + <p className="text-sm font-semibold">AI Settings</p> 2572 + <p className="text-xs text-muted-foreground">Optional enrichment and rewrite provider config.</p> 2573 + </div> 2574 + <div className="flex items-center gap-2"> 2575 + <Badge variant={aiConfigured ? 'success' : 'outline'}> 2576 + {aiConfigured ? 'Configured' : 'Optional'} 2577 + </Badge> 2578 + <ChevronDown 2579 + className={cn( 2580 + 'h-4 w-4 transition-transform duration-200', 2581 + isSettingsSectionExpanded('ai') ? 'rotate-0' : '-rotate-90', 2582 + )} 2583 + /> 2584 + </div> 2585 + </button> 2586 + <div 2587 + className={cn( 2588 + 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 2589 + isSettingsSectionExpanded('ai') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 2590 + )} 2591 + > 2592 + <div className="min-h-0 overflow-hidden"> 2593 + <CardContent className="space-y-3 border-t border-border/70 pt-4"> 2594 + <form className="space-y-3" onSubmit={handleSaveAiConfig}> 2595 + <div className="space-y-2"> 2596 + <Label htmlFor="provider">Provider</Label> 2597 + <select 2598 + className={selectClassName} 2599 + id="provider" 2600 + value={aiConfig.provider} 2601 + onChange={(event) => { 2602 + setAiConfig((prev) => ({ ...prev, provider: event.target.value as AIConfig['provider'] })); 2603 + }} 2604 + > 2605 + <option value="gemini">Google Gemini</option> 2606 + <option value="openai">OpenAI / OpenRouter</option> 2607 + <option value="anthropic">Anthropic</option> 2608 + <option value="custom">Custom</option> 2609 + </select> 2610 + </div> 2611 + <div className="space-y-2"> 2612 + <Label htmlFor="apiKey">API Key</Label> 2613 + <Input 2614 + id="apiKey" 2615 + type="password" 2616 + value={aiConfig.apiKey || ''} 2617 + onChange={(event) => { 2618 + setAiConfig((prev) => ({ ...prev, apiKey: event.target.value })); 2619 + }} 2620 + /> 2621 + </div> 2622 + {aiConfig.provider !== 'gemini' ? ( 2623 + <> 2624 + <div className="space-y-2"> 2625 + <Label htmlFor="model">Model ID</Label> 2626 + <Input 2627 + id="model" 2628 + value={aiConfig.model || ''} 2629 + onChange={(event) => { 2630 + setAiConfig((prev) => ({ ...prev, model: event.target.value })); 2631 + }} 2632 + placeholder="gpt-4o" 2633 + /> 2634 + </div> 2635 + <div className="space-y-2"> 2636 + <Label htmlFor="baseUrl">Base URL</Label> 2637 + <Input 2638 + id="baseUrl" 2639 + value={aiConfig.baseUrl || ''} 2640 + onChange={(event) => { 2641 + setAiConfig((prev) => ({ ...prev, baseUrl: event.target.value })); 2642 + }} 2643 + placeholder="https://api.example.com/v1" 2644 + /> 2645 + </div> 2646 + </> 2647 + ) : null} 2648 + 2649 + <Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}> 2650 + <Bot className="mr-2 h-4 w-4" /> 2651 + Save AI Settings 2652 + </Button> 2653 + </form> 2654 + </CardContent> 2655 + </div> 2656 + </div> 2657 + </Card> 2658 + 2659 + <Card className="animate-slide-up"> 2660 + <button 2661 + className="flex w-full items-center justify-between px-5 py-4 text-left" 2662 + onClick={() => toggleSettingsSection('data')} 2663 + type="button" 2664 + > 2665 + <div> 2666 + <p className="text-sm font-semibold">Data Management</p> 2667 + <p className="text-xs text-muted-foreground">Export/import mappings and provider settings.</p> 2668 + </div> 2669 + <ChevronDown 2670 + className={cn( 2671 + 'h-4 w-4 transition-transform duration-200', 2672 + isSettingsSectionExpanded('data') ? 'rotate-0' : '-rotate-90', 2673 + )} 2674 + /> 2675 + </button> 2676 + <div 2677 + className={cn( 2678 + 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 2679 + isSettingsSectionExpanded('data') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 2680 + )} 2681 + > 2682 + <div className="min-h-0 overflow-hidden"> 2683 + <CardContent className="space-y-3 border-t border-border/70 pt-4"> 2684 + <Button className="w-full sm:w-auto" variant="outline" onClick={handleExportConfig}> 2685 + <Download className="mr-2 h-4 w-4" /> 2686 + Export configuration 2687 + </Button> 2688 + <input 2689 + ref={importInputRef} 2690 + className="hidden" 2691 + type="file" 2692 + accept="application/json,.json" 2693 + onChange={(event) => { 2694 + void handleImportConfig(event); 2695 + }} 2696 + /> 2697 + <Button 2698 + className="w-full sm:w-auto" 2699 + variant="outline" 2700 + onClick={() => { 2701 + importInputRef.current?.click(); 2702 + }} 2703 + > 2704 + <Upload className="mr-2 h-4 w-4" /> 2705 + Import configuration 2706 + </Button> 2707 + <p className="text-xs text-muted-foreground"> 2708 + Imports preserve dashboard users and passwords while replacing mappings, provider keys, and 2709 + scheduler settings. 2710 + </p> 2711 + </CardContent> 2712 + </div> 2713 + </div> 2714 + </Card> 2715 + </section> 2716 + ) : null 2717 + ) : null} 2718 + 2719 + {isAddAccountSheetOpen ? ( 2720 + <div 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 + onClick={closeAddAccountSheet} 2723 + > 2724 + <aside 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 + onClick={(event) => event.stopPropagation()} 2727 + > 2728 + <div className="flex items-start justify-between border-b border-border/70 px-5 py-4"> 2729 + <div> 2730 + <p className="text-xs uppercase tracking-[0.15em] text-muted-foreground">Add Account</p> 2731 + <h2 className="text-lg font-semibold">Create Crosspost Mapping</h2> 2732 + </div> 2733 + <Button variant="ghost" size="icon" onClick={closeAddAccountSheet} aria-label="Close add account flow"> 2734 + <X className="h-4 w-4" /> 2735 + </Button> 2736 + </div> 2737 + 2738 + <div className="border-b border-border/70 px-5 py-3"> 2739 + <div className="flex items-center gap-2"> 2740 + {ADD_ACCOUNT_STEPS.map((label, index) => { 2741 + const step = index + 1; 2742 + const active = step === addAccountStep; 2743 + const complete = step < addAccountStep; 2744 + return ( 2745 + <div key={label} className="flex min-w-0 flex-1 items-center gap-2"> 2746 + <div 2747 + className={cn( 2748 + 'flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold', 2749 + complete && 'border-foreground bg-foreground text-background', 2750 + active && 'border-foreground text-foreground', 2751 + !active && !complete && 'border-border text-muted-foreground', 2752 + )} 2753 + > 2754 + {step} 2755 + </div> 2756 + <span 2757 + className={cn( 2758 + 'truncate text-xs', 2759 + active ? 'text-foreground' : complete ? 'text-foreground/90' : 'text-muted-foreground', 2760 + )} 2761 + > 2762 + {label} 2763 + </span> 2764 + {step < ADD_ACCOUNT_STEP_COUNT ? <div className="h-px flex-1 bg-border/70" /> : null} 2765 + </div> 2766 + ); 2767 + })} 2768 + </div> 2769 + </div> 2770 + 2771 + <div className="flex-1 overflow-y-auto px-5 py-4"> 2772 + {addAccountStep === 1 ? ( 2773 + <div className="space-y-4 animate-fade-in"> 2774 + <div className="space-y-1"> 2775 + <p className="text-sm font-semibold">Who owns this mapping?</p> 2776 + <p className="text-xs text-muted-foreground">Set a label so account rows stay easy to scan.</p> 2777 + </div> 2407 2778 <div className="space-y-2"> 2408 - <Label htmlFor="owner">Owner</Label> 2779 + <Label htmlFor="add-account-owner">Owner</Label> 2409 2780 <Input 2410 - id="owner" 2781 + id="add-account-owner" 2411 2782 value={newMapping.owner} 2412 2783 onChange={(event) => { 2413 - setNewMapping((prev) => ({ ...prev, owner: event.target.value })); 2784 + setNewMapping((previous) => ({ ...previous, owner: event.target.value })); 2414 2785 }} 2415 - required 2786 + placeholder="jack" 2416 2787 /> 2417 2788 </div> 2789 + <div className="space-y-2"> 2790 + <Label>Use Existing Folder (Optional)</Label> 2791 + {reusableGroupOptions.length === 0 ? ( 2792 + <p className="rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground"> 2793 + No folders yet. Create one below or from the Accounts tab. 2794 + </p> 2795 + ) : ( 2796 + <div className="flex flex-wrap gap-2"> 2797 + {reusableGroupOptions.map((group) => { 2798 + const selected = getGroupKey(newMapping.groupName) === group.key; 2799 + return ( 2800 + <button 2801 + key={`preset-group-${group.key}`} 2802 + className={cn( 2803 + 'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs transition-colors', 2804 + selected 2805 + ? 'border-foreground bg-foreground text-background' 2806 + : 'border-border bg-background text-foreground hover:bg-muted', 2807 + )} 2808 + onClick={() => applyGroupPresetToNewMapping(group.key)} 2809 + type="button" 2810 + > 2811 + <span>{group.emoji}</span> 2812 + <span>{group.name}</span> 2813 + </button> 2814 + ); 2815 + })} 2816 + </div> 2817 + )} 2818 + </div> 2418 2819 <div className="grid gap-3 sm:grid-cols-[1fr_auto]"> 2419 2820 <div className="space-y-2"> 2420 - <Label htmlFor="groupName">Folder / Group Name</Label> 2821 + <Label htmlFor="add-account-group-name">Folder / Group Name (Optional)</Label> 2421 2822 <Input 2422 - id="groupName" 2823 + id="add-account-group-name" 2423 2824 value={newMapping.groupName} 2424 2825 onChange={(event) => { 2425 - setNewMapping((prev) => ({ ...prev, groupName: event.target.value })); 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 - <Label htmlFor="groupEmoji">Emoji</Label> 2832 + <Label htmlFor="add-account-group-emoji">Emoji</Label> 2432 2833 <Input 2433 - id="groupEmoji" 2834 + id="add-account-group-emoji" 2434 2835 value={newMapping.groupEmoji} 2435 2836 onChange={(event) => { 2436 - setNewMapping((prev) => ({ ...prev, groupEmoji: event.target.value })); 2837 + setNewMapping((previous) => ({ ...previous, groupEmoji: event.target.value })); 2437 2838 }} 2438 - placeholder="📁" 2439 2839 maxLength={8} 2840 + placeholder={DEFAULT_GROUP_EMOJI} 2440 2841 /> 2441 2842 </div> 2442 2843 </div> 2844 + </div> 2845 + ) : null} 2846 + 2847 + {addAccountStep === 2 ? ( 2848 + <div className="space-y-4 animate-fade-in"> 2849 + <div className="space-y-1"> 2850 + <p className="text-sm font-semibold">Choose Twitter sources</p> 2851 + <p className="text-xs text-muted-foreground"> 2852 + Add one or many usernames. Press Enter or comma to add quickly. 2853 + </p> 2854 + </div> 2443 2855 <div className="space-y-2"> 2444 - <Label htmlFor="twitterUsernames">Twitter Usernames</Label> 2856 + <Label htmlFor="add-account-twitter-usernames">Twitter Usernames</Label> 2445 2857 <div className="flex gap-2"> 2446 2858 <Input 2447 - id="twitterUsernames" 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 - placeholder="@accountname (press Enter to add)" 2870 + placeholder="@accountname" 2459 2871 /> 2460 2872 <Button 2461 2873 variant="outline" 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 - <p className="text-xs text-muted-foreground">Press Enter or comma to add multiple handles quickly.</p> 2471 - <div className="flex min-h-7 flex-wrap gap-2"> 2472 - {newTwitterUsers.map((username) => ( 2881 + </div> 2882 + <div className="flex min-h-7 flex-wrap gap-2"> 2883 + {newTwitterUsers.length === 0 ? ( 2884 + <p className="text-xs text-muted-foreground">No source usernames added yet.</p> 2885 + ) : ( 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 - ))} 2485 - </div> 2898 + )) 2899 + )} 2900 + </div> 2901 + </div> 2902 + ) : null} 2903 + 2904 + {addAccountStep === 3 ? ( 2905 + <div className="space-y-4 animate-fade-in"> 2906 + <div className="space-y-1"> 2907 + <p className="text-sm font-semibold">Target Bluesky account</p> 2908 + <p className="text-xs text-muted-foreground"> 2909 + Use an app password for the destination account. 2910 + </p> 2486 2911 </div> 2487 2912 <div className="space-y-2"> 2488 - <Label htmlFor="bskyIdentifier">Bluesky Identifier</Label> 2913 + <Label htmlFor="add-account-bsky-identifier">Bluesky Identifier</Label> 2489 2914 <Input 2490 - id="bskyIdentifier" 2915 + id="add-account-bsky-identifier" 2491 2916 value={newMapping.bskyIdentifier} 2492 2917 onChange={(event) => { 2493 - setNewMapping((prev) => ({ ...prev, bskyIdentifier: event.target.value })); 2918 + setNewMapping((previous) => ({ ...previous, bskyIdentifier: event.target.value })); 2494 2919 }} 2495 - required 2920 + placeholder="example.bsky.social" 2496 2921 /> 2497 2922 </div> 2498 2923 <div className="space-y-2"> 2499 - <Label htmlFor="bskyPassword">Bluesky App Password</Label> 2924 + <Label htmlFor="add-account-bsky-password">Bluesky App Password</Label> 2500 2925 <Input 2501 - id="bskyPassword" 2926 + id="add-account-bsky-password" 2502 2927 type="password" 2503 2928 value={newMapping.bskyPassword} 2504 2929 onChange={(event) => { 2505 - setNewMapping((prev) => ({ ...prev, bskyPassword: event.target.value })); 2930 + setNewMapping((previous) => ({ ...previous, bskyPassword: event.target.value })); 2506 2931 }} 2507 - required 2508 2932 /> 2509 2933 </div> 2510 2934 <div className="space-y-2"> 2511 - <Label htmlFor="bskyServiceUrl">Bluesky Service URL</Label> 2935 + <Label htmlFor="add-account-bsky-url">Bluesky Service URL</Label> 2512 2936 <Input 2513 - id="bskyServiceUrl" 2937 + id="add-account-bsky-url" 2514 2938 value={newMapping.bskyServiceUrl} 2515 2939 onChange={(event) => { 2516 - setNewMapping((prev) => ({ ...prev, bskyServiceUrl: event.target.value })); 2940 + setNewMapping((previous) => ({ ...previous, bskyServiceUrl: event.target.value })); 2517 2941 }} 2518 2942 placeholder="https://bsky.social" 2519 2943 /> 2520 2944 </div> 2945 + </div> 2946 + ) : null} 2521 2947 2522 - <Button className="w-full" size="sm" type="submit" disabled={isBusy}> 2523 - <Plus className="mr-2 h-4 w-4" /> 2524 - Add Mapping 2525 - </Button> 2526 - </form> 2527 - </CardContent> 2528 - </Card> 2948 + {addAccountStep === 4 ? ( 2949 + <div className="space-y-4 animate-fade-in"> 2950 + <div className="space-y-1"> 2951 + <p className="text-sm font-semibold">Review and create</p> 2952 + <p className="text-xs text-muted-foreground">Confirm details before saving this mapping.</p> 2953 + </div> 2954 + <div className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm"> 2955 + <p> 2956 + <span className="font-medium">Owner:</span> {newMapping.owner || '--'} 2957 + </p> 2958 + <p> 2959 + <span className="font-medium">Twitter Sources:</span>{' '} 2960 + {newTwitterUsers.length > 0 ? newTwitterUsers.map((username) => `@${username}`).join(', ') : '--'} 2961 + </p> 2962 + <p> 2963 + <span className="font-medium">Bluesky Target:</span> {newMapping.bskyIdentifier || '--'} 2964 + </p> 2965 + <p> 2966 + <span className="font-medium">Folder:</span>{' '} 2967 + {newMapping.groupName.trim() 2968 + ? `${newMapping.groupEmoji.trim() || DEFAULT_GROUP_EMOJI} ${newMapping.groupName.trim()}` 2969 + : `${DEFAULT_GROUP_EMOJI} ${DEFAULT_GROUP_NAME}`} 2970 + </p> 2971 + </div> 2972 + </div> 2973 + ) : null} 2974 + </div> 2529 2975 2530 - <Card className="animate-slide-up"> 2531 - <CardHeader> 2532 - <CardTitle>Data Management</CardTitle> 2533 - <CardDescription>Export/import account and provider config without login credentials.</CardDescription> 2534 - </CardHeader> 2535 - <CardContent className="space-y-3"> 2536 - <Button className="w-full" variant="outline" onClick={handleExportConfig}> 2537 - <Download className="mr-2 h-4 w-4" /> 2538 - Export configuration 2976 + <div className="flex items-center justify-between gap-2 border-t border-border/70 px-5 py-4"> 2977 + <Button variant="outline" onClick={retreatAddAccountStep} disabled={addAccountStep === 1 || isBusy}> 2978 + <ChevronLeft className="mr-2 h-4 w-4" /> 2979 + Back 2980 + </Button> 2981 + {addAccountStep < ADD_ACCOUNT_STEP_COUNT ? ( 2982 + <Button onClick={advanceAddAccountStep}> 2983 + Next 2984 + <ChevronRight className="ml-2 h-4 w-4" /> 2539 2985 </Button> 2540 - <input 2541 - ref={importInputRef} 2542 - className="hidden" 2543 - type="file" 2544 - accept="application/json,.json" 2545 - onChange={(event) => { 2546 - void handleImportConfig(event); 2547 - }} 2548 - /> 2549 - <Button 2550 - className="w-full" 2551 - variant="outline" 2552 - onClick={() => { 2553 - importInputRef.current?.click(); 2554 - }} 2555 - > 2556 - <Upload className="mr-2 h-4 w-4" /> 2557 - Import configuration 2986 + ) : ( 2987 + <Button onClick={() => void submitNewMapping()} disabled={isBusy}> 2988 + {isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />} 2989 + Create Account 2558 2990 </Button> 2559 - <p className="text-xs text-muted-foreground"> 2560 - Imports preserve dashboard users and passwords while replacing mappings, provider keys, and scheduler 2561 - settings. 2562 - </p> 2563 - </CardContent> 2564 - </Card> 2565 - </section> 2566 - ) : null 2991 + )} 2992 + </div> 2993 + </aside> 2994 + </div> 2567 2995 ) : null} 2568 2996 2569 2997 {editingMapping ? (