import axios from 'axios'; import { ArrowUpRight, Bot, ChevronDown, ChevronLeft, ChevronRight, Clock3, Download, Folder, Heart, History, LayoutDashboard, Loader2, LogOut, MessageCircle, Moon, Newspaper, Play, Plus, Quote, RefreshCw, Repeat2, Save, Settings2, Sun, SunMoon, Trash2, Upload, UserRound, Users, X, } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Badge } from './components/ui/badge'; import { Button } from './components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './components/ui/card'; import { Input } from './components/ui/input'; import { Label } from './components/ui/label'; import { cn } from './lib/utils'; type ThemeMode = 'system' | 'light' | 'dark'; type AuthView = 'login' | 'register'; type DashboardTab = 'overview' | 'accounts' | 'posts' | 'activity' | 'settings'; type SettingsSection = 'account' | 'users' | 'twitter' | 'ai' | 'data'; type AppState = 'idle' | 'checking' | 'backfilling' | 'pacing' | 'processing'; interface AccountMapping { id: string; twitterUsernames: string[]; bskyIdentifier: string; bskyPassword?: string; bskyServiceUrl?: string; enabled: boolean; owner?: string; groupName?: string; groupEmoji?: string; createdByUserId?: string; createdByLabel?: string; createdByUser?: { id: string; username?: string; email?: string; role: 'admin' | 'user'; }; } interface AccountGroup { name: string; emoji?: string; } interface TwitterConfig { authToken: string; ct0: string; backupAuthToken?: string; backupCt0?: string; } interface AIConfig { provider: 'gemini' | 'openai' | 'anthropic' | 'custom'; apiKey?: string; model?: string; baseUrl?: string; } interface ActivityLog { twitter_id: string; twitter_username: string; bsky_identifier: string; tweet_text?: string; bsky_uri?: string; status: 'migrated' | 'skipped' | 'failed'; created_at?: string; } interface BskyFacetFeatureLink { $type: 'app.bsky.richtext.facet#link'; uri: string; } interface BskyFacetFeatureMention { $type: 'app.bsky.richtext.facet#mention'; did: string; } interface BskyFacetFeatureTag { $type: 'app.bsky.richtext.facet#tag'; tag: string; } type BskyFacetFeature = BskyFacetFeatureLink | BskyFacetFeatureMention | BskyFacetFeatureTag; interface BskyFacet { index?: { byteStart?: number; byteEnd?: number; }; features?: BskyFacetFeature[]; } interface EnrichedPostMedia { type: 'image' | 'video' | 'external'; url?: string; thumb?: string; alt?: string; width?: number; height?: number; title?: string; description?: string; } interface EnrichedPost { bskyUri: string; bskyCid?: string; bskyIdentifier: string; twitterId: string; twitterUsername: string; twitterUrl?: string; postUrl?: string; createdAt?: string; text: string; facets: BskyFacet[]; author: { did?: string; handle: string; displayName?: string; avatar?: string; }; stats: { likes: number; reposts: number; replies: number; quotes: number; engagement: number; }; media: EnrichedPostMedia[]; } interface LocalPostSearchResult { twitterId: string; twitterUsername: string; bskyIdentifier: string; tweetText?: string; bskyUri?: string; bskyCid?: string; createdAt?: string; postUrl?: string; twitterUrl?: string; score: number; } interface BskyProfileView { did?: string; handle?: string; displayName?: string; avatar?: string; } interface PendingBackfill { id: string; limit?: number; queuedAt: number; sequence: number; requestId: string; position: number; } interface StatusState { state: AppState; currentAccount?: string; processedCount?: number; totalCount?: number; message?: string; backfillMappingId?: string; backfillRequestId?: string; lastUpdate: number; } interface StatusResponse { lastCheckTime: number; nextCheckTime: number; nextCheckMinutes: number; checkIntervalMinutes: number; pendingBackfills: PendingBackfill[]; currentStatus: StatusState; } interface UserPermissions { viewAllMappings: boolean; manageOwnMappings: boolean; manageAllMappings: boolean; manageGroups: boolean; queueBackfills: boolean; runNow: boolean; } interface AuthUser { id: string; username?: string; email?: string; isAdmin: boolean; permissions: UserPermissions; } interface ManagedUser { id: string; username?: string; email?: string; role: 'admin' | 'user'; isAdmin: boolean; permissions: UserPermissions; createdAt: string; updatedAt: string; mappingCount: number; activeMappingCount: number; mappings: AccountMapping[]; } interface BootstrapStatus { bootstrapOpen: boolean; } interface RuntimeVersionInfo { version: string; commit?: string; branch?: string; startedAt: number; } interface UpdateStatusInfo { running: boolean; pid?: number; startedAt?: number; startedBy?: string; finishedAt?: number; exitCode?: number | null; signal?: string | null; logFile?: string; logTail?: string[]; } interface Notice { tone: 'success' | 'error' | 'info'; message: string; } interface MappingFormState { owner: string; bskyIdentifier: string; bskyPassword: string; bskyServiceUrl: string; groupName: string; groupEmoji: string; } interface UserFormState { username: string; email: string; password: string; isAdmin: boolean; permissions: UserPermissions; } interface AccountSecurityEmailState { currentEmail: string; newEmail: string; password: string; } interface AccountSecurityPasswordState { currentPassword: string; newPassword: string; confirmPassword: string; } const defaultMappingForm = (): MappingFormState => ({ owner: '', bskyIdentifier: '', bskyPassword: '', bskyServiceUrl: 'https://bsky.social', groupName: '', groupEmoji: '📁', }); const defaultUserForm = (): UserFormState => ({ username: '', email: '', password: '', isAdmin: false, permissions: { ...DEFAULT_USER_PERMISSIONS }, }); const DEFAULT_GROUP_NAME = 'Ungrouped'; const DEFAULT_GROUP_EMOJI = '📁'; const DEFAULT_GROUP_KEY = 'ungrouped'; const TAB_PATHS: Record = { overview: '/', accounts: '/accounts', posts: '/posts', activity: '/activity', settings: '/settings', }; const ADD_ACCOUNT_STEP_COUNT = 4; const ADD_ACCOUNT_STEPS = ['Owner', 'Sources', 'Bluesky', 'Confirm'] as const; const ACCOUNT_SEARCH_MIN_SCORE = 22; const DEFAULT_BACKFILL_LIMIT = 15; const DEFAULT_USER_PERMISSIONS: UserPermissions = { viewAllMappings: false, manageOwnMappings: true, manageAllMappings: false, manageGroups: false, queueBackfills: true, runNow: true, }; const PERMISSION_OPTIONS: Array<{ key: keyof UserPermissions; label: string; help: string; }> = [ { key: 'viewAllMappings', label: 'View all mappings', help: 'See every mapped account, post, and activity row.' }, { key: 'manageOwnMappings', label: 'Manage own mappings', help: 'Create, edit, and delete mappings this user owns.' }, { key: 'manageAllMappings', label: 'Manage all mappings', help: 'Edit/delete mappings created by any user.' }, { key: 'manageGroups', label: 'Manage groups', help: 'Create, rename, and delete account groups.' }, { key: 'queueBackfills', label: 'Queue backfills', help: 'Queue backfills for mappings they can manage.' }, { key: 'runNow', label: 'Run checks now', help: 'Trigger an immediate scheduler run.' }, ]; 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'; function getApiErrorMessage(error: unknown, fallback: string): string { if (axios.isAxiosError(error)) { const serverMessage = error.response?.data?.error; if (typeof serverMessage === 'string' && serverMessage.length > 0) { return serverMessage; } if (typeof error.message === 'string' && error.message.length > 0) { return error.message; } } return fallback; } function formatState(state: AppState): string { switch (state) { case 'checking': return 'Checking'; case 'backfilling': return 'Backfilling'; case 'pacing': return 'Pacing'; case 'processing': return 'Processing'; default: return 'Idle'; } } function getBskyPostUrl(activity: ActivityLog): string | null { if (!activity.bsky_uri || !activity.bsky_identifier) { return null; } const postId = activity.bsky_uri.split('/').filter(Boolean).pop(); if (!postId) { return null; } return `https://bsky.app/profile/${activity.bsky_identifier}/post/${postId}`; } function normalizeTwitterUsername(value: string): string { return value.trim().replace(/^@/, '').toLowerCase(); } function normalizeGroupName(value?: string): string { const trimmed = typeof value === 'string' ? value.trim() : ''; return trimmed || DEFAULT_GROUP_NAME; } function normalizeGroupEmoji(value?: string): string { const trimmed = typeof value === 'string' ? value.trim() : ''; 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) { return getGroupMeta(mapping?.groupName, mapping?.groupEmoji); } function getTwitterPostUrl(twitterUsername?: string, twitterId?: string): string | undefined { if (!twitterUsername || !twitterId) { return undefined; } return `https://x.com/${normalizeTwitterUsername(twitterUsername)}/status/${twitterId}`; } function normalizePath(pathname: string): string { const normalized = pathname.replace(/\/+$/, ''); return normalized.length === 0 ? '/' : normalized; } function getTabFromPath(pathname: string): DashboardTab | null { const normalized = normalizePath(pathname); const entry = (Object.entries(TAB_PATHS) as Array<[DashboardTab, string]>).find(([, path]) => path === normalized); return entry ? entry[0] : null; } function normalizeEmail(value: string): string { return value.trim().toLowerCase(); } function normalizeUsername(value: string): string { return value.trim().replace(/^@/, '').toLowerCase(); } function getUserLabel(user?: Pick): string { return user?.username || user?.email || 'user'; } function normalizePermissions(permissions?: Partial): UserPermissions { return { ...DEFAULT_USER_PERMISSIONS, ...(permissions || {}), }; } function addTwitterUsernames(current: string[], value: string): string[] { const candidates = value .split(/[\s,]+/) .map(normalizeTwitterUsername) .filter((username) => username.length > 0); if (candidates.length === 0) { return current; } const seen = new Set(current.map(normalizeTwitterUsername)); const next = [...current]; for (const candidate of candidates) { if (seen.has(candidate)) { continue; } seen.add(candidate); next.push(candidate); } return next; } function normalizeSearchValue(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9@#._\-\s]+/g, ' ') .replace(/\s+/g, ' ') .trim(); } function tokenizeSearchValue(value: string): string[] { if (!value) { return []; } return value.split(' ').filter((token) => token.length > 0); } function orderedSubsequenceScore(query: string, candidate: string): number { if (!query || !candidate) { return 0; } let matched = 0; let searchIndex = 0; for (const char of query) { const foundIndex = candidate.indexOf(char, searchIndex); if (foundIndex === -1) { continue; } matched += 1; searchIndex = foundIndex + 1; } return matched / query.length; } function buildBigrams(value: string): Set { const result = new Set(); if (value.length < 2) { if (value.length === 1) { result.add(value); } return result; } for (let i = 0; i < value.length - 1; i += 1) { result.add(value.slice(i, i + 2)); } return result; } function diceCoefficient(a: string, b: string): number { const aBigrams = buildBigrams(a); const bBigrams = buildBigrams(b); if (aBigrams.size === 0 || bBigrams.size === 0) { return 0; } let overlap = 0; for (const gram of aBigrams) { if (bBigrams.has(gram)) { overlap += 1; } } return (2 * overlap) / (aBigrams.size + bBigrams.size); } function scoreSearchField(query: string, tokens: string[], candidateValue?: string): number { const candidate = normalizeSearchValue(candidateValue || ''); if (!query || !candidate) { return 0; } let score = 0; if (candidate === query) { score += 170; } else if (candidate.startsWith(query)) { score += 138; } else if (candidate.includes(query)) { score += 108; } let matchedTokens = 0; for (const token of tokens) { if (candidate.includes(token)) { matchedTokens += 1; score += token.length >= 4 ? 18 : 12; } } if (tokens.length > 0) { score += (matchedTokens / tokens.length) * 46; } score += orderedSubsequenceScore(query, candidate) * 45; score += diceCoefficient(query, candidate) * 52; return score; } function scoreAccountMapping(mapping: AccountMapping, query: string, tokens: string[]): number { const usernameScores = mapping.twitterUsernames.map((username) => scoreSearchField(query, tokens, username) * 1.24); const bestUsernameScore = usernameScores.length > 0 ? Math.max(...usernameScores) : 0; const identifierScore = scoreSearchField(query, tokens, mapping.bskyIdentifier) * 1.2; const ownerScore = scoreSearchField(query, tokens, mapping.owner) * 0.92; const groupScore = scoreSearchField(query, tokens, mapping.groupName) * 0.72; const combined = [bestUsernameScore, identifierScore, ownerScore, groupScore]; const maxScore = Math.max(...combined); return maxScore + (combined.reduce((total, value) => total + value, 0) - maxScore) * 0.24; } const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); const compactNumberFormatter = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }); type FacetSegment = | { type: 'text'; text: string } | { type: 'link'; text: string; href: string } | { type: 'mention'; text: string; href: string } | { type: 'tag'; text: string; href: string }; function sliceByBytes(bytes: Uint8Array, start: number, end: number): string { return textDecoder.decode(bytes.slice(start, end)); } function buildFacetSegments(text: string, facets: BskyFacet[]): FacetSegment[] { const bytes = textEncoder.encode(text); const sortedFacets = [...facets].sort((a, b) => (a.index?.byteStart || 0) - (b.index?.byteStart || 0)); const segments: FacetSegment[] = []; let cursor = 0; for (const facet of sortedFacets) { const start = Number(facet.index?.byteStart); const end = Number(facet.index?.byteEnd); if (!Number.isFinite(start) || !Number.isFinite(end)) continue; if (start < cursor || end <= start || end > bytes.length) continue; if (start > cursor) { segments.push({ type: 'text', text: sliceByBytes(bytes, cursor, start) }); } const rawText = sliceByBytes(bytes, start, end); const feature = facet.features?.[0]; if (!feature) { segments.push({ type: 'text', text: rawText }); } else if (feature.$type === 'app.bsky.richtext.facet#link' && feature.uri) { segments.push({ type: 'link', text: rawText, href: feature.uri }); } else if (feature.$type === 'app.bsky.richtext.facet#mention' && feature.did) { segments.push({ type: 'mention', text: rawText, href: `https://bsky.app/profile/${feature.did}` }); } else if (feature.$type === 'app.bsky.richtext.facet#tag' && feature.tag) { segments.push({ type: 'tag', text: rawText, href: `https://bsky.app/hashtag/${encodeURIComponent(feature.tag)}`, }); } else { segments.push({ type: 'text', text: rawText }); } cursor = end; } if (cursor < bytes.length) { segments.push({ type: 'text', text: sliceByBytes(bytes, cursor, bytes.length) }); } if (segments.length === 0) { return [{ type: 'text', text }]; } return segments; } function formatCompactNumber(value: number): string { return compactNumberFormatter.format(Math.max(0, value)); } function App() { const [token, setToken] = useState(() => localStorage.getItem('token')); const [authView, setAuthView] = useState('login'); const [bootstrapOpen, setBootstrapOpen] = useState(false); const [themeMode, setThemeMode] = useState(() => { const saved = localStorage.getItem('theme-mode'); if (saved === 'light' || saved === 'dark' || saved === 'system') { return saved; } return 'system'; }); 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: '' }); const [aiConfig, setAiConfig] = useState({ provider: 'gemini', apiKey: '', model: '', baseUrl: '' }); const [recentActivity, setRecentActivity] = useState([]); const [status, setStatus] = useState(null); const [runtimeVersion, setRuntimeVersion] = useState(null); const [updateStatus, setUpdateStatus] = useState(null); const [countdown, setCountdown] = useState('--'); const [activeTab, setActiveTab] = useState(() => { const fromPath = getTabFromPath(window.location.pathname); if (fromPath) { return fromPath; } const saved = localStorage.getItem('dashboard-tab'); if ( saved === 'overview' || saved === 'accounts' || saved === 'posts' || saved === 'activity' || saved === 'settings' ) { return saved; } return 'overview'; }); const [me, setMe] = useState(null); const [editingMapping, setEditingMapping] = useState(null); const [newMapping, setNewMapping] = useState(defaultMappingForm); const [newTwitterUsers, setNewTwitterUsers] = useState([]); const [newTwitterInput, setNewTwitterInput] = useState(''); 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 [isAddAccountSheetOpen, setIsAddAccountSheetOpen] = useState(false); const [addAccountStep, setAddAccountStep] = useState(1); const [settingsSectionOverrides, setSettingsSectionOverrides] = useState>>( {}, ); const [collapsedGroupKeys, setCollapsedGroupKeys] = useState>(() => { const raw = localStorage.getItem('accounts-collapsed-groups'); if (!raw) return {}; try { const parsed = JSON.parse(raw) as Record; return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } }); const [accountsViewMode, setAccountsViewMode] = useState<'grouped' | 'global'>('grouped'); const [accountsSearchQuery, setAccountsSearchQuery] = useState(''); const [postsGroupFilter, setPostsGroupFilter] = useState('all'); const [postsSearchQuery, setPostsSearchQuery] = useState(''); const [localPostSearchResults, setLocalPostSearchResults] = useState([]); const [isSearchingLocalPosts, setIsSearchingLocalPosts] = useState(false); const [activityGroupFilter, setActivityGroupFilter] = useState('all'); const [groupDraftsByKey, setGroupDraftsByKey] = useState>({}); const [isGroupActionBusy, setIsGroupActionBusy] = useState(false); const [notice, setNotice] = useState(null); const [managedUsers, setManagedUsers] = useState([]); const [accountsCreatorFilter, setAccountsCreatorFilter] = useState('all'); const [newUserForm, setNewUserForm] = useState(defaultUserForm); const [editingUserId, setEditingUserId] = useState(null); const [editingUserForm, setEditingUserForm] = useState(defaultUserForm); const [emailForm, setEmailForm] = useState({ currentEmail: '', newEmail: '', password: '', }); const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '', }); const [isBusy, setIsBusy] = useState(false); const [isUpdateBusy, setIsUpdateBusy] = useState(false); const [authError, setAuthError] = useState(''); const noticeTimerRef = useRef(null); const importInputRef = useRef(null); const postsSearchRequestRef = useRef(0); const isAdmin = me?.isAdmin ?? false; const effectivePermissions = useMemo(() => normalizePermissions(me?.permissions), [me?.permissions]); const canManageAllMappings = isAdmin || effectivePermissions.manageAllMappings; const canManageOwnMappings = isAdmin || effectivePermissions.manageOwnMappings; const canCreateMappings = canManageAllMappings || canManageOwnMappings; const canManageGroupsPermission = isAdmin || effectivePermissions.manageGroups; const canQueueBackfillsPermission = isAdmin || effectivePermissions.queueBackfills; const canRunNowPermission = isAdmin || effectivePermissions.runNow; const hasCurrentEmail = Boolean(me?.email && me.email.trim().length > 0); const authHeaders = useMemo(() => (token ? { Authorization: `Bearer ${token}` } : undefined), [token]); const showNotice = useCallback((tone: Notice['tone'], message: string) => { setNotice({ tone, message }); if (noticeTimerRef.current) { window.clearTimeout(noticeTimerRef.current); } noticeTimerRef.current = window.setTimeout(() => { setNotice(null); }, 4200); }, []); const handleLogout = useCallback(() => { localStorage.removeItem('token'); setToken(null); setMe(null); setMappings([]); setGroups([]); setEnrichedPosts([]); setProfilesByActor({}); setStatus(null); setRuntimeVersion(null); setUpdateStatus(null); setRecentActivity([]); setEditingMapping(null); setNewTwitterUsers([]); setEditTwitterUsers([]); setNewGroupName(''); setNewGroupEmoji(DEFAULT_GROUP_EMOJI); setIsAddAccountSheetOpen(false); setAddAccountStep(1); setSettingsSectionOverrides({}); setAccountsViewMode('grouped'); setAccountsSearchQuery(''); setPostsSearchQuery(''); setLocalPostSearchResults([]); setIsSearchingLocalPosts(false); setGroupDraftsByKey({}); setIsGroupActionBusy(false); setIsUpdateBusy(false); setManagedUsers([]); setAccountsCreatorFilter('all'); setNewUserForm(defaultUserForm()); setEditingUserId(null); setEditingUserForm(defaultUserForm()); setEmailForm({ currentEmail: '', newEmail: '', password: '' }); setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); postsSearchRequestRef.current = 0; setAuthView('login'); }, []); const handleAuthFailure = useCallback( (error: unknown, fallbackMessage: string) => { if (axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 403)) { handleLogout(); return; } showNotice('error', getApiErrorMessage(error, fallbackMessage)); }, [handleLogout, showNotice], ); const fetchBootstrapStatus = useCallback(async () => { try { const response = await axios.get('/api/auth/bootstrap-status'); setBootstrapOpen(Boolean(response.data?.bootstrapOpen)); } catch { setBootstrapOpen(false); } }, []); const fetchStatus = useCallback(async () => { if (!authHeaders) { return; } try { const response = await axios.get('/api/status', { headers: authHeaders }); setStatus(response.data); } catch (error) { handleAuthFailure(error, 'Failed to fetch status.'); } }, [authHeaders, handleAuthFailure]); const fetchRecentActivity = useCallback(async () => { if (!authHeaders) { return; } try { const response = await axios.get('/api/recent-activity?limit=20', { headers: authHeaders }); setRecentActivity(response.data); } catch (error) { handleAuthFailure(error, 'Failed to fetch activity.'); } }, [authHeaders, handleAuthFailure]); const fetchEnrichedPosts = useCallback(async () => { if (!authHeaders) { return; } try { const response = await axios.get('/api/posts/enriched?limit=36', { headers: authHeaders }); setEnrichedPosts(response.data); } catch (error) { handleAuthFailure(error, 'Failed to fetch Bluesky posts.'); } }, [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 fetchRuntimeVersion = useCallback(async () => { if (!authHeaders) { return; } try { const response = await axios.get('/api/version', { headers: authHeaders }); setRuntimeVersion(response.data); } catch (error) { handleAuthFailure(error, 'Failed to fetch app version.'); } }, [authHeaders, handleAuthFailure]); const fetchUpdateStatus = useCallback(async () => { if (!authHeaders || !isAdmin) { return; } try { const response = await axios.get('/api/update-status', { headers: authHeaders }); setUpdateStatus(response.data); } catch (error) { handleAuthFailure(error, 'Failed to fetch update status.'); } }, [authHeaders, handleAuthFailure, isAdmin]); const fetchManagedUsers = useCallback(async () => { if (!authHeaders || !isAdmin) { setManagedUsers([]); return; } try { const response = await axios.get('/api/admin/users', { headers: authHeaders }); setManagedUsers(Array.isArray(response.data) ? response.data : []); } catch (error) { handleAuthFailure(error, 'Failed to fetch dashboard users.'); } }, [authHeaders, handleAuthFailure, isAdmin]); const fetchProfiles = useCallback( async (actors: string[]) => { if (!authHeaders) { return; } const normalizedActors = [...new Set(actors.map(normalizeTwitterUsername).filter((actor) => actor.length > 0))]; if (normalizedActors.length === 0) { setProfilesByActor({}); return; } try { const response = await axios.post>( '/api/bsky/profiles', { actors: normalizedActors }, { headers: authHeaders }, ); setProfilesByActor(response.data || {}); } catch (error) { handleAuthFailure(error, 'Failed to resolve Bluesky profiles.'); } }, [authHeaders, handleAuthFailure], ); const fetchData = useCallback(async () => { if (!authHeaders) { return; } try { 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 = Array.isArray(mappingsResponse.data) ? mappingsResponse.data : []; const groupData = Array.isArray(groupsResponse.data) ? groupsResponse.data : []; setMe({ ...profile, permissions: normalizePermissions(profile.permissions), }); setMappings(mappingData); setGroups(groupData); setEmailForm((previous) => ({ ...previous, currentEmail: profile.email || '', })); const versionResponse = await axios.get('/api/version', { headers: authHeaders }); setRuntimeVersion(versionResponse.data); if (profile.isAdmin) { const [twitterResponse, aiResponse, updateStatusResponse, usersResponse] = await Promise.all([ axios.get('/api/twitter-config', { headers: authHeaders }), axios.get('/api/ai-config', { headers: authHeaders }), axios.get('/api/update-status', { headers: authHeaders }), axios.get('/api/admin/users', { headers: authHeaders }), ]); setTwitterConfig({ authToken: twitterResponse.data.authToken || '', ct0: twitterResponse.data.ct0 || '', backupAuthToken: twitterResponse.data.backupAuthToken || '', backupCt0: twitterResponse.data.backupCt0 || '', }); setAiConfig({ provider: aiResponse.data.provider || 'gemini', apiKey: aiResponse.data.apiKey || '', model: aiResponse.data.model || '', baseUrl: aiResponse.data.baseUrl || '', }); setUpdateStatus(updateStatusResponse.data); setManagedUsers(Array.isArray(usersResponse.data) ? usersResponse.data : []); } else { setUpdateStatus(null); setManagedUsers([]); } await Promise.all([fetchStatus(), fetchRecentActivity(), fetchEnrichedPosts()]); await fetchProfiles(mappingData.map((mapping) => mapping.bskyIdentifier)); } catch (error) { handleAuthFailure(error, 'Failed to load dashboard data.'); } }, [authHeaders, fetchEnrichedPosts, fetchProfiles, fetchRecentActivity, fetchStatus, handleAuthFailure]); useEffect(() => { localStorage.setItem('theme-mode', themeMode); }, [themeMode]); useEffect(() => { localStorage.setItem('dashboard-tab', activeTab); }, [activeTab]); useEffect(() => { const expectedPath = TAB_PATHS[activeTab]; const currentPath = normalizePath(window.location.pathname); if (currentPath !== expectedPath) { window.history.pushState({ tab: activeTab }, '', expectedPath); } }, [activeTab]); useEffect(() => { const onPopState = () => { const tabFromPath = getTabFromPath(window.location.pathname); if (tabFromPath) { setActiveTab(tabFromPath); } else { setActiveTab('overview'); } }; window.addEventListener('popstate', onPopState); return () => { window.removeEventListener('popstate', onPopState); }; }, []); useEffect(() => { localStorage.setItem('accounts-collapsed-groups', JSON.stringify(collapsedGroupKeys)); }, [collapsedGroupKeys]); useEffect(() => { const media = window.matchMedia('(prefers-color-scheme: dark)'); const applyTheme = () => { const next = themeMode === 'system' ? (media.matches ? 'dark' : 'light') : themeMode; setResolvedTheme(next); document.documentElement.classList.remove('light', 'dark'); document.documentElement.classList.add(next); }; applyTheme(); media.addEventListener('change', applyTheme); return () => { media.removeEventListener('change', applyTheme); }; }, [themeMode]); useEffect(() => { if (!token) { void fetchBootstrapStatus(); return; } void fetchData(); }, [token, fetchBootstrapStatus, fetchData]); useEffect(() => { if (!bootstrapOpen && authView === 'register') { setAuthView('login'); } }, [authView, bootstrapOpen]); useEffect(() => { if (!token) { return; } const statusInterval = window.setInterval(() => { void fetchStatus(); }, 2000); const activityInterval = window.setInterval(() => { void fetchRecentActivity(); }, 7000); const postsInterval = window.setInterval(() => { void fetchEnrichedPosts(); }, 12000); return () => { window.clearInterval(statusInterval); window.clearInterval(activityInterval); window.clearInterval(postsInterval); }; }, [token, fetchEnrichedPosts, fetchRecentActivity, fetchStatus]); useEffect(() => { if (!token) { return; } const versionInterval = window.setInterval(() => { void fetchRuntimeVersion(); if (isAdmin) { void fetchUpdateStatus(); } }, 15000); return () => { window.clearInterval(versionInterval); }; }, [token, isAdmin, fetchRuntimeVersion, fetchUpdateStatus]); useEffect(() => { if (!status?.nextCheckTime) { setCountdown('--'); return; } const updateCountdown = () => { const ms = status.nextCheckTime - Date.now(); if (ms <= 0) { setCountdown('Checking...'); return; } const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); setCountdown(`${minutes}m ${String(seconds).padStart(2, '0')}s`); }; updateCountdown(); const timer = window.setInterval(updateCountdown, 1000); return () => { window.clearInterval(timer); }; }, [status?.nextCheckTime]); useEffect(() => { return () => { if (noticeTimerRef.current) { window.clearTimeout(noticeTimerRef.current); } }; }, []); useEffect(() => { if (!isAddAccountSheetOpen) { return; } const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { closeAddAccountSheet(); } }; window.addEventListener('keydown', onKeyDown); return () => { window.removeEventListener('keydown', onKeyDown); }; }, [isAddAccountSheetOpen]); const pendingBackfills = status?.pendingBackfills ?? []; const currentStatus = status?.currentStatus; const latestActivity = recentActivity[0]; const dashboardTabs = useMemo( () => [ { id: 'overview' as DashboardTab, label: 'Overview', icon: LayoutDashboard }, { id: 'accounts' as DashboardTab, label: 'Accounts', icon: Users }, { id: 'posts' as DashboardTab, label: 'Posts', icon: Newspaper }, { id: 'activity' as DashboardTab, label: 'Activity', icon: History }, { id: 'settings' as DashboardTab, label: 'Settings', icon: Settings2 }, ], [], ); const postedActivity = useMemo(() => enrichedPosts.slice(0, 12), [enrichedPosts]); const engagementByAccount = useMemo(() => { const map = new Map(); for (const post of enrichedPosts) { const key = normalizeTwitterUsername(post.bskyIdentifier); const existing = map.get(key) || { identifier: post.bskyIdentifier, score: 0, posts: 0, }; existing.score += post.stats.engagement || 0; existing.posts += 1; map.set(key, existing); } return [...map.values()].sort((a, b) => b.score - a.score); }, [enrichedPosts]); const topAccount = engagementByAccount[0]; const getProfileForActor = useCallback( (actor: string) => profilesByActor[normalizeTwitterUsername(actor)], [profilesByActor], ); const topAccountProfile = topAccount ? getProfileForActor(topAccount.identifier) : undefined; const mappingsByBskyIdentifier = useMemo(() => { const map = new Map(); for (const mapping of mappings) { map.set(normalizeTwitterUsername(mapping.bskyIdentifier), mapping); } return map; }, [mappings]); const mappingsByTwitterUsername = useMemo(() => { const map = new Map(); for (const mapping of mappings) { for (const username of mapping.twitterUsernames) { map.set(normalizeTwitterUsername(username), mapping); } } return map; }, [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); options.set(group.key, options.get(group.key) || group); } return [...options.values()].sort((a, b) => { const aUngrouped = a.name === DEFAULT_GROUP_NAME; const bUngrouped = b.name === DEFAULT_GROUP_NAME; if (aUngrouped && !bUngrouped) return 1; if (!aUngrouped && bUngrouped) return -1; return a.name.localeCompare(b.name); }); }, [groups, mappings]); const groupOptionsByKey = useMemo(() => new Map(groupOptions.map((group) => [group.key, group])), [groupOptions]); const reusableGroupOptions = useMemo( () => groupOptions.filter((group) => group.key !== DEFAULT_GROUP_KEY), [groupOptions], ); const managedUsersById = useMemo(() => new Map(managedUsers.map((user) => [user.id, user])), [managedUsers]); const accountMappingsForView = useMemo(() => { if (!isAdmin || accountsCreatorFilter === 'all') { return mappings; } return mappings.filter((mapping) => mapping.createdByUserId === accountsCreatorFilter); }, [accountsCreatorFilter, isAdmin, mappings]); const groupedMappings = useMemo(() => { const groups = new Map(); for (const option of groupOptions) { groups.set(option.key, { ...option, mappings: [], }); } for (const mapping of accountMappingsForView) { const group = getMappingGroupMeta(mapping); const existing = groups.get(group.key); if (!existing) { groups.set(group.key, { ...group, mappings: [mapping] }); continue; } existing.mappings.push(mapping); } return [...groups.values()] .sort((a, b) => { const aUngrouped = a.name === DEFAULT_GROUP_NAME; const bUngrouped = b.name === DEFAULT_GROUP_NAME; if (aUngrouped && !bUngrouped) return 1; if (!aUngrouped && bUngrouped) return -1; return a.name.localeCompare(b.name); }) .map((group) => ({ ...group, mappings: [...group.mappings].sort((a, b) => `${(a.owner || '').toLowerCase()}-${a.bskyIdentifier.toLowerCase()}`.localeCompare( `${(b.owner || '').toLowerCase()}-${b.bskyIdentifier.toLowerCase()}`, ), ), })); }, [accountMappingsForView, groupOptions]); const normalizedAccountsQuery = useMemo(() => normalizeSearchValue(accountsSearchQuery), [accountsSearchQuery]); const accountSearchTokens = useMemo(() => tokenizeSearchValue(normalizedAccountsQuery), [normalizedAccountsQuery]); const accountSearchScores = useMemo(() => { const scores = new Map(); if (!normalizedAccountsQuery) { return scores; } for (const mapping of accountMappingsForView) { scores.set(mapping.id, scoreAccountMapping(mapping, normalizedAccountsQuery, accountSearchTokens)); } return scores; }, [accountMappingsForView, accountSearchTokens, normalizedAccountsQuery]); const filteredGroupedMappings = useMemo(() => { const hasQuery = normalizedAccountsQuery.length > 0; const sortByScore = (items: AccountMapping[]) => { if (!hasQuery) { return items; } return [...items].sort((a, b) => { const scoreDelta = (accountSearchScores.get(b.id) || 0) - (accountSearchScores.get(a.id) || 0); if (scoreDelta !== 0) return scoreDelta; return `${(a.owner || '').toLowerCase()}-${a.bskyIdentifier.toLowerCase()}`.localeCompare( `${(b.owner || '').toLowerCase()}-${b.bskyIdentifier.toLowerCase()}`, ); }); }; const withSearch = groupedMappings .map((group) => { const mappingsForGroup = hasQuery ? group.mappings.filter((mapping) => (accountSearchScores.get(mapping.id) || 0) >= ACCOUNT_SEARCH_MIN_SCORE) : group.mappings; return { ...group, mappings: sortByScore(mappingsForGroup), }; }) .filter((group) => !hasQuery || group.mappings.length > 0); if (accountsViewMode === 'grouped') { return withSearch; } const allMappings = sortByScore( hasQuery ? accountMappingsForView.filter( (mapping) => (accountSearchScores.get(mapping.id) || 0) >= ACCOUNT_SEARCH_MIN_SCORE, ) : [...accountMappingsForView].sort((a, b) => `${(a.owner || '').toLowerCase()}-${a.bskyIdentifier.toLowerCase()}`.localeCompare( `${(b.owner || '').toLowerCase()}-${b.bskyIdentifier.toLowerCase()}`, ), ), ); return [ { key: '__all__', name: hasQuery ? 'Search Results' : 'All Accounts', emoji: hasQuery ? '🔎' : '🌐', mappings: allMappings, }, ]; }, [accountMappingsForView, accountSearchScores, accountsViewMode, groupedMappings, normalizedAccountsQuery]); const accountMatchesCount = useMemo( () => filteredGroupedMappings.reduce((total, group) => total + group.mappings.length, 0), [filteredGroupedMappings], ); const groupKeysForCollapse = useMemo(() => groupedMappings.map((group) => group.key), [groupedMappings]); const allGroupsCollapsed = useMemo( () => groupKeysForCollapse.length > 0 && groupKeysForCollapse.every((groupKey) => collapsedGroupKeys[groupKey] === true), [collapsedGroupKeys, groupKeysForCollapse], ); const resolveMappingForLocalPost = useCallback( (post: LocalPostSearchResult) => mappingsByBskyIdentifier.get(normalizeTwitterUsername(post.bskyIdentifier)) || mappingsByTwitterUsername.get(normalizeTwitterUsername(post.twitterUsername)), [mappingsByBskyIdentifier, mappingsByTwitterUsername], ); const resolveMappingForPost = useCallback( (post: EnrichedPost) => mappingsByBskyIdentifier.get(normalizeTwitterUsername(post.bskyIdentifier)) || mappingsByTwitterUsername.get(normalizeTwitterUsername(post.twitterUsername)), [mappingsByBskyIdentifier, mappingsByTwitterUsername], ); const resolveMappingForActivity = useCallback( (activity: ActivityLog) => mappingsByBskyIdentifier.get(normalizeTwitterUsername(activity.bsky_identifier)) || mappingsByTwitterUsername.get(normalizeTwitterUsername(activity.twitter_username)), [mappingsByBskyIdentifier, mappingsByTwitterUsername], ); const filteredPostedActivity = useMemo( () => postedActivity.filter((post) => { if (postsGroupFilter === 'all') return true; const mapping = resolveMappingForPost(post); return getMappingGroupMeta(mapping).key === postsGroupFilter; }), [postedActivity, postsGroupFilter, resolveMappingForPost], ); const filteredLocalPostSearchResults = useMemo( () => localPostSearchResults.filter((post) => { if (postsGroupFilter === 'all') return true; const mapping = resolveMappingForLocalPost(post); return getMappingGroupMeta(mapping).key === postsGroupFilter; }), [localPostSearchResults, postsGroupFilter, resolveMappingForLocalPost], ); const filteredRecentActivity = useMemo( () => recentActivity.filter((activity) => { if (activityGroupFilter === 'all') return true; const mapping = resolveMappingForActivity(activity); return getMappingGroupMeta(mapping).key === activityGroupFilter; }), [activityGroupFilter, recentActivity, resolveMappingForActivity], ); const canManageMapping = useCallback( (mapping: AccountMapping) => canManageAllMappings || (canManageOwnMappings && (!mapping.createdByUserId || mapping.createdByUserId === me?.id)), [canManageAllMappings, canManageOwnMappings, me?.id], ); const twitterConfigured = Boolean(twitterConfig.authToken && twitterConfig.ct0); const aiConfigured = Boolean(aiConfig.apiKey); const sectionDefaultExpanded = useMemo>( () => ({ account: true, users: true, twitter: !twitterConfigured, ai: !aiConfigured, data: false, }), [aiConfigured, twitterConfigured], ); const isSettingsSectionExpanded = useCallback( (section: SettingsSection) => settingsSectionOverrides[section] ?? sectionDefaultExpanded[section], [sectionDefaultExpanded, settingsSectionOverrides], ); const toggleSettingsSection = (section: SettingsSection) => { setSettingsSectionOverrides((previous) => ({ ...previous, [section]: !(previous[section] ?? sectionDefaultExpanded[section]), })); }; useEffect(() => { if (postsGroupFilter !== 'all' && !groupOptions.some((group) => group.key === postsGroupFilter)) { setPostsGroupFilter('all'); } if (activityGroupFilter !== 'all' && !groupOptions.some((group) => group.key === activityGroupFilter)) { setActivityGroupFilter('all'); } }, [activityGroupFilter, groupOptions, postsGroupFilter]); useEffect(() => { if (!isAdmin) { if (accountsCreatorFilter !== 'all') { setAccountsCreatorFilter('all'); } return; } if (accountsCreatorFilter !== 'all' && !managedUsers.some((user) => user.id === accountsCreatorFilter)) { setAccountsCreatorFilter('all'); } }, [accountsCreatorFilter, isAdmin, managedUsers]); useEffect(() => { setGroupDraftsByKey((previous) => { const next: Record = {}; for (const group of reusableGroupOptions) { const existing = previous[group.key]; next[group.key] = { name: existing?.name ?? group.name, emoji: existing?.emoji ?? group.emoji, }; } return next; }); }, [reusableGroupOptions]); useEffect(() => { if (!authHeaders) { setIsSearchingLocalPosts(false); setLocalPostSearchResults([]); return; } const query = postsSearchQuery.trim(); if (!query) { postsSearchRequestRef.current += 1; setIsSearchingLocalPosts(false); setLocalPostSearchResults([]); return; } const requestId = postsSearchRequestRef.current + 1; postsSearchRequestRef.current = requestId; setIsSearchingLocalPosts(true); const timer = window.setTimeout(async () => { try { const response = await axios.get('/api/posts/search', { params: { q: query, limit: 120 }, headers: authHeaders, }); if (postsSearchRequestRef.current !== requestId) { return; } setLocalPostSearchResults(Array.isArray(response.data) ? response.data : []); } catch (error) { if (postsSearchRequestRef.current !== requestId) { return; } setLocalPostSearchResults([]); handleAuthFailure(error, 'Failed to search local post history.'); } finally { if (postsSearchRequestRef.current === requestId) { setIsSearchingLocalPosts(false); } } }, 220); return () => { window.clearTimeout(timer); }; }, [authHeaders, handleAuthFailure, postsSearchQuery]); const isBackfillQueued = useCallback( (mappingId: string) => pendingBackfills.some((entry) => entry.id === mappingId), [pendingBackfills], ); const getBackfillEntry = useCallback( (mappingId: string) => pendingBackfills.find((entry) => entry.id === mappingId), [pendingBackfills], ); const isBackfillActive = useCallback( (mappingId: string) => currentStatus?.state === 'backfilling' && currentStatus.backfillMappingId === mappingId, [currentStatus], ); const progressPercent = useMemo(() => { if (!currentStatus?.totalCount || currentStatus.totalCount <= 0) { return 0; } const processed = currentStatus.processedCount || 0; return Math.max(0, Math.min(100, Math.round((processed / currentStatus.totalCount) * 100))); }, [currentStatus]); const cycleThemeMode = () => { setThemeMode((prev) => { if (prev === 'system') return 'light'; if (prev === 'light') return 'dark'; return 'system'; }); }; const themeIcon = themeMode === 'system' ? ( ) : themeMode === 'light' ? ( ) : ( ); const themeLabel = themeMode === 'system' ? `Theme: system (${resolvedTheme})` : `Theme: ${themeMode}`; const runtimeVersionLabel = runtimeVersion ? `v${runtimeVersion.version}${runtimeVersion.commit ? ` (${runtimeVersion.commit})` : ''}` : 'v--'; const runtimeBranchLabel = runtimeVersion?.branch ? `branch ${runtimeVersion.branch}` : null; const updateStateLabel = updateStatus?.running ? 'Update in progress' : updateStatus?.finishedAt ? updateStatus.exitCode === 0 ? 'Last update succeeded' : 'Last update failed' : 'No update run recorded'; const handleLogin = async (event: React.FormEvent) => { event.preventDefault(); setAuthError(''); setIsBusy(true); const data = new FormData(event.currentTarget); const identifier = String(data.get('identifier') || '').trim(); const password = String(data.get('password') || ''); try { const response = await axios.post<{ token: string }>('/api/login', { identifier, password }); localStorage.setItem('token', response.data.token); setToken(response.data.token); showNotice('success', 'Logged in.'); } catch (error) { setAuthError(getApiErrorMessage(error, 'Invalid credentials.')); } finally { setIsBusy(false); } }; const handleRegister = async (event: React.FormEvent) => { event.preventDefault(); setAuthError(''); setIsBusy(true); const data = new FormData(event.currentTarget); const username = String(data.get('username') || '').trim(); const email = String(data.get('email') || '').trim(); const password = String(data.get('password') || ''); try { await axios.post('/api/register', { username, email, password }); setAuthView('login'); showNotice('success', 'Registration successful. Please log in.'); await fetchBootstrapStatus(); } catch (error) { setAuthError(getApiErrorMessage(error, 'Registration failed.')); } finally { setIsBusy(false); } }; const runNow = async () => { if (!authHeaders) { return; } if (!canRunNowPermission) { showNotice('error', 'You do not have permission to run checks now.'); return; } try { await axios.post('/api/run-now', {}, { headers: authHeaders }); showNotice('info', 'Check triggered.'); await fetchStatus(); } catch (error) { handleAuthFailure(error, 'Failed to trigger a check.'); } }; const clearAllBackfills = async () => { if (!authHeaders) { return; } const confirmed = window.confirm('Stop all pending and active backfills?'); if (!confirmed) { return; } try { await axios.post('/api/backfill/clear-all', {}, { headers: authHeaders }); showNotice('success', 'Backfill queue cleared.'); await fetchStatus(); } catch (error) { handleAuthFailure(error, 'Failed to clear backfill queue.'); } }; const requestBackfill = async (mappingId: string, mode: 'normal' | 'reset') => { if (!authHeaders) { return; } const mapping = mappings.find((entry) => entry.id === mappingId); if (!mapping || !canQueueBackfillsPermission || !canManageMapping(mapping)) { showNotice('error', 'You do not have permission to queue backfill for this account.'); return; } const busy = pendingBackfills.length > 0 || currentStatus?.state === 'backfilling'; if (busy) { const proceed = window.confirm( 'Backfill is already queued or active. This request will replace the existing queue item for this account. Continue?', ); if (!proceed) { return; } } const safeLimit = DEFAULT_BACKFILL_LIMIT; try { if (mode === 'reset') { if (!isAdmin) { showNotice('error', 'Only admins can reset cache before backfill.'); return; } await axios.delete(`/api/mappings/${mappingId}/cache`, { headers: authHeaders }); } await axios.post(`/api/backfill/${mappingId}`, { limit: safeLimit }, { headers: authHeaders }); showNotice( 'success', mode === 'reset' ? `Cache reset and backfill queued (${safeLimit} tweets).` : `Backfill queued (${safeLimit} tweets).`, ); await fetchStatus(); } catch (error) { handleAuthFailure(error, 'Failed to queue backfill.'); } }; const handleDeleteAllPosts = async (mappingId: string) => { if (!authHeaders) { return; } const firstConfirm = window.confirm( 'Danger: this deletes all posts on the mapped Bluesky account and clears local cache. Continue?', ); if (!firstConfirm) { return; } const finalConfirm = window.prompt('Type DELETE to confirm:'); if (finalConfirm !== 'DELETE') { return; } try { const response = await axios.post<{ message: string }>( `/api/mappings/${mappingId}/delete-all-posts`, {}, { headers: authHeaders }, ); showNotice('success', response.data.message); } catch (error) { handleAuthFailure(error, 'Failed to delete posts.'); } }; const handleDeleteMapping = async (mappingId: string) => { if (!authHeaders) { return; } const mapping = mappings.find((entry) => entry.id === mappingId); if (!mapping || !canManageMapping(mapping)) { showNotice('error', 'You do not have permission to delete this mapping.'); return; } const confirmed = window.confirm('Delete this mapping?'); if (!confirmed) { return; } try { await axios.delete(`/api/mappings/${mappingId}`, { headers: authHeaders }); setMappings((prev) => prev.filter((mapping) => mapping.id !== mappingId)); showNotice('success', 'Mapping deleted.'); await fetchData(); } catch (error) { handleAuthFailure(error, 'Failed to delete mapping.'); } }; const addNewTwitterUsername = () => { setNewTwitterUsers((previous) => addTwitterUsernames(previous, newTwitterInput)); setNewTwitterInput(''); }; const removeNewTwitterUsername = (username: string) => { setNewTwitterUsers((previous) => previous.filter((existing) => normalizeTwitterUsername(existing) !== normalizeTwitterUsername(username)), ); }; const addEditTwitterUsername = () => { setEditTwitterUsers((previous) => addTwitterUsernames(previous, editTwitterInput)); setEditTwitterInput(''); }; const removeEditTwitterUsername = (username: string) => { setEditTwitterUsers((previous) => previous.filter((existing) => normalizeTwitterUsername(existing) !== normalizeTwitterUsername(username)), ); }; const toggleGroupCollapsed = (groupKey: string) => { setCollapsedGroupKeys((previous) => ({ ...previous, [groupKey]: !previous[groupKey], })); }; const toggleCollapseAllGroups = () => { const shouldCollapse = !allGroupsCollapsed; setCollapsedGroupKeys((previous) => { const next = { ...previous }; for (const groupKey of groupKeysForCollapse) { next[groupKey] = shouldCollapse; } return next; }); }; const handleCreateGroup = async (event: React.FormEvent) => { event.preventDefault(); if (!authHeaders) { return; } if (!canManageGroupsPermission) { showNotice('error', 'You do not have permission to create groups.'); 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; } if (!canManageMapping(mapping)) { showNotice('error', 'You do not have permission to update this mapping.'); 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 updateGroupDraft = (groupKey: string, field: 'name' | 'emoji', value: string) => { setGroupDraftsByKey((previous) => ({ ...previous, [groupKey]: { name: previous[groupKey]?.name ?? '', emoji: previous[groupKey]?.emoji ?? '', [field]: value, }, })); }; const handleRenameGroup = async (groupKey: string) => { if (!authHeaders) { return; } if (!canManageGroupsPermission) { showNotice('error', 'You do not have permission to rename groups.'); return; } const draft = groupDraftsByKey[groupKey]; if (!draft || !draft.name.trim()) { showNotice('error', 'Group name is required.'); return; } setIsGroupActionBusy(true); try { await axios.put( `/api/groups/${encodeURIComponent(groupKey)}`, { name: draft.name.trim(), emoji: draft.emoji.trim(), }, { headers: authHeaders }, ); showNotice('success', 'Group updated.'); await fetchData(); } catch (error) { handleAuthFailure(error, 'Failed to update group.'); } finally { setIsGroupActionBusy(false); } }; const handleDeleteGroup = async (groupKey: string) => { if (!authHeaders) { return; } if (!canManageGroupsPermission) { showNotice('error', 'You do not have permission to delete groups.'); return; } const group = groupOptionsByKey.get(groupKey); if (!group) { showNotice('error', 'Group not found.'); return; } const confirmed = window.confirm( `Delete "${group.name}"? Mappings in this folder will move to ${DEFAULT_GROUP_NAME}.`, ); if (!confirmed) { return; } setIsGroupActionBusy(true); try { const response = await axios.delete<{ reassignedCount?: number }>(`/api/groups/${encodeURIComponent(groupKey)}`, { headers: authHeaders, }); const reassignedCount = response.data?.reassignedCount || 0; showNotice('success', `Group deleted. ${reassignedCount} account${reassignedCount === 1 ? '' : 's'} moved.`); await fetchData(); } catch (error) { handleAuthFailure(error, 'Failed to delete group.'); } finally { setIsGroupActionBusy(false); } }; const resetAddAccountDraft = () => { setNewMapping({ ...defaultMappingForm(), owner: getUserLabel(me), }); setNewTwitterUsers([]); setNewTwitterInput(''); setAddAccountStep(1); }; const openAddAccountSheet = () => { if (!canCreateMappings) { showNotice('error', 'You do not have permission to add mappings.'); return; } resetAddAccountDraft(); setIsAddAccountSheetOpen(true); }; const closeAddAccountSheet = () => { setIsAddAccountSheetOpen(false); resetAddAccountDraft(); }; const applyGroupPresetToNewMapping = (groupKey: string) => { const group = groupOptionsByKey.get(groupKey); if (!group || group.key === DEFAULT_GROUP_KEY) { return; } setNewMapping((previous) => ({ ...previous, groupName: group.name, groupEmoji: group.emoji, })); }; const submitNewMapping = async () => { if (!authHeaders) { return; } if (!canCreateMappings) { showNotice('error', 'You do not have permission to add mappings.'); return; } if (newTwitterUsers.length === 0) { showNotice('error', 'Add at least one Twitter username.'); return; } setIsBusy(true); try { await axios.post( '/api/mappings', { owner: newMapping.owner.trim(), twitterUsernames: newTwitterUsers, bskyIdentifier: newMapping.bskyIdentifier.trim(), bskyPassword: newMapping.bskyPassword, bskyServiceUrl: newMapping.bskyServiceUrl.trim(), groupName: newMapping.groupName.trim(), groupEmoji: newMapping.groupEmoji.trim(), }, { headers: authHeaders }, ); setNewMapping(defaultMappingForm()); setNewTwitterUsers([]); setNewTwitterInput(''); setIsAddAccountSheetOpen(false); setAddAccountStep(1); showNotice('success', 'Account mapping added.'); await fetchData(); } catch (error) { handleAuthFailure(error, 'Failed to add account mapping.'); } finally { setIsBusy(false); } }; const advanceAddAccountStep = () => { if (addAccountStep === 1) { if (!newMapping.owner.trim()) { showNotice('error', 'Owner is required.'); return; } setAddAccountStep(2); return; } if (addAccountStep === 2) { if (newTwitterUsers.length === 0) { showNotice('error', 'Add at least one Twitter username.'); return; } setAddAccountStep(3); return; } if (addAccountStep === 3) { if (!newMapping.bskyIdentifier.trim() || !newMapping.bskyPassword.trim()) { showNotice('error', 'Bluesky identifier and app password are required.'); return; } setAddAccountStep(4); } }; const retreatAddAccountStep = () => { setAddAccountStep((previous) => Math.max(1, previous - 1)); }; const startEditMapping = (mapping: AccountMapping) => { if (!canManageMapping(mapping)) { showNotice('error', 'You do not have permission to edit this mapping.'); return; } setEditingMapping(mapping); setEditForm({ owner: mapping.owner || '', bskyIdentifier: mapping.bskyIdentifier, bskyPassword: '', bskyServiceUrl: mapping.bskyServiceUrl || 'https://bsky.social', groupName: mapping.groupName || '', groupEmoji: mapping.groupEmoji || '📁', }); setEditTwitterUsers(mapping.twitterUsernames); setEditTwitterInput(''); }; const handleUpdateMapping = async (event: React.FormEvent) => { event.preventDefault(); if (!authHeaders || !editingMapping) { return; } if (!canManageMapping(editingMapping)) { showNotice('error', 'You do not have permission to edit this mapping.'); return; } if (editTwitterUsers.length === 0) { showNotice('error', 'At least one Twitter username is required.'); return; } setIsBusy(true); try { await axios.put( `/api/mappings/${editingMapping.id}`, { owner: editForm.owner.trim(), twitterUsernames: editTwitterUsers, bskyIdentifier: editForm.bskyIdentifier.trim(), bskyPassword: editForm.bskyPassword, bskyServiceUrl: editForm.bskyServiceUrl.trim(), groupName: editForm.groupName.trim(), groupEmoji: editForm.groupEmoji.trim(), }, { headers: authHeaders }, ); setEditingMapping(null); setEditForm(defaultMappingForm()); setEditTwitterUsers([]); setEditTwitterInput(''); showNotice('success', 'Mapping updated.'); await fetchData(); } catch (error) { handleAuthFailure(error, 'Failed to update mapping.'); } finally { setIsBusy(false); } }; const handleSaveTwitterConfig = async (event: React.FormEvent) => { event.preventDefault(); if (!authHeaders) { return; } setIsBusy(true); try { await axios.post( '/api/twitter-config', { authToken: twitterConfig.authToken, ct0: twitterConfig.ct0, backupAuthToken: twitterConfig.backupAuthToken, backupCt0: twitterConfig.backupCt0, }, { headers: authHeaders }, ); showNotice('success', 'Twitter credentials saved.'); await fetchData(); } catch (error) { handleAuthFailure(error, 'Failed to save Twitter credentials.'); } finally { setIsBusy(false); } }; const handleSaveAiConfig = async (event: React.FormEvent) => { event.preventDefault(); if (!authHeaders) { return; } setIsBusy(true); try { await axios.post( '/api/ai-config', { provider: aiConfig.provider, apiKey: aiConfig.apiKey, model: aiConfig.model, baseUrl: aiConfig.baseUrl, }, { headers: authHeaders }, ); showNotice('success', 'AI settings saved.'); await fetchData(); } catch (error) { handleAuthFailure(error, 'Failed to save AI settings.'); } finally { setIsBusy(false); } }; const handleExportConfig = async () => { if (!authHeaders) { return; } try { const response = await axios.get('/api/config/export', { headers: authHeaders, responseType: 'blob', }); const blobUrl = window.URL.createObjectURL(new Blob([response.data])); const link = document.createElement('a'); link.href = blobUrl; link.download = `tweets-2-bsky-config-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(blobUrl); showNotice('success', 'Configuration exported.'); } catch (error) { handleAuthFailure(error, 'Failed to export configuration.'); } }; const handleImportConfig = async (event: React.ChangeEvent) => { if (!authHeaders) { return; } const file = event.target.files?.[0]; if (!file) { return; } const confirmed = window.confirm( 'This will overwrite accounts/settings (except user logins). Continue with import?', ); if (!confirmed) { event.target.value = ''; return; } try { const text = await file.text(); const json = JSON.parse(text); await axios.post('/api/config/import', json, { headers: authHeaders }); showNotice('success', 'Configuration imported.'); await fetchData(); } catch (error) { handleAuthFailure(error, 'Failed to import configuration.'); } finally { event.target.value = ''; } }; const handleRunUpdate = async () => { if (!authHeaders || !isAdmin) { return; } const confirmed = window.confirm( 'Run ./update.sh now? The service may restart automatically after update completes.', ); if (!confirmed) { return; } setIsUpdateBusy(true); try { const response = await axios.post<{ message?: string }>('/api/update', {}, { headers: authHeaders }); showNotice('info', response.data?.message || 'Update started. Service may restart soon.'); await Promise.all([fetchRuntimeVersion(), fetchUpdateStatus()]); } catch (error) { handleAuthFailure(error, 'Failed to start update.'); } finally { setIsUpdateBusy(false); } }; const beginEditUser = (user: ManagedUser) => { setEditingUserId(user.id); setEditingUserForm({ username: user.username || '', email: user.email || '', password: '', isAdmin: user.isAdmin, permissions: normalizePermissions(user.permissions), }); }; const resetEditingUser = () => { setEditingUserId(null); setEditingUserForm(defaultUserForm()); }; const handleCreateUser = async (event: React.FormEvent) => { event.preventDefault(); if (!authHeaders || !isAdmin) { return; } const username = normalizeUsername(newUserForm.username); const email = normalizeEmail(newUserForm.email); if (!username && !email) { showNotice('error', 'Provide at least a username or email.'); return; } if (!newUserForm.password || newUserForm.password.length < 8) { showNotice('error', 'Password must be at least 8 characters.'); return; } setIsBusy(true); try { await axios.post( '/api/admin/users', { username: username || undefined, email: email || undefined, password: newUserForm.password, isAdmin: newUserForm.isAdmin, permissions: newUserForm.permissions, }, { headers: authHeaders }, ); setNewUserForm(defaultUserForm()); showNotice('success', 'User account created.'); await fetchManagedUsers(); } catch (error) { handleAuthFailure(error, 'Failed to create user.'); } finally { setIsBusy(false); } }; const handleSaveEditedUser = async (userId: string) => { if (!authHeaders || !isAdmin) { return; } const username = normalizeUsername(editingUserForm.username); const email = normalizeEmail(editingUserForm.email); if (!username && !email) { showNotice('error', 'Provide at least a username or email.'); return; } setIsBusy(true); try { await axios.put( `/api/admin/users/${userId}`, { username: username || undefined, email: email || undefined, isAdmin: editingUserForm.isAdmin, permissions: editingUserForm.permissions, }, { headers: authHeaders }, ); showNotice('success', 'User updated.'); resetEditingUser(); await Promise.all([fetchManagedUsers(), fetchData()]); } catch (error) { handleAuthFailure(error, 'Failed to update user.'); } finally { setIsBusy(false); } }; const handleResetUserPassword = async (userId: string) => { if (!authHeaders || !isAdmin) { return; } const newPassword = window.prompt('Enter a new password (min 8 chars):'); if (!newPassword) { return; } if (newPassword.length < 8) { showNotice('error', 'Password must be at least 8 characters.'); return; } setIsBusy(true); try { await axios.post( `/api/admin/users/${userId}/reset-password`, { newPassword, }, { headers: authHeaders }, ); showNotice('success', 'Password reset.'); } catch (error) { handleAuthFailure(error, 'Failed to reset password.'); } finally { setIsBusy(false); } }; const handleDeleteUser = async (user: ManagedUser) => { if (!authHeaders || !isAdmin) { return; } const confirmed = window.confirm( `Delete ${user.username || user.email || user.id}? Their mapped accounts will be disabled.`, ); if (!confirmed) { return; } setIsBusy(true); try { await axios.delete(`/api/admin/users/${user.id}`, { headers: authHeaders }); showNotice('success', 'User deleted and owned mappings disabled.'); if (accountsCreatorFilter === user.id) { setAccountsCreatorFilter('all'); } await Promise.all([fetchManagedUsers(), fetchData()]); } catch (error) { handleAuthFailure(error, 'Failed to delete user.'); } finally { setIsBusy(false); } }; const handleChangeOwnEmail = async (event: React.FormEvent) => { event.preventDefault(); if (!authHeaders) { return; } if (!emailForm.newEmail.trim() || !emailForm.password || (hasCurrentEmail && !emailForm.currentEmail.trim())) { showNotice('error', hasCurrentEmail ? 'Fill in current email, new email, and password.' : 'Fill in new email and password.'); return; } setIsBusy(true); try { const response = await axios.post<{ token?: string; me?: AuthUser }>( '/api/me/change-email', { currentEmail: emailForm.currentEmail, newEmail: emailForm.newEmail, password: emailForm.password, }, { headers: authHeaders }, ); if (response.data?.token) { localStorage.setItem('token', response.data.token); setToken(response.data.token); } if (response.data?.me) { setMe({ ...response.data.me, permissions: normalizePermissions(response.data.me.permissions), }); } setEmailForm((previous) => ({ currentEmail: previous.newEmail, newEmail: '', password: '', })); showNotice('success', 'Email updated.'); await fetchData(); } catch (error) { handleAuthFailure(error, 'Failed to update email.'); } finally { setIsBusy(false); } }; const handleChangeOwnPassword = async (event: React.FormEvent) => { event.preventDefault(); if (!authHeaders) { return; } if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) { showNotice('error', 'Complete all password fields.'); return; } if (passwordForm.newPassword.length < 8) { showNotice('error', 'New password must be at least 8 characters.'); return; } if (passwordForm.newPassword !== passwordForm.confirmPassword) { showNotice('error', 'New password and confirmation do not match.'); return; } setIsBusy(true); try { await axios.post( '/api/me/change-password', { currentPassword: passwordForm.currentPassword, newPassword: passwordForm.newPassword, }, { headers: authHeaders }, ); setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); showNotice('success', 'Password updated.'); } catch (error) { handleAuthFailure(error, 'Failed to update password.'); } finally { setIsBusy(false); } }; if (!token) { return (
Tweets-2-Bsky {authView === 'login' ? 'Sign in to manage mappings, status, and account settings.' : 'Create your first dashboard account.'} {authError ? (
{authError}
) : null}
{authView === 'login' ? (
) : ( <>
)}
{bootstrapOpen || authView === 'register' ? ( ) : (

Account creation is disabled. Ask an admin to create your user.

)}
); } return (

Dashboard

Tweets-2-Bsky Control Panel

Next run in {countdown}

Version {runtimeVersionLabel} {runtimeBranchLabel ? {runtimeBranchLabel} : null}

{canCreateMappings ? ( ) : null} {isAdmin && pendingBackfills.length > 0 ? ( ) : null}
{notice ? (
{notice.message}
) : null} {currentStatus && currentStatus.state !== 'idle' ? (

{formatState(currentStatus.state)} in progress

{currentStatus.currentAccount ? `@${currentStatus.currentAccount} • ` : ''} {currentStatus.message || 'Working through account queue.'}

{progressPercent || 0}%

{(currentStatus.processedCount || 0).toLocaleString()} /{' '} {(currentStatus.totalCount || 0).toLocaleString()}

) : null}
{dashboardTabs.map((tab) => { const Icon = tab.icon; const isActive = activeTab === tab.id; return ( ); })}
{activeTab === 'overview' ? (

Mapped Accounts

{mappings.length}

Backfill Queue

{pendingBackfills.length}

Current State

{formatState(currentStatus?.state || 'idle')}

Latest Activity

{latestActivity?.created_at ? new Date(latestActivity.created_at).toLocaleString() : 'No activity yet'}

Top Account (Engagement)

{topAccount ? (
{topAccountProfile?.avatar ? ( {topAccountProfile.handle ) : (
)}

@{topAccountProfile?.handle || topAccount.identifier}

{formatCompactNumber(topAccount.score)} interactions • {topAccount.posts} posts

) : (

No engagement data yet.

)}
Quick Navigation Use tabs to focus one workflow at a time, especially on mobile. {dashboardTabs .filter((tab) => tab.id !== 'overview') .map((tab) => { const Icon = tab.icon; return ( ); })}
) : null} {activeTab === 'accounts' ? (
Active Accounts Organize mappings into folders and collapse/expand groups.
{canCreateMappings ? ( ) : null} {accountMappingsForView.length} configured
{canManageGroupsPermission ? (
{ void handleCreateGroup(event); }} >

Create Folder

setNewGroupName(event.target.value)} placeholder="Gaming, News, Sports..." />
setNewGroupEmoji(event.target.value)} placeholder="📁" maxLength={8} />
) : null}
setAccountsSearchQuery(event.target.value)} placeholder="Find by @username, owner, Bluesky handle, or folder" /> {normalizedAccountsQuery ? (

{accountMatchesCount} result{accountMatchesCount === 1 ? '' : 's'} ranked by relevance

) : null} {isAdmin ? (
) : null}
{accountsViewMode === 'grouped' ? ( ) : null}
{filteredGroupedMappings.length === 0 ? (
{normalizedAccountsQuery ? 'No accounts matched this search.' : 'No mappings yet.'} {canCreateMappings ? (
) : null}
) : (
{filteredGroupedMappings.map((group, groupIndex) => { const canCollapseGroup = accountsViewMode === 'grouped'; const collapsed = canCollapseGroup ? collapsedGroupKeys[group.key] === true : false; return (
{group.mappings.length === 0 ? (
No accounts in this folder yet.
) : (
{isAdmin ? : null} {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 ( {isAdmin ? ( ) : null} ); })}
OwnerCreated ByTwitter Sources Bluesky Target Status Actions
{mapping.owner || 'System'}
{mapping.createdByLabel || mapping.createdByUser?.username || mapping.createdByUser?.email || '--'}
{mapping.twitterUsernames.map((username) => ( @{username} ))}
{profile?.avatar ? ( {profileName} ) : (
)}

{profileName}

{profileHandle}

{active ? ( Backfilling ) : queued ? ( Queued {queuePosition ? `#${queuePosition}` : ''} ) : ( Active )}
{canManageMapping(mapping) ? ( <> {canQueueBackfillsPermission ? ( <> {isAdmin ? ( ) : null} ) : null} {isAdmin ? ( ) : null} ) : null} {canManageMapping(mapping) ? ( ) : null}
)}
); })}
)}
{canManageGroupsPermission ? ( Group Manager Edit folder names/emojis or delete a group. {reusableGroupOptions.length === 0 ? (
No custom folders yet.
) : (
{reusableGroupOptions.map((group) => { const draft = groupDraftsByKey[group.key] || { name: group.name, emoji: group.emoji }; return (
updateGroupDraft(group.key, 'emoji', event.target.value)} maxLength={8} />
updateGroupDraft(group.key, 'name', event.target.value)} />
); })}
)}

