diff --git a/src/cli.ts b/src/cli.ts index 7e20ed0..7934b82 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -143,6 +143,7 @@ const importConfig = (inputFile: string) => { const nextConfig: AppConfig = { ...currentConfig, mappings: parsed.mappings, + groups: Array.isArray(parsed.groups) ? parsed.groups : currentConfig.groups, twitter: parsed.twitter || currentConfig.twitter, ai: parsed.ai || currentConfig.ai, checkIntervalMinutes: parsed.checkIntervalMinutes || currentConfig.checkIntervalMinutes, diff --git a/src/config-manager.ts b/src/config-manager.ts index 45f64c5..0fe6b70 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -38,9 +38,15 @@ export interface AccountMapping { groupEmoji?: string; } +export interface AccountGroup { + name: string; + emoji?: string; +} + export interface AppConfig { twitter: TwitterConfig; mappings: AccountMapping[]; + groups: AccountGroup[]; users: WebUser[]; checkIntervalMinutes: number; geminiApiKey?: string; @@ -52,6 +58,7 @@ export function getConfig(): AppConfig { return { twitter: { authToken: '', ct0: '' }, mappings: [], + groups: [], users: [], checkIntervalMinutes: 5, }; @@ -59,12 +66,14 @@ export function getConfig(): AppConfig { try { const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); if (!config.users) config.users = []; + if (!Array.isArray(config.groups)) config.groups = []; return config; } catch (err) { console.error('Error reading config:', err); return { twitter: { authToken: '', ct0: '' }, mappings: [], + groups: [], users: [], checkIntervalMinutes: 5, }; @@ -80,6 +89,27 @@ export function saveConfig(config: AppConfig): void { return rest; }); + const groups = Array.isArray(configToSave.groups) ? configToSave.groups : []; + const seenGroupNames = new Set(); + configToSave.groups = groups + .map((group: any) => ({ + name: typeof group?.name === 'string' ? group.name.trim() : '', + emoji: typeof group?.emoji === 'string' ? group.emoji.trim() : '', + })) + .filter((group: any) => group.name.length > 0) + .filter((group: any) => { + const key = group.name.toLowerCase(); + if (seenGroupNames.has(key)) { + return false; + } + seenGroupNames.add(key); + return true; + }) + .map((group: any) => ({ + name: group.name, + ...(group.emoji ? { emoji: group.emoji } : {}), + })); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(configToSave, null, 2)); } diff --git a/src/server.ts b/src/server.ts index bc6a2cf..652f678 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,7 +7,7 @@ import cors from 'cors'; import express from 'express'; import jwt from 'jsonwebtoken'; import { deleteAllPosts } from './bsky.js'; -import { getConfig, saveConfig } from './config-manager.js'; +import { getConfig, saveConfig, type AppConfig } from './config-manager.js'; import { dbService } from './db.js'; import type { ProcessedTweet } from './db.js'; @@ -106,6 +106,42 @@ function normalizeActor(actor: string): string { return actor.trim().replace(/^@/, '').toLowerCase(); } +function normalizeGroupName(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeGroupEmoji(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function ensureGroupExists(config: AppConfig, name?: string, emoji?: string) { + const normalizedName = normalizeGroupName(name); + if (!normalizedName) return; + + if (!Array.isArray(config.groups)) { + config.groups = []; + } + + const existingIndex = config.groups.findIndex((group) => normalizeGroupName(group.name).toLowerCase() === normalizedName.toLowerCase()); + const normalizedEmoji = normalizeGroupEmoji(emoji); + + if (existingIndex === -1) { + config.groups.push({ + name: normalizedName, + ...(normalizedEmoji ? { emoji: normalizedEmoji } : {}), + }); + return; + } + + if (normalizedEmoji) { + const existingGroupName = normalizeGroupName(config.groups[existingIndex]?.name) || normalizedName; + config.groups[existingIndex] = { + name: existingGroupName, + emoji: normalizedEmoji, + }; + } +} + function extractMediaFromEmbed(embed: any): EnrichedPostMedia[] { if (!embed || typeof embed !== 'object') { return []; @@ -406,6 +442,28 @@ app.get('/api/mappings', authenticateToken, (_req, res) => { res.json(config.mappings); }); +app.get('/api/groups', authenticateToken, (_req, res) => { + const config = getConfig(); + res.json(Array.isArray(config.groups) ? config.groups : []); +}); + +app.post('/api/groups', authenticateToken, (req, res) => { + const config = getConfig(); + const normalizedName = normalizeGroupName(req.body?.name); + const normalizedEmoji = normalizeGroupEmoji(req.body?.emoji); + + if (!normalizedName) { + res.status(400).json({ error: 'Group name is required.' }); + return; + } + + ensureGroupExists(config, normalizedName, normalizedEmoji); + saveConfig(config); + + const group = config.groups.find((entry) => normalizeGroupName(entry.name).toLowerCase() === normalizedName.toLowerCase()); + res.json(group || { name: normalizedName, ...(normalizedEmoji ? { emoji: normalizedEmoji } : {}) }); +}); + app.post('/api/mappings', authenticateToken, (req, res) => { const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner, groupName, groupEmoji } = req.body; const config = getConfig(); @@ -421,8 +479,8 @@ app.post('/api/mappings', authenticateToken, (req, res) => { .filter((u) => u.length > 0); } - const normalizedGroupName = typeof groupName === 'string' ? groupName.trim() : ''; - const normalizedGroupEmoji = typeof groupEmoji === 'string' ? groupEmoji.trim() : ''; + const normalizedGroupName = normalizeGroupName(groupName); + const normalizedGroupEmoji = normalizeGroupEmoji(groupEmoji); const newMapping = { id: Math.random().toString(36).substring(7), @@ -436,6 +494,7 @@ app.post('/api/mappings', authenticateToken, (req, res) => { groupEmoji: normalizedGroupEmoji || undefined, }; + ensureGroupExists(config, normalizedGroupName, normalizedGroupEmoji); config.mappings.push(newMapping); saveConfig(config); res.json(newMapping); @@ -468,13 +527,13 @@ app.put('/api/mappings/:id', authenticateToken, (req, res) => { let nextGroupName = existingMapping.groupName; if (groupName !== undefined) { - const normalizedGroupName = typeof groupName === 'string' ? groupName.trim() : ''; + const normalizedGroupName = normalizeGroupName(groupName); nextGroupName = normalizedGroupName || undefined; } let nextGroupEmoji = existingMapping.groupEmoji; if (groupEmoji !== undefined) { - const normalizedGroupEmoji = typeof groupEmoji === 'string' ? groupEmoji.trim() : ''; + const normalizedGroupEmoji = normalizeGroupEmoji(groupEmoji); nextGroupEmoji = normalizedGroupEmoji || undefined; } @@ -490,6 +549,7 @@ app.put('/api/mappings/:id', authenticateToken, (req, res) => { groupEmoji: nextGroupEmoji, }; + ensureGroupExists(config, nextGroupName, nextGroupEmoji); config.mappings[index] = updatedMapping; saveConfig(config); res.json(updatedMapping); @@ -697,6 +757,7 @@ app.post('/api/config/import', authenticateToken, requireAdmin, (req, res) => { const newConfig = { ...currentConfig, mappings: importData.mappings, + groups: Array.isArray(importData.groups) ? importData.groups : currentConfig.groups, twitter: importData.twitter || currentConfig.twitter, ai: importData.ai || currentConfig.ai, checkIntervalMinutes: importData.checkIntervalMinutes || currentConfig.checkIntervalMinutes diff --git a/web/src/App.tsx b/web/src/App.tsx index 2325578..b7cc561 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -54,6 +54,11 @@ interface AccountMapping { groupEmoji?: string; } +interface AccountGroup { + name: string; + emoji?: string; +} + interface TwitterConfig { authToken: string; ct0: string; @@ -207,6 +212,7 @@ const defaultMappingForm = (): MappingFormState => ({ const DEFAULT_GROUP_NAME = 'Ungrouped'; const DEFAULT_GROUP_EMOJI = '📁'; +const DEFAULT_GROUP_KEY = 'ungrouped'; const selectClassName = '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,11 +272,22 @@ function normalizeGroupEmoji(value?: string): string { return trimmed || DEFAULT_GROUP_EMOJI; } +function getGroupKey(groupName?: string): string { + return normalizeGroupName(groupName).toLowerCase(); +} + +function getGroupMeta(groupName?: string, groupEmoji?: string) { + const name = normalizeGroupName(groupName); + const emoji = normalizeGroupEmoji(groupEmoji); + return { + key: getGroupKey(name), + name, + emoji, + }; +} + function getMappingGroupMeta(mapping?: Pick) { - const name = normalizeGroupName(mapping?.groupName); - const emoji = normalizeGroupEmoji(mapping?.groupEmoji); - const key = `${name.toLowerCase()}::${emoji}`; - return { key, name, emoji }; + return getGroupMeta(mapping?.groupName, mapping?.groupEmoji); } function getTwitterPostUrl(twitterUsername?: string, twitterId?: string): string | undefined { @@ -377,6 +394,7 @@ function App() { const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light'); const [mappings, setMappings] = useState([]); + const [groups, setGroups] = useState([]); const [enrichedPosts, setEnrichedPosts] = useState([]); const [profilesByActor, setProfilesByActor] = useState>({}); const [twitterConfig, setTwitterConfig] = useState({ authToken: '', ct0: '' }); @@ -406,6 +424,8 @@ function App() { const [editForm, setEditForm] = useState(defaultMappingForm); const [editTwitterUsers, setEditTwitterUsers] = useState([]); const [editTwitterInput, setEditTwitterInput] = useState(''); + const [newGroupName, setNewGroupName] = useState(''); + const [newGroupEmoji, setNewGroupEmoji] = useState(DEFAULT_GROUP_EMOJI); const [collapsedGroupKeys, setCollapsedGroupKeys] = useState>(() => { const raw = localStorage.getItem('accounts-collapsed-groups'); if (!raw) return {}; @@ -444,6 +464,7 @@ function App() { setToken(null); setMe(null); setMappings([]); + setGroups([]); setEnrichedPosts([]); setProfilesByActor({}); setStatus(null); @@ -451,6 +472,8 @@ function App() { setEditingMapping(null); setNewTwitterUsers([]); setEditTwitterUsers([]); + setNewGroupName(''); + setNewGroupEmoji(DEFAULT_GROUP_EMOJI); setAuthView('login'); }, []); @@ -504,6 +527,19 @@ function App() { } }, [authHeaders, handleAuthFailure]); + const fetchGroups = useCallback(async () => { + if (!authHeaders) { + return; + } + + try { + const response = await axios.get('/api/groups', { headers: authHeaders }); + setGroups(Array.isArray(response.data) ? response.data : []); + } catch (error) { + handleAuthFailure(error, 'Failed to fetch account groups.'); + } + }, [authHeaders, handleAuthFailure]); + const fetchProfiles = useCallback( async (actors: string[]) => { if (!authHeaders) { @@ -536,15 +572,18 @@ function App() { } try { - const [meResponse, mappingsResponse] = await Promise.all([ + const [meResponse, mappingsResponse, groupsResponse] = await Promise.all([ axios.get('/api/me', { headers: authHeaders }), axios.get('/api/mappings', { headers: authHeaders }), + axios.get('/api/groups', { headers: authHeaders }), ]); const profile = meResponse.data; const mappingData = mappingsResponse.data; + const groupData = Array.isArray(groupsResponse.data) ? groupsResponse.data : []; setMe(profile); setMappings(mappingData); + setGroups(groupData); if (profile.isAdmin) { const [twitterResponse, aiResponse] = await Promise.all([ @@ -733,11 +772,16 @@ function App() { }, [mappings]); const groupOptions = useMemo(() => { const options = new Map(); + for (const group of groups) { + const meta = getGroupMeta(group.name, group.emoji); + if (meta.key === DEFAULT_GROUP_KEY) { + continue; + } + options.set(meta.key, meta); + } for (const mapping of mappings) { const group = getMappingGroupMeta(mapping); - if (!options.has(group.key)) { - options.set(group.key, group); - } + options.set(group.key, options.get(group.key) || group); } return [...options.values()].sort((a, b) => { const aUngrouped = a.name === DEFAULT_GROUP_NAME; @@ -746,9 +790,16 @@ function App() { if (!aUngrouped && bUngrouped) return -1; return a.name.localeCompare(b.name); }); - }, [mappings]); + }, [groups, mappings]); + const groupOptionsByKey = useMemo(() => new Map(groupOptions.map((group) => [group.key, group])), [groupOptions]); const groupedMappings = useMemo(() => { const groups = new Map(); + for (const option of groupOptions) { + groups.set(option.key, { + ...option, + mappings: [], + }); + } for (const mapping of mappings) { const group = getMappingGroupMeta(mapping); const existing = groups.get(group.key); @@ -775,7 +826,7 @@ function App() { ), ), })); - }, [mappings]); + }, [groupOptions, mappings]); const resolveMappingForPost = useCallback( (post: EnrichedPost) => mappingsByBskyIdentifier.get(normalizeTwitterUsername(post.bskyIdentifier)) || @@ -1042,6 +1093,78 @@ function App() { })); }; + const handleCreateGroup = async (event: React.FormEvent) => { + event.preventDefault(); + if (!authHeaders) { + return; + } + + const name = newGroupName.trim(); + const emoji = newGroupEmoji.trim() || DEFAULT_GROUP_EMOJI; + if (!name) { + showNotice('error', 'Enter a group name first.'); + return; + } + + setIsBusy(true); + try { + await axios.post('/api/groups', { name, emoji }, { headers: authHeaders }); + setNewGroupName(''); + setNewGroupEmoji(DEFAULT_GROUP_EMOJI); + await fetchGroups(); + showNotice('success', `Group "${name}" created.`); + } catch (error) { + handleAuthFailure(error, 'Failed to create group.'); + } finally { + setIsBusy(false); + } + }; + + const handleAssignMappingGroup = async (mapping: AccountMapping, groupKey: string) => { + if (!authHeaders) { + return; + } + + const selectedGroup = groupOptionsByKey.get(groupKey); + const nextGroupName = selectedGroup?.name || ''; + const nextGroupEmoji = selectedGroup?.emoji || ''; + + try { + await axios.put( + `/api/mappings/${mapping.id}`, + { + groupName: nextGroupName, + groupEmoji: nextGroupEmoji, + }, + { headers: authHeaders }, + ); + + setMappings((previous) => + previous.map((entry) => + entry.id === mapping.id + ? { + ...entry, + groupName: nextGroupName || undefined, + groupEmoji: nextGroupEmoji || undefined, + } + : entry, + ), + ); + + if (nextGroupName) { + setGroups((previous) => { + const key = getGroupKey(nextGroupName); + if (previous.some((group) => getGroupKey(group.name) === key)) { + return previous; + } + return [...previous, { name: nextGroupName, ...(nextGroupEmoji ? { emoji: nextGroupEmoji } : {}) }]; + }); + } + } catch (error) { + handleAuthFailure(error, 'Failed to move account to folder.'); + } + }; + const handleAddMapping = async (event: React.FormEvent) => { event.preventDefault(); if (!authHeaders) { @@ -1508,8 +1631,42 @@ function App() { {mappings.length} configured - - {mappings.length === 0 ? ( + +
{ + void handleCreateGroup(event); + }} + > +

Create Folder

+
+
+ + setNewGroupName(event.target.value)} + placeholder="Gaming, News, Sports..." + /> +
+
+ + setNewGroupEmoji(event.target.value)} + placeholder="📁" + maxLength={8} + /> +
+ +
+
+ + {groupedMappings.length === 0 ? (
No mappings yet. Add one from the settings panel.
@@ -1550,126 +1707,151 @@ function App() { )} >
-
- - - - - - - - - - - - {group.mappings.map((mapping) => { - const queued = isBackfillQueued(mapping.id); - const active = isBackfillActive(mapping.id); - const queuePosition = getBackfillEntry(mapping.id)?.position; - const profile = getProfileForActor(mapping.bskyIdentifier); - const profileHandle = profile?.handle || mapping.bskyIdentifier; - const profileName = profile?.displayName || profileHandle; - - return ( - - - - + + ); + })} + +
OwnerTwitter SourcesBluesky TargetStatusActions
-
- - {mapping.owner || 'System'} -
-
-
- {mapping.twitterUsernames.map((username) => ( - - @{username} - - ))} -
-
-
- {profile?.avatar ? ( - {profileName} - ) : ( -
- + {group.mappings.length === 0 ? ( +
+ No accounts in this folder yet. +
+ ) : ( +
+ + + + + + + + + + + + {group.mappings.map((mapping) => { + const queued = isBackfillQueued(mapping.id); + const active = isBackfillActive(mapping.id); + const queuePosition = getBackfillEntry(mapping.id)?.position; + const profile = getProfileForActor(mapping.bskyIdentifier); + const profileHandle = profile?.handle || mapping.bskyIdentifier; + const profileName = profile?.displayName || profileHandle; + const mappingGroup = getMappingGroupMeta(mapping); + + return ( + + - - + + + + - - ); - })} - -
OwnerTwitter SourcesBluesky TargetStatusActions
+
+ + {mapping.owner || 'System'}
- )} -
-

{profileName}

-

{profileHandle}

-
- -
- {active ? ( - Backfilling - ) : queued ? ( - Queued {queuePosition ? `#${queuePosition}` : ''} - ) : ( - Active - )} - -
- {isAdmin ? ( - <> - - -
+
+ {mapping.twitterUsernames.map((username) => ( + + @{username} + + ))} +
+
+
+ {profile?.avatar ? ( + {profileName} + ) : ( +
+ +
+ )} +
+

{profileName}

+

{profileHandle}

+
+
+
+ {active ? ( + Backfilling + ) : queued ? ( + Queued {queuePosition ? `#${queuePosition}` : ''} + ) : ( + Active + )} + +
+ + {isAdmin ? ( + <> + + + + + + ) : null} - - ) : null} - -
-
-
+
+
+
+ )}