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: manage folders from accounts view and reassign existing mappings

jack 955ca3ba 4a6ef9d9

+401 -127
+1
src/cli.ts
··· 143 143 const nextConfig: AppConfig = { 144 144 ...currentConfig, 145 145 mappings: parsed.mappings, 146 + groups: Array.isArray(parsed.groups) ? parsed.groups : currentConfig.groups, 146 147 twitter: parsed.twitter || currentConfig.twitter, 147 148 ai: parsed.ai || currentConfig.ai, 148 149 checkIntervalMinutes: parsed.checkIntervalMinutes || currentConfig.checkIntervalMinutes,
+30
src/config-manager.ts
··· 38 38 groupEmoji?: string; 39 39 } 40 40 41 + export interface AccountGroup { 42 + name: string; 43 + emoji?: string; 44 + } 45 + 41 46 export interface AppConfig { 42 47 twitter: TwitterConfig; 43 48 mappings: AccountMapping[]; 49 + groups: AccountGroup[]; 44 50 users: WebUser[]; 45 51 checkIntervalMinutes: number; 46 52 geminiApiKey?: string; ··· 52 58 return { 53 59 twitter: { authToken: '', ct0: '' }, 54 60 mappings: [], 61 + groups: [], 55 62 users: [], 56 63 checkIntervalMinutes: 5, 57 64 }; ··· 59 66 try { 60 67 const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); 61 68 if (!config.users) config.users = []; 69 + if (!Array.isArray(config.groups)) config.groups = []; 62 70 return config; 63 71 } catch (err) { 64 72 console.error('Error reading config:', err); 65 73 return { 66 74 twitter: { authToken: '', ct0: '' }, 67 75 mappings: [], 76 + groups: [], 68 77 users: [], 69 78 checkIntervalMinutes: 5, 70 79 }; ··· 79 88 const { twitterUsername, ...rest } = m; 80 89 return rest; 81 90 }); 91 + 92 + const groups = Array.isArray(configToSave.groups) ? configToSave.groups : []; 93 + const seenGroupNames = new Set<string>(); 94 + configToSave.groups = groups 95 + .map((group: any) => ({ 96 + name: typeof group?.name === 'string' ? group.name.trim() : '', 97 + emoji: typeof group?.emoji === 'string' ? group.emoji.trim() : '', 98 + })) 99 + .filter((group: any) => group.name.length > 0) 100 + .filter((group: any) => { 101 + const key = group.name.toLowerCase(); 102 + if (seenGroupNames.has(key)) { 103 + return false; 104 + } 105 + seenGroupNames.add(key); 106 + return true; 107 + }) 108 + .map((group: any) => ({ 109 + name: group.name, 110 + ...(group.emoji ? { emoji: group.emoji } : {}), 111 + })); 82 112 83 113 fs.writeFileSync(CONFIG_FILE, JSON.stringify(configToSave, null, 2)); 84 114 }
+66 -5
src/server.ts
··· 7 7 import express from 'express'; 8 8 import jwt from 'jsonwebtoken'; 9 9 import { deleteAllPosts } from './bsky.js'; 10 - import { getConfig, saveConfig } from './config-manager.js'; 10 + import { getConfig, saveConfig, type AppConfig } from './config-manager.js'; 11 11 import { dbService } from './db.js'; 12 12 import type { ProcessedTweet } from './db.js'; 13 13 ··· 106 106 return actor.trim().replace(/^@/, '').toLowerCase(); 107 107 } 108 108 109 + function normalizeGroupName(value: unknown): string { 110 + return typeof value === 'string' ? value.trim() : ''; 111 + } 112 + 113 + function normalizeGroupEmoji(value: unknown): string { 114 + return typeof value === 'string' ? value.trim() : ''; 115 + } 116 + 117 + function ensureGroupExists(config: AppConfig, name?: string, emoji?: string) { 118 + const normalizedName = normalizeGroupName(name); 119 + if (!normalizedName) return; 120 + 121 + if (!Array.isArray(config.groups)) { 122 + config.groups = []; 123 + } 124 + 125 + const existingIndex = config.groups.findIndex((group) => normalizeGroupName(group.name).toLowerCase() === normalizedName.toLowerCase()); 126 + const normalizedEmoji = normalizeGroupEmoji(emoji); 127 + 128 + if (existingIndex === -1) { 129 + config.groups.push({ 130 + name: normalizedName, 131 + ...(normalizedEmoji ? { emoji: normalizedEmoji } : {}), 132 + }); 133 + return; 134 + } 135 + 136 + if (normalizedEmoji) { 137 + const existingGroupName = normalizeGroupName(config.groups[existingIndex]?.name) || normalizedName; 138 + config.groups[existingIndex] = { 139 + name: existingGroupName, 140 + emoji: normalizedEmoji, 141 + }; 142 + } 143 + } 144 + 109 145 function extractMediaFromEmbed(embed: any): EnrichedPostMedia[] { 110 146 if (!embed || typeof embed !== 'object') { 111 147 return []; ··· 406 442 res.json(config.mappings); 407 443 }); 408 444 445 + app.get('/api/groups', authenticateToken, (_req, res) => { 446 + const config = getConfig(); 447 + res.json(Array.isArray(config.groups) ? config.groups : []); 448 + }); 449 + 450 + app.post('/api/groups', authenticateToken, (req, res) => { 451 + const config = getConfig(); 452 + const normalizedName = normalizeGroupName(req.body?.name); 453 + const normalizedEmoji = normalizeGroupEmoji(req.body?.emoji); 454 + 455 + if (!normalizedName) { 456 + res.status(400).json({ error: 'Group name is required.' }); 457 + return; 458 + } 459 + 460 + ensureGroupExists(config, normalizedName, normalizedEmoji); 461 + saveConfig(config); 462 + 463 + const group = config.groups.find((entry) => normalizeGroupName(entry.name).toLowerCase() === normalizedName.toLowerCase()); 464 + res.json(group || { name: normalizedName, ...(normalizedEmoji ? { emoji: normalizedEmoji } : {}) }); 465 + }); 466 + 409 467 app.post('/api/mappings', authenticateToken, (req, res) => { 410 468 const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner, groupName, groupEmoji } = req.body; 411 469 const config = getConfig(); ··· 421 479 .filter((u) => u.length > 0); 422 480 } 423 481 424 - const normalizedGroupName = typeof groupName === 'string' ? groupName.trim() : ''; 425 - const normalizedGroupEmoji = typeof groupEmoji === 'string' ? groupEmoji.trim() : ''; 482 + const normalizedGroupName = normalizeGroupName(groupName); 483 + const normalizedGroupEmoji = normalizeGroupEmoji(groupEmoji); 426 484 427 485 const newMapping = { 428 486 id: Math.random().toString(36).substring(7), ··· 436 494 groupEmoji: normalizedGroupEmoji || undefined, 437 495 }; 438 496 497 + ensureGroupExists(config, normalizedGroupName, normalizedGroupEmoji); 439 498 config.mappings.push(newMapping); 440 499 saveConfig(config); 441 500 res.json(newMapping); ··· 468 527 469 528 let nextGroupName = existingMapping.groupName; 470 529 if (groupName !== undefined) { 471 - const normalizedGroupName = typeof groupName === 'string' ? groupName.trim() : ''; 530 + const normalizedGroupName = normalizeGroupName(groupName); 472 531 nextGroupName = normalizedGroupName || undefined; 473 532 } 474 533 475 534 let nextGroupEmoji = existingMapping.groupEmoji; 476 535 if (groupEmoji !== undefined) { 477 - const normalizedGroupEmoji = typeof groupEmoji === 'string' ? groupEmoji.trim() : ''; 536 + const normalizedGroupEmoji = normalizeGroupEmoji(groupEmoji); 478 537 nextGroupEmoji = normalizedGroupEmoji || undefined; 479 538 } 480 539 ··· 490 549 groupEmoji: nextGroupEmoji, 491 550 }; 492 551 552 + ensureGroupExists(config, nextGroupName, nextGroupEmoji); 493 553 config.mappings[index] = updatedMapping; 494 554 saveConfig(config); 495 555 res.json(updatedMapping); ··· 697 757 const newConfig = { 698 758 ...currentConfig, 699 759 mappings: importData.mappings, 760 + groups: Array.isArray(importData.groups) ? importData.groups : currentConfig.groups, 700 761 twitter: importData.twitter || currentConfig.twitter, 701 762 ai: importData.ai || currentConfig.ai, 702 763 checkIntervalMinutes: importData.checkIntervalMinutes || currentConfig.checkIntervalMinutes
+304 -122
web/src/App.tsx
··· 54 54 groupEmoji?: string; 55 55 } 56 56 57 + interface AccountGroup { 58 + name: string; 59 + emoji?: string; 60 + } 61 + 57 62 interface TwitterConfig { 58 63 authToken: string; 59 64 ct0: string; ··· 207 212 208 213 const DEFAULT_GROUP_NAME = 'Ungrouped'; 209 214 const DEFAULT_GROUP_EMOJI = '📁'; 215 + const DEFAULT_GROUP_KEY = 'ungrouped'; 210 216 211 217 const selectClassName = 212 218 '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'; ··· 266 272 return trimmed || DEFAULT_GROUP_EMOJI; 267 273 } 268 274 275 + function getGroupKey(groupName?: string): string { 276 + return normalizeGroupName(groupName).toLowerCase(); 277 + } 278 + 279 + function getGroupMeta(groupName?: string, groupEmoji?: string) { 280 + const name = normalizeGroupName(groupName); 281 + const emoji = normalizeGroupEmoji(groupEmoji); 282 + return { 283 + key: getGroupKey(name), 284 + name, 285 + emoji, 286 + }; 287 + } 288 + 269 289 function getMappingGroupMeta(mapping?: Pick<AccountMapping, 'groupName' | 'groupEmoji'>) { 270 - const name = normalizeGroupName(mapping?.groupName); 271 - const emoji = normalizeGroupEmoji(mapping?.groupEmoji); 272 - const key = `${name.toLowerCase()}::${emoji}`; 273 - return { key, name, emoji }; 290 + return getGroupMeta(mapping?.groupName, mapping?.groupEmoji); 274 291 } 275 292 276 293 function getTwitterPostUrl(twitterUsername?: string, twitterId?: string): string | undefined { ··· 377 394 const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light'); 378 395 379 396 const [mappings, setMappings] = useState<AccountMapping[]>([]); 397 + const [groups, setGroups] = useState<AccountGroup[]>([]); 380 398 const [enrichedPosts, setEnrichedPosts] = useState<EnrichedPost[]>([]); 381 399 const [profilesByActor, setProfilesByActor] = useState<Record<string, BskyProfileView>>({}); 382 400 const [twitterConfig, setTwitterConfig] = useState<TwitterConfig>({ authToken: '', ct0: '' }); ··· 406 424 const [editForm, setEditForm] = useState<MappingFormState>(defaultMappingForm); 407 425 const [editTwitterUsers, setEditTwitterUsers] = useState<string[]>([]); 408 426 const [editTwitterInput, setEditTwitterInput] = useState(''); 427 + const [newGroupName, setNewGroupName] = useState(''); 428 + const [newGroupEmoji, setNewGroupEmoji] = useState(DEFAULT_GROUP_EMOJI); 409 429 const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Record<string, boolean>>(() => { 410 430 const raw = localStorage.getItem('accounts-collapsed-groups'); 411 431 if (!raw) return {}; ··· 444 464 setToken(null); 445 465 setMe(null); 446 466 setMappings([]); 467 + setGroups([]); 447 468 setEnrichedPosts([]); 448 469 setProfilesByActor({}); 449 470 setStatus(null); ··· 451 472 setEditingMapping(null); 452 473 setNewTwitterUsers([]); 453 474 setEditTwitterUsers([]); 475 + setNewGroupName(''); 476 + setNewGroupEmoji(DEFAULT_GROUP_EMOJI); 454 477 setAuthView('login'); 455 478 }, []); 456 479 ··· 504 527 } 505 528 }, [authHeaders, handleAuthFailure]); 506 529 530 + const fetchGroups = useCallback(async () => { 531 + if (!authHeaders) { 532 + return; 533 + } 534 + 535 + try { 536 + const response = await axios.get<AccountGroup[]>('/api/groups', { headers: authHeaders }); 537 + setGroups(Array.isArray(response.data) ? response.data : []); 538 + } catch (error) { 539 + handleAuthFailure(error, 'Failed to fetch account groups.'); 540 + } 541 + }, [authHeaders, handleAuthFailure]); 542 + 507 543 const fetchProfiles = useCallback( 508 544 async (actors: string[]) => { 509 545 if (!authHeaders) { ··· 536 572 } 537 573 538 574 try { 539 - const [meResponse, mappingsResponse] = await Promise.all([ 575 + const [meResponse, mappingsResponse, groupsResponse] = await Promise.all([ 540 576 axios.get<AuthUser>('/api/me', { headers: authHeaders }), 541 577 axios.get<AccountMapping[]>('/api/mappings', { headers: authHeaders }), 578 + axios.get<AccountGroup[]>('/api/groups', { headers: authHeaders }), 542 579 ]); 543 580 544 581 const profile = meResponse.data; 545 582 const mappingData = mappingsResponse.data; 583 + const groupData = Array.isArray(groupsResponse.data) ? groupsResponse.data : []; 546 584 setMe(profile); 547 585 setMappings(mappingData); 586 + setGroups(groupData); 548 587 549 588 if (profile.isAdmin) { 550 589 const [twitterResponse, aiResponse] = await Promise.all([ ··· 733 772 }, [mappings]); 734 773 const groupOptions = useMemo(() => { 735 774 const options = new Map<string, { key: string; name: string; emoji: string }>(); 775 + for (const group of groups) { 776 + const meta = getGroupMeta(group.name, group.emoji); 777 + if (meta.key === DEFAULT_GROUP_KEY) { 778 + continue; 779 + } 780 + options.set(meta.key, meta); 781 + } 736 782 for (const mapping of mappings) { 737 783 const group = getMappingGroupMeta(mapping); 738 - if (!options.has(group.key)) { 739 - options.set(group.key, group); 740 - } 784 + options.set(group.key, options.get(group.key) || group); 741 785 } 742 786 return [...options.values()].sort((a, b) => { 743 787 const aUngrouped = a.name === DEFAULT_GROUP_NAME; ··· 746 790 if (!aUngrouped && bUngrouped) return -1; 747 791 return a.name.localeCompare(b.name); 748 792 }); 749 - }, [mappings]); 793 + }, [groups, mappings]); 794 + const groupOptionsByKey = useMemo(() => new Map(groupOptions.map((group) => [group.key, group])), [groupOptions]); 750 795 const groupedMappings = useMemo(() => { 751 796 const groups = new Map<string, { key: string; name: string; emoji: string; mappings: AccountMapping[] }>(); 797 + for (const option of groupOptions) { 798 + groups.set(option.key, { 799 + ...option, 800 + mappings: [], 801 + }); 802 + } 752 803 for (const mapping of mappings) { 753 804 const group = getMappingGroupMeta(mapping); 754 805 const existing = groups.get(group.key); ··· 775 826 ), 776 827 ), 777 828 })); 778 - }, [mappings]); 829 + }, [groupOptions, mappings]); 779 830 const resolveMappingForPost = useCallback( 780 831 (post: EnrichedPost) => 781 832 mappingsByBskyIdentifier.get(normalizeTwitterUsername(post.bskyIdentifier)) || ··· 1042 1093 })); 1043 1094 }; 1044 1095 1096 + const handleCreateGroup = async (event: React.FormEvent<HTMLFormElement>) => { 1097 + event.preventDefault(); 1098 + if (!authHeaders) { 1099 + return; 1100 + } 1101 + 1102 + const name = newGroupName.trim(); 1103 + const emoji = newGroupEmoji.trim() || DEFAULT_GROUP_EMOJI; 1104 + if (!name) { 1105 + showNotice('error', 'Enter a group name first.'); 1106 + return; 1107 + } 1108 + 1109 + setIsBusy(true); 1110 + try { 1111 + await axios.post('/api/groups', { name, emoji }, { headers: authHeaders }); 1112 + setNewGroupName(''); 1113 + setNewGroupEmoji(DEFAULT_GROUP_EMOJI); 1114 + await fetchGroups(); 1115 + showNotice('success', `Group "${name}" created.`); 1116 + } catch (error) { 1117 + handleAuthFailure(error, 'Failed to create group.'); 1118 + } finally { 1119 + setIsBusy(false); 1120 + } 1121 + }; 1122 + 1123 + const handleAssignMappingGroup = async (mapping: AccountMapping, groupKey: string) => { 1124 + if (!authHeaders) { 1125 + return; 1126 + } 1127 + 1128 + const selectedGroup = groupOptionsByKey.get(groupKey); 1129 + const nextGroupName = selectedGroup?.name || ''; 1130 + const nextGroupEmoji = selectedGroup?.emoji || ''; 1131 + 1132 + try { 1133 + await axios.put( 1134 + `/api/mappings/${mapping.id}`, 1135 + { 1136 + groupName: nextGroupName, 1137 + groupEmoji: nextGroupEmoji, 1138 + }, 1139 + { headers: authHeaders }, 1140 + ); 1141 + 1142 + setMappings((previous) => 1143 + previous.map((entry) => 1144 + entry.id === mapping.id 1145 + ? { 1146 + ...entry, 1147 + groupName: nextGroupName || undefined, 1148 + groupEmoji: nextGroupEmoji || undefined, 1149 + } 1150 + : entry, 1151 + ), 1152 + ); 1153 + 1154 + if (nextGroupName) { 1155 + setGroups((previous) => { 1156 + const key = getGroupKey(nextGroupName); 1157 + if (previous.some((group) => getGroupKey(group.name) === key)) { 1158 + return previous; 1159 + } 1160 + return [...previous, { name: nextGroupName, ...(nextGroupEmoji ? { emoji: nextGroupEmoji } : {}) }]; 1161 + }); 1162 + } 1163 + } catch (error) { 1164 + handleAuthFailure(error, 'Failed to move account to folder.'); 1165 + } 1166 + }; 1167 + 1045 1168 const handleAddMapping = async (event: React.FormEvent<HTMLFormElement>) => { 1046 1169 event.preventDefault(); 1047 1170 if (!authHeaders) { ··· 1508 1631 <Badge variant="outline">{mappings.length} configured</Badge> 1509 1632 </div> 1510 1633 </CardHeader> 1511 - <CardContent className="pt-0"> 1512 - {mappings.length === 0 ? ( 1634 + <CardContent className="space-y-4 pt-0"> 1635 + <form 1636 + className="rounded-lg border border-border/70 bg-muted/30 p-3" 1637 + onSubmit={(event) => { 1638 + void handleCreateGroup(event); 1639 + }} 1640 + > 1641 + <p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Create Folder</p> 1642 + <div className="flex flex-wrap items-end gap-2"> 1643 + <div className="min-w-[180px] flex-1 space-y-1"> 1644 + <Label htmlFor="accounts-group-name">Folder name</Label> 1645 + <Input 1646 + id="accounts-group-name" 1647 + value={newGroupName} 1648 + onChange={(event) => setNewGroupName(event.target.value)} 1649 + placeholder="Gaming, News, Sports..." 1650 + /> 1651 + </div> 1652 + <div className="w-20 space-y-1"> 1653 + <Label htmlFor="accounts-group-emoji">Emoji</Label> 1654 + <Input 1655 + id="accounts-group-emoji" 1656 + value={newGroupEmoji} 1657 + onChange={(event) => setNewGroupEmoji(event.target.value)} 1658 + placeholder="📁" 1659 + maxLength={8} 1660 + /> 1661 + </div> 1662 + <Button type="submit" size="sm" disabled={isBusy || newGroupName.trim().length === 0}> 1663 + <Plus className="mr-2 h-4 w-4" /> 1664 + Create 1665 + </Button> 1666 + </div> 1667 + </form> 1668 + 1669 + {groupedMappings.length === 0 ? ( 1513 1670 <div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground"> 1514 1671 No mappings yet. Add one from the settings panel. 1515 1672 </div> ··· 1550 1707 )} 1551 1708 > 1552 1709 <div className="min-h-0 overflow-hidden"> 1553 - <div className="overflow-x-auto"> 1554 - <table className="min-w-full text-left text-sm"> 1555 - <thead className="border-b border-border text-xs uppercase tracking-wide text-muted-foreground"> 1556 - <tr> 1557 - <th className="px-2 py-3">Owner</th> 1558 - <th className="px-2 py-3">Twitter Sources</th> 1559 - <th className="px-2 py-3">Bluesky Target</th> 1560 - <th className="px-2 py-3">Status</th> 1561 - <th className="px-2 py-3 text-right">Actions</th> 1562 - </tr> 1563 - </thead> 1564 - <tbody> 1565 - {group.mappings.map((mapping) => { 1566 - const queued = isBackfillQueued(mapping.id); 1567 - const active = isBackfillActive(mapping.id); 1568 - const queuePosition = getBackfillEntry(mapping.id)?.position; 1569 - const profile = getProfileForActor(mapping.bskyIdentifier); 1570 - const profileHandle = profile?.handle || mapping.bskyIdentifier; 1571 - const profileName = profile?.displayName || profileHandle; 1710 + {group.mappings.length === 0 ? ( 1711 + <div className="border-t border-border/60 p-4 text-sm text-muted-foreground"> 1712 + No accounts in this folder yet. 1713 + </div> 1714 + ) : ( 1715 + <div className="overflow-x-auto"> 1716 + <table className="min-w-full text-left text-sm"> 1717 + <thead className="border-b border-border text-xs uppercase tracking-wide text-muted-foreground"> 1718 + <tr> 1719 + <th className="px-2 py-3">Owner</th> 1720 + <th className="px-2 py-3">Twitter Sources</th> 1721 + <th className="px-2 py-3">Bluesky Target</th> 1722 + <th className="px-2 py-3">Status</th> 1723 + <th className="px-2 py-3 text-right">Actions</th> 1724 + </tr> 1725 + </thead> 1726 + <tbody> 1727 + {group.mappings.map((mapping) => { 1728 + const queued = isBackfillQueued(mapping.id); 1729 + const active = isBackfillActive(mapping.id); 1730 + const queuePosition = getBackfillEntry(mapping.id)?.position; 1731 + const profile = getProfileForActor(mapping.bskyIdentifier); 1732 + const profileHandle = profile?.handle || mapping.bskyIdentifier; 1733 + const profileName = profile?.displayName || profileHandle; 1734 + const mappingGroup = getMappingGroupMeta(mapping); 1572 1735 1573 - return ( 1574 - <tr key={mapping.id} className="interactive-row border-b border-border/60 last:border-0"> 1575 - <td className="px-2 py-3 align-top"> 1576 - <div className="flex items-center gap-2 font-medium"> 1577 - <UserRound className="h-4 w-4 text-muted-foreground" /> 1578 - {mapping.owner || 'System'} 1579 - </div> 1580 - </td> 1581 - <td className="px-2 py-3 align-top"> 1582 - <div className="flex flex-wrap gap-2"> 1583 - {mapping.twitterUsernames.map((username) => ( 1584 - <Badge key={username} variant="secondary"> 1585 - @{username} 1586 - </Badge> 1587 - ))} 1588 - </div> 1589 - </td> 1590 - <td className="px-2 py-3 align-top"> 1591 - <div className="flex items-center gap-2"> 1592 - {profile?.avatar ? ( 1593 - <img 1594 - className="h-8 w-8 rounded-full border border-border/70 object-cover" 1595 - src={profile.avatar} 1596 - alt={profileName} 1597 - loading="lazy" 1598 - /> 1599 - ) : ( 1600 - <div className="flex h-8 w-8 items-center justify-center rounded-full border border-border/70 bg-muted text-muted-foreground"> 1601 - <UserRound className="h-4 w-4" /> 1736 + return ( 1737 + <tr key={mapping.id} className="interactive-row border-b border-border/60 last:border-0"> 1738 + <td className="px-2 py-3 align-top"> 1739 + <div className="flex items-center gap-2 font-medium"> 1740 + <UserRound className="h-4 w-4 text-muted-foreground" /> 1741 + {mapping.owner || 'System'} 1742 + </div> 1743 + </td> 1744 + <td className="px-2 py-3 align-top"> 1745 + <div className="flex flex-wrap gap-2"> 1746 + {mapping.twitterUsernames.map((username) => ( 1747 + <Badge key={username} variant="secondary"> 1748 + @{username} 1749 + </Badge> 1750 + ))} 1751 + </div> 1752 + </td> 1753 + <td className="px-2 py-3 align-top"> 1754 + <div className="flex items-center gap-2"> 1755 + {profile?.avatar ? ( 1756 + <img 1757 + className="h-8 w-8 rounded-full border border-border/70 object-cover" 1758 + src={profile.avatar} 1759 + alt={profileName} 1760 + loading="lazy" 1761 + /> 1762 + ) : ( 1763 + <div className="flex h-8 w-8 items-center justify-center rounded-full border border-border/70 bg-muted text-muted-foreground"> 1764 + <UserRound className="h-4 w-4" /> 1765 + </div> 1766 + )} 1767 + <div className="min-w-0"> 1768 + <p className="truncate text-sm font-medium">{profileName}</p> 1769 + <p className="truncate font-mono text-xs text-muted-foreground">{profileHandle}</p> 1770 + </div> 1602 1771 </div> 1603 - )} 1604 - <div className="min-w-0"> 1605 - <p className="truncate text-sm font-medium">{profileName}</p> 1606 - <p className="truncate font-mono text-xs text-muted-foreground">{profileHandle}</p> 1607 - </div> 1608 - </div> 1609 - </td> 1610 - <td className="px-2 py-3 align-top"> 1611 - {active ? ( 1612 - <Badge variant="warning">Backfilling</Badge> 1613 - ) : queued ? ( 1614 - <Badge variant="warning">Queued {queuePosition ? `#${queuePosition}` : ''}</Badge> 1615 - ) : ( 1616 - <Badge variant="success">Active</Badge> 1617 - )} 1618 - </td> 1619 - <td className="px-2 py-3 align-top"> 1620 - <div className="flex flex-wrap justify-end gap-1"> 1621 - {isAdmin ? ( 1622 - <> 1623 - <Button variant="outline" size="sm" onClick={() => startEditMapping(mapping)}> 1624 - Edit 1625 - </Button> 1626 - <Button 1627 - variant="outline" 1628 - size="sm" 1629 - onClick={() => { 1630 - void requestBackfill(mapping.id, 'normal'); 1772 + </td> 1773 + <td className="px-2 py-3 align-top"> 1774 + {active ? ( 1775 + <Badge variant="warning">Backfilling</Badge> 1776 + ) : queued ? ( 1777 + <Badge variant="warning">Queued {queuePosition ? `#${queuePosition}` : ''}</Badge> 1778 + ) : ( 1779 + <Badge variant="success">Active</Badge> 1780 + )} 1781 + </td> 1782 + <td className="px-2 py-3 align-top"> 1783 + <div className="flex flex-wrap justify-end gap-1"> 1784 + <select 1785 + className={cn(selectClassName, 'h-9 w-44 px-2 py-1 text-xs')} 1786 + value={mappingGroup.key} 1787 + onChange={(event) => { 1788 + void handleAssignMappingGroup(mapping, event.target.value); 1631 1789 }} 1632 1790 > 1633 - Backfill 1634 - </Button> 1635 - <Button 1636 - variant="subtle" 1637 - size="sm" 1638 - onClick={() => { 1639 - void requestBackfill(mapping.id, 'reset'); 1640 - }} 1641 - > 1642 - Reset + Backfill 1643 - </Button> 1791 + <option value={DEFAULT_GROUP_KEY}> 1792 + {DEFAULT_GROUP_EMOJI} {DEFAULT_GROUP_NAME} 1793 + </option> 1794 + {groupOptions 1795 + .filter((option) => option.key !== DEFAULT_GROUP_KEY) 1796 + .map((option) => ( 1797 + <option key={`group-move-${mapping.id}-${option.key}`} value={option.key}> 1798 + {option.emoji} {option.name} 1799 + </option> 1800 + ))} 1801 + </select> 1802 + {isAdmin ? ( 1803 + <> 1804 + <Button variant="outline" size="sm" onClick={() => startEditMapping(mapping)}> 1805 + Edit 1806 + </Button> 1807 + <Button 1808 + variant="outline" 1809 + size="sm" 1810 + onClick={() => { 1811 + void requestBackfill(mapping.id, 'normal'); 1812 + }} 1813 + > 1814 + Backfill 1815 + </Button> 1816 + <Button 1817 + variant="subtle" 1818 + size="sm" 1819 + onClick={() => { 1820 + void requestBackfill(mapping.id, 'reset'); 1821 + }} 1822 + > 1823 + Reset + Backfill 1824 + </Button> 1825 + <Button 1826 + variant="destructive" 1827 + size="sm" 1828 + onClick={() => { 1829 + void handleDeleteAllPosts(mapping.id); 1830 + }} 1831 + > 1832 + Delete Posts 1833 + </Button> 1834 + </> 1835 + ) : null} 1644 1836 <Button 1645 - variant="destructive" 1837 + variant="ghost" 1646 1838 size="sm" 1647 1839 onClick={() => { 1648 - void handleDeleteAllPosts(mapping.id); 1840 + void handleDeleteMapping(mapping.id); 1649 1841 }} 1650 1842 > 1651 - Delete Posts 1843 + <Trash2 className="mr-1 h-4 w-4" /> 1844 + Remove 1652 1845 </Button> 1653 - </> 1654 - ) : null} 1655 - <Button 1656 - variant="ghost" 1657 - size="sm" 1658 - onClick={() => { 1659 - void handleDeleteMapping(mapping.id); 1660 - }} 1661 - > 1662 - <Trash2 className="mr-1 h-4 w-4" /> 1663 - Remove 1664 - </Button> 1665 - </div> 1666 - </td> 1667 - </tr> 1668 - ); 1669 - })} 1670 - </tbody> 1671 - </table> 1672 - </div> 1846 + </div> 1847 + </td> 1848 + </tr> 1849 + ); 1850 + })} 1851 + </tbody> 1852 + </table> 1853 + </div> 1854 + )} 1673 1855 </div> 1674 1856 </div> 1675 1857 </div>