Deleting a folder keeps mappings intact and moves them to {DEFAULT_GROUP_NAME}.

) : null}
) : null} {activeTab === 'posts' ? (
Already Posted Native-styled feed plus local SQLite search across all crossposted history.
setPostsSearchQuery(event.target.value)} placeholder="Search by text, @username, tweet id, or Bluesky handle" /> {isSearchingLocalPosts ? ( ) : null}
{postsSearchQuery.trim() ? ( filteredLocalPostSearchResults.length === 0 ? (
{isSearchingLocalPosts ? 'Searching local history...' : 'No local crossposted posts matched.'}
) : (
{filteredLocalPostSearchResults.map((post) => { const mapping = resolveMappingForLocalPost(post); const groupMeta = getMappingGroupMeta(mapping); const sourceTweetUrl = post.twitterUrl || getTwitterPostUrl(post.twitterUsername, post.twitterId); const postUrl = post.postUrl || (post.bskyUri ? `https://bsky.app/profile/${post.bskyIdentifier}/post/${ post.bskyUri.split('/').filter(Boolean).pop() || '' }` : undefined); return (

@{post.bskyIdentifier}{' '} from @{post.twitterUsername}

{post.createdAt ? new Date(post.createdAt).toLocaleString() : 'Unknown time'}

{groupMeta.emoji} {groupMeta.name} Relevance {Math.round(post.score)}

{post.tweetText || 'No local tweet text stored for this record.'}

Tweet ID: {post.twitterId} {sourceTweetUrl ? ( Source ) : null} {postUrl ? ( Bluesky ) : null}
); })}
) ) : filteredPostedActivity.length === 0 ? (
No posted entries yet.
) : (
{filteredPostedActivity.map((post, index) => { const postUrl = post.postUrl || (post.bskyUri ? `https://bsky.app/profile/${post.bskyIdentifier}/post/${ post.bskyUri.split('/').filter(Boolean).pop() || '' }` : undefined); const sourceTweetUrl = post.twitterUrl || getTwitterPostUrl(post.twitterUsername, post.twitterId); const segments = buildFacetSegments(post.text, post.facets || []); const mapping = resolveMappingForPost(post); const groupMeta = getMappingGroupMeta(mapping); const statItems: Array<{ key: 'likes' | 'reposts' | 'replies' | 'quotes'; value: number; icon: typeof Heart; }> = [ { key: 'likes', value: post.stats.likes, icon: Heart }, { key: 'reposts', value: post.stats.reposts, icon: Repeat2 }, { key: 'replies', value: post.stats.replies, icon: MessageCircle }, { key: 'quotes', value: post.stats.quotes, icon: Quote }, ].filter((item) => item.value > 0); const authorAvatar = post.author.avatar || getProfileForActor(post.author.handle)?.avatar; const authorHandle = post.author.handle || post.bskyIdentifier; const authorName = post.author.displayName || authorHandle; return (
{authorAvatar ? ( {authorName} ) : (
)}

{authorName}

@{authorHandle} • from @{post.twitterUsername}

{groupMeta.emoji} {groupMeta.name} Posted

{segments.map((segment, segmentIndex) => { if (segment.type === 'text') { return {segment.text}; } const linkTone = segment.type === 'mention' ? 'text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200' : segment.type === 'tag' ? 'text-indigo-600 hover:text-indigo-500 dark:text-indigo-300 dark:hover:text-indigo-200' : 'text-sky-600 hover:text-sky-500 dark:text-sky-300 dark:hover:text-sky-200'; return ( {segment.text} ); })}

{post.media.length > 0 ? (
{post.media.map((media, mediaIndex) => { if (media.type === 'image') { const imageSrc = media.url || media.thumb; if (!imageSrc) return null; return ( {media.alt ); } if (media.type === 'video') { const videoHref = media.url || media.thumb; return (
{media.thumb ? ( {media.alt ) : (
Video attachment
)} {videoHref ? ( ) : null}
); } if (media.type === 'external') { if (!media.url) return null; return ( {media.thumb ? ( {media.title ) : null}

{media.title || media.url}

{media.description ? (

{media.description}

) : null}
); } return null; })}
) : null} {statItems.length > 0 ? (
{statItems.map((stat) => { const Icon = stat.icon; return ( {formatCompactNumber(stat.value)} ); })}
) : null}
{post.createdAt ? new Date(post.createdAt).toLocaleString() : 'Unknown time'}
{sourceTweetUrl ? ( Source ) : null} {postUrl ? ( Bluesky ) : ( Missing URI )}
); })}
)}
) : null} {activeTab === 'activity' ? (
Recent Activity Latest migration outcomes from the processing database.
{filteredRecentActivity.map((activity, index) => { const href = getBskyPostUrl(activity); const sourceTweetUrl = getTwitterPostUrl(activity.twitter_username, activity.twitter_id); const mapping = resolveMappingForActivity(activity); const groupMeta = getMappingGroupMeta(mapping); return ( ); })} {filteredRecentActivity.length === 0 ? ( ) : null}
Time Twitter User Group Status Details Link
{activity.created_at ? new Date(activity.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', }) : '--'} @{activity.twitter_username} {groupMeta.emoji} {groupMeta.name} {activity.status === 'migrated' ? ( Migrated ) : activity.status === 'skipped' ? ( Skipped ) : ( Failed )}
{activity.tweet_text || `Tweet ID: ${activity.twitter_id}`}
{sourceTweetUrl ? ( Source ) : null} {href ? ( Bluesky ) : ( -- )}
No activity for this filter.
) : null} {activeTab === 'settings' ? (

Change Email

{ setEmailForm((previous) => ({ ...previous, currentEmail: event.target.value })); }} placeholder={hasCurrentEmail ? undefined : 'No current email on this account'} required={hasCurrentEmail} disabled={!hasCurrentEmail} />
{ setEmailForm((previous) => ({ ...previous, newEmail: event.target.value })); }} required />
{ setEmailForm((previous) => ({ ...previous, password: event.target.value })); }} required />

Change Password

{ setPasswordForm((previous) => ({ ...previous, currentPassword: event.target.value })); }} required />
{ setPasswordForm((previous) => ({ ...previous, newPassword: event.target.value })); }} required />
{ setPasswordForm((previous) => ({ ...previous, confirmPassword: event.target.value })); }} required />
{isAdmin ? ( <> Admin Settings Configured sections stay collapsed so adding accounts is one click.

Running Version

{runtimeVersionLabel}

{runtimeBranchLabel ? (

{runtimeBranchLabel}

) : null}

{updateStateLabel}

{canCreateMappings ? ( ) : null}
{updateStatus?.logTail && updateStatus.logTail.length > 0 ? (
Update log
                          {updateStatus.logTail.join('\n')}
                        
) : null}

Create User

{ setNewUserForm((previous) => ({ ...previous, username: event.target.value })); }} placeholder="operator" />
{ setNewUserForm((previous) => ({ ...previous, email: event.target.value })); }} placeholder="operator@example.com" />
{ setNewUserForm((previous) => ({ ...previous, password: event.target.value })); }} placeholder="Minimum 8 characters" required />
{!newUserForm.isAdmin ? (
{PERMISSION_OPTIONS.map((permission) => ( ))}
) : (

Admins always get full access.

)}
{managedUsers.length === 0 ? (
No user accounts created yet.
) : (
{managedUsers.map((user) => { const isEditing = editingUserId === user.id; const displayName = user.username || user.email || user.id; return (
{isEditing ? (
{ setEditingUserForm((previous) => ({ ...previous, username: event.target.value, })); }} />
{ setEditingUserForm((previous) => ({ ...previous, email: event.target.value, })); }} />
{!editingUserForm.isAdmin ? (
{PERMISSION_OPTIONS.map((permission) => ( ))}
) : null}
) : (

{displayName}

{user.email ? `Email: ${user.email}` : 'No email set'}

{user.mappingCount} mappings ({user.activeMappingCount} active)

{user.isAdmin ? 'Admin' : 'User'} {user.id === me?.id ? You : null}
{!user.isAdmin ? (
{PERMISSION_OPTIONS.filter( (permission) => user.permissions[permission.key], ).map((permission) => ( {permission.label} ))}
) : null}
{user.id !== me?.id ? ( ) : null}
)}
); })}
)}
{ setTwitterConfig((prev) => ({ ...prev, authToken: event.target.value })); }} required />
{ setTwitterConfig((prev) => ({ ...prev, ct0: event.target.value })); }} required />
{ setTwitterConfig((prev) => ({ ...prev, backupAuthToken: event.target.value })); }} />
{ setTwitterConfig((prev) => ({ ...prev, backupCt0: event.target.value })); }} />
{ setAiConfig((prev) => ({ ...prev, apiKey: event.target.value })); }} />
{aiConfig.provider !== 'gemini' ? ( <>
{ setAiConfig((prev) => ({ ...prev, model: event.target.value })); }} placeholder="gpt-4o" />
{ setAiConfig((prev) => ({ ...prev, baseUrl: event.target.value })); }} placeholder="https://api.example.com/v1" />
) : null}
{ void handleImportConfig(event); }} />

Imports preserve dashboard users and passwords while replacing mappings, provider keys, and scheduler settings.

) : ( Access Scope Your current account permissions. {PERMISSION_OPTIONS.filter((permission) => effectivePermissions[permission.key]).map((permission) => ( {permission.label} ))} )}
) : null} {isAddAccountSheetOpen ? (
) : null} {editingMapping ? (
Edit Mapping Update ownership, handles, and target credentials.
{ setEditForm((prev) => ({ ...prev, owner: event.target.value })); }} required />
{ setEditForm((prev) => ({ ...prev, groupName: event.target.value })); }} placeholder="Gaming, News, Sports..." />
{ setEditForm((prev) => ({ ...prev, groupEmoji: event.target.value })); }} placeholder="📁" maxLength={8} />
{ setEditTwitterInput(event.target.value); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ',') { event.preventDefault(); addEditTwitterUsername(); } }} placeholder="@accountname" />
{editTwitterUsers.map((username) => ( @{username} ))}
{ setEditForm((prev) => ({ ...prev, bskyIdentifier: event.target.value })); }} required />
{ setEditForm((prev) => ({ ...prev, bskyPassword: event.target.value })); }} placeholder="Leave blank to keep existing" />
{ setEditForm((prev) => ({ ...prev, bskyServiceUrl: event.target.value })); }} />
) : null}
); } export default App;