A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
at main 4893 lines 200 kB view raw
1import axios from 'axios'; 2import { 3 ArrowUpRight, 4 Bot, 5 ChevronDown, 6 ChevronLeft, 7 ChevronRight, 8 Clock3, 9 Download, 10 Folder, 11 Heart, 12 History, 13 LayoutDashboard, 14 Loader2, 15 LogOut, 16 MessageCircle, 17 Moon, 18 Newspaper, 19 Play, 20 Plus, 21 Quote, 22 RefreshCw, 23 Repeat2, 24 Save, 25 Settings2, 26 Sun, 27 SunMoon, 28 Trash2, 29 Upload, 30 UserRound, 31 Users, 32 X, 33} from 'lucide-react'; 34import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 35import { Badge } from './components/ui/badge'; 36import { Button } from './components/ui/button'; 37import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './components/ui/card'; 38import { Input } from './components/ui/input'; 39import { Label } from './components/ui/label'; 40import { cn } from './lib/utils'; 41 42type ThemeMode = 'system' | 'light' | 'dark'; 43type AuthView = 'login' | 'register'; 44type DashboardTab = 'overview' | 'accounts' | 'posts' | 'activity' | 'settings'; 45type SettingsSection = 'account' | 'users' | 'twitter' | 'ai' | 'data'; 46 47type AppState = 'idle' | 'checking' | 'backfilling' | 'pacing' | 'processing'; 48 49interface AccountMapping { 50 id: string; 51 twitterUsernames: string[]; 52 bskyIdentifier: string; 53 bskyPassword?: string; 54 bskyServiceUrl?: string; 55 enabled: boolean; 56 owner?: string; 57 groupName?: string; 58 groupEmoji?: string; 59 createdByUserId?: string; 60 createdByLabel?: string; 61 createdByUser?: { 62 id: string; 63 username?: string; 64 email?: string; 65 role: 'admin' | 'user'; 66 }; 67} 68 69interface AccountGroup { 70 name: string; 71 emoji?: string; 72} 73 74interface TwitterConfig { 75 authToken: string; 76 ct0: string; 77 backupAuthToken?: string; 78 backupCt0?: string; 79} 80 81interface AIConfig { 82 provider: 'gemini' | 'openai' | 'anthropic' | 'custom'; 83 apiKey?: string; 84 model?: string; 85 baseUrl?: string; 86} 87 88interface ActivityLog { 89 twitter_id: string; 90 twitter_username: string; 91 bsky_identifier: string; 92 tweet_text?: string; 93 bsky_uri?: string; 94 status: 'migrated' | 'skipped' | 'failed'; 95 created_at?: string; 96} 97 98interface BskyFacetFeatureLink { 99 $type: 'app.bsky.richtext.facet#link'; 100 uri: string; 101} 102 103interface BskyFacetFeatureMention { 104 $type: 'app.bsky.richtext.facet#mention'; 105 did: string; 106} 107 108interface BskyFacetFeatureTag { 109 $type: 'app.bsky.richtext.facet#tag'; 110 tag: string; 111} 112 113type BskyFacetFeature = BskyFacetFeatureLink | BskyFacetFeatureMention | BskyFacetFeatureTag; 114 115interface BskyFacet { 116 index?: { 117 byteStart?: number; 118 byteEnd?: number; 119 }; 120 features?: BskyFacetFeature[]; 121} 122 123interface EnrichedPostMedia { 124 type: 'image' | 'video' | 'external'; 125 url?: string; 126 thumb?: string; 127 alt?: string; 128 width?: number; 129 height?: number; 130 title?: string; 131 description?: string; 132} 133 134interface EnrichedPost { 135 bskyUri: string; 136 bskyCid?: string; 137 bskyIdentifier: string; 138 twitterId: string; 139 twitterUsername: string; 140 twitterUrl?: string; 141 postUrl?: string; 142 createdAt?: string; 143 text: string; 144 facets: BskyFacet[]; 145 author: { 146 did?: string; 147 handle: string; 148 displayName?: string; 149 avatar?: string; 150 }; 151 stats: { 152 likes: number; 153 reposts: number; 154 replies: number; 155 quotes: number; 156 engagement: number; 157 }; 158 media: EnrichedPostMedia[]; 159} 160 161interface LocalPostSearchResult { 162 twitterId: string; 163 twitterUsername: string; 164 bskyIdentifier: string; 165 tweetText?: string; 166 bskyUri?: string; 167 bskyCid?: string; 168 createdAt?: string; 169 postUrl?: string; 170 twitterUrl?: string; 171 score: number; 172} 173 174interface BskyProfileView { 175 did?: string; 176 handle?: string; 177 displayName?: string; 178 avatar?: string; 179} 180 181interface PendingBackfill { 182 id: string; 183 limit?: number; 184 queuedAt: number; 185 sequence: number; 186 requestId: string; 187 position: number; 188} 189 190interface StatusState { 191 state: AppState; 192 currentAccount?: string; 193 processedCount?: number; 194 totalCount?: number; 195 message?: string; 196 backfillMappingId?: string; 197 backfillRequestId?: string; 198 lastUpdate: number; 199} 200 201interface StatusResponse { 202 lastCheckTime: number; 203 nextCheckTime: number; 204 nextCheckMinutes: number; 205 checkIntervalMinutes: number; 206 pendingBackfills: PendingBackfill[]; 207 currentStatus: StatusState; 208} 209 210interface UserPermissions { 211 viewAllMappings: boolean; 212 manageOwnMappings: boolean; 213 manageAllMappings: boolean; 214 manageGroups: boolean; 215 queueBackfills: boolean; 216 runNow: boolean; 217} 218 219interface AuthUser { 220 id: string; 221 username?: string; 222 email?: string; 223 isAdmin: boolean; 224 permissions: UserPermissions; 225} 226 227interface ManagedUser { 228 id: string; 229 username?: string; 230 email?: string; 231 role: 'admin' | 'user'; 232 isAdmin: boolean; 233 permissions: UserPermissions; 234 createdAt: string; 235 updatedAt: string; 236 mappingCount: number; 237 activeMappingCount: number; 238 mappings: AccountMapping[]; 239} 240 241interface BootstrapStatus { 242 bootstrapOpen: boolean; 243} 244 245interface RuntimeVersionInfo { 246 version: string; 247 commit?: string; 248 branch?: string; 249 startedAt: number; 250} 251 252interface UpdateStatusInfo { 253 running: boolean; 254 pid?: number; 255 startedAt?: number; 256 startedBy?: string; 257 finishedAt?: number; 258 exitCode?: number | null; 259 signal?: string | null; 260 logFile?: string; 261 logTail?: string[]; 262} 263 264interface Notice { 265 tone: 'success' | 'error' | 'info'; 266 message: string; 267} 268 269interface MappingFormState { 270 owner: string; 271 bskyIdentifier: string; 272 bskyPassword: string; 273 bskyServiceUrl: string; 274 groupName: string; 275 groupEmoji: string; 276} 277 278interface UserFormState { 279 username: string; 280 email: string; 281 password: string; 282 isAdmin: boolean; 283 permissions: UserPermissions; 284} 285 286interface AccountSecurityEmailState { 287 currentEmail: string; 288 newEmail: string; 289 password: string; 290} 291 292interface AccountSecurityPasswordState { 293 currentPassword: string; 294 newPassword: string; 295 confirmPassword: string; 296} 297 298const defaultMappingForm = (): MappingFormState => ({ 299 owner: '', 300 bskyIdentifier: '', 301 bskyPassword: '', 302 bskyServiceUrl: 'https://bsky.social', 303 groupName: '', 304 groupEmoji: '📁', 305}); 306 307const defaultUserForm = (): UserFormState => ({ 308 username: '', 309 email: '', 310 password: '', 311 isAdmin: false, 312 permissions: { ...DEFAULT_USER_PERMISSIONS }, 313}); 314 315const DEFAULT_GROUP_NAME = 'Ungrouped'; 316const DEFAULT_GROUP_EMOJI = '📁'; 317const DEFAULT_GROUP_KEY = 'ungrouped'; 318const TAB_PATHS: Record<DashboardTab, string> = { 319 overview: '/', 320 accounts: '/accounts', 321 posts: '/posts', 322 activity: '/activity', 323 settings: '/settings', 324}; 325const ADD_ACCOUNT_STEP_COUNT = 4; 326const ADD_ACCOUNT_STEPS = ['Owner', 'Sources', 'Bluesky', 'Confirm'] as const; 327const ACCOUNT_SEARCH_MIN_SCORE = 22; 328const DEFAULT_BACKFILL_LIMIT = 15; 329const DEFAULT_USER_PERMISSIONS: UserPermissions = { 330 viewAllMappings: false, 331 manageOwnMappings: true, 332 manageAllMappings: false, 333 manageGroups: false, 334 queueBackfills: true, 335 runNow: true, 336}; 337const PERMISSION_OPTIONS: Array<{ 338 key: keyof UserPermissions; 339 label: string; 340 help: string; 341}> = [ 342 { key: 'viewAllMappings', label: 'View all mappings', help: 'See every mapped account, post, and activity row.' }, 343 { key: 'manageOwnMappings', label: 'Manage own mappings', help: 'Create, edit, and delete mappings this user owns.' }, 344 { key: 'manageAllMappings', label: 'Manage all mappings', help: 'Edit/delete mappings created by any user.' }, 345 { key: 'manageGroups', label: 'Manage groups', help: 'Create, rename, and delete account groups.' }, 346 { key: 'queueBackfills', label: 'Queue backfills', help: 'Queue backfills for mappings they can manage.' }, 347 { key: 'runNow', label: 'Run checks now', help: 'Trigger an immediate scheduler run.' }, 348]; 349 350const selectClassName = 351 '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'; 352 353function getApiErrorMessage(error: unknown, fallback: string): string { 354 if (axios.isAxiosError(error)) { 355 const serverMessage = error.response?.data?.error; 356 if (typeof serverMessage === 'string' && serverMessage.length > 0) { 357 return serverMessage; 358 } 359 if (typeof error.message === 'string' && error.message.length > 0) { 360 return error.message; 361 } 362 } 363 return fallback; 364} 365 366function formatState(state: AppState): string { 367 switch (state) { 368 case 'checking': 369 return 'Checking'; 370 case 'backfilling': 371 return 'Backfilling'; 372 case 'pacing': 373 return 'Pacing'; 374 case 'processing': 375 return 'Processing'; 376 default: 377 return 'Idle'; 378 } 379} 380 381function getBskyPostUrl(activity: ActivityLog): string | null { 382 if (!activity.bsky_uri || !activity.bsky_identifier) { 383 return null; 384 } 385 386 const postId = activity.bsky_uri.split('/').filter(Boolean).pop(); 387 if (!postId) { 388 return null; 389 } 390 391 return `https://bsky.app/profile/${activity.bsky_identifier}/post/${postId}`; 392} 393 394function normalizeTwitterUsername(value: string): string { 395 return value.trim().replace(/^@/, '').toLowerCase(); 396} 397 398function normalizeGroupName(value?: string): string { 399 const trimmed = typeof value === 'string' ? value.trim() : ''; 400 return trimmed || DEFAULT_GROUP_NAME; 401} 402 403function normalizeGroupEmoji(value?: string): string { 404 const trimmed = typeof value === 'string' ? value.trim() : ''; 405 return trimmed || DEFAULT_GROUP_EMOJI; 406} 407 408function getGroupKey(groupName?: string): string { 409 return normalizeGroupName(groupName).toLowerCase(); 410} 411 412function getGroupMeta(groupName?: string, groupEmoji?: string) { 413 const name = normalizeGroupName(groupName); 414 const emoji = normalizeGroupEmoji(groupEmoji); 415 return { 416 key: getGroupKey(name), 417 name, 418 emoji, 419 }; 420} 421 422function getMappingGroupMeta(mapping?: Pick<AccountMapping, 'groupName' | 'groupEmoji'>) { 423 return getGroupMeta(mapping?.groupName, mapping?.groupEmoji); 424} 425 426function getTwitterPostUrl(twitterUsername?: string, twitterId?: string): string | undefined { 427 if (!twitterUsername || !twitterId) { 428 return undefined; 429 } 430 return `https://x.com/${normalizeTwitterUsername(twitterUsername)}/status/${twitterId}`; 431} 432 433function normalizePath(pathname: string): string { 434 const normalized = pathname.replace(/\/+$/, ''); 435 return normalized.length === 0 ? '/' : normalized; 436} 437 438function getTabFromPath(pathname: string): DashboardTab | null { 439 const normalized = normalizePath(pathname); 440 const entry = (Object.entries(TAB_PATHS) as Array<[DashboardTab, string]>).find(([, path]) => path === normalized); 441 return entry ? entry[0] : null; 442} 443 444function normalizeEmail(value: string): string { 445 return value.trim().toLowerCase(); 446} 447 448function normalizeUsername(value: string): string { 449 return value.trim().replace(/^@/, '').toLowerCase(); 450} 451 452function getUserLabel(user?: Pick<AuthUser, 'username' | 'email'>): string { 453 return user?.username || user?.email || 'user'; 454} 455 456function normalizePermissions(permissions?: Partial<UserPermissions>): UserPermissions { 457 return { 458 ...DEFAULT_USER_PERMISSIONS, 459 ...(permissions || {}), 460 }; 461} 462 463function addTwitterUsernames(current: string[], value: string): string[] { 464 const candidates = value 465 .split(/[\s,]+/) 466 .map(normalizeTwitterUsername) 467 .filter((username) => username.length > 0); 468 if (candidates.length === 0) { 469 return current; 470 } 471 472 const seen = new Set(current.map(normalizeTwitterUsername)); 473 const next = [...current]; 474 for (const candidate of candidates) { 475 if (seen.has(candidate)) { 476 continue; 477 } 478 seen.add(candidate); 479 next.push(candidate); 480 } 481 482 return next; 483} 484 485function normalizeSearchValue(value: string): string { 486 return value 487 .toLowerCase() 488 .replace(/[^a-z0-9@#._\-\s]+/g, ' ') 489 .replace(/\s+/g, ' ') 490 .trim(); 491} 492 493function tokenizeSearchValue(value: string): string[] { 494 if (!value) { 495 return []; 496 } 497 return value.split(' ').filter((token) => token.length > 0); 498} 499 500function orderedSubsequenceScore(query: string, candidate: string): number { 501 if (!query || !candidate) { 502 return 0; 503 } 504 505 let matched = 0; 506 let searchIndex = 0; 507 for (const char of query) { 508 const foundIndex = candidate.indexOf(char, searchIndex); 509 if (foundIndex === -1) { 510 continue; 511 } 512 matched += 1; 513 searchIndex = foundIndex + 1; 514 } 515 516 return matched / query.length; 517} 518 519function buildBigrams(value: string): Set<string> { 520 const result = new Set<string>(); 521 if (value.length < 2) { 522 if (value.length === 1) { 523 result.add(value); 524 } 525 return result; 526 } 527 for (let i = 0; i < value.length - 1; i += 1) { 528 result.add(value.slice(i, i + 2)); 529 } 530 return result; 531} 532 533function diceCoefficient(a: string, b: string): number { 534 const aBigrams = buildBigrams(a); 535 const bBigrams = buildBigrams(b); 536 if (aBigrams.size === 0 || bBigrams.size === 0) { 537 return 0; 538 } 539 let overlap = 0; 540 for (const gram of aBigrams) { 541 if (bBigrams.has(gram)) { 542 overlap += 1; 543 } 544 } 545 return (2 * overlap) / (aBigrams.size + bBigrams.size); 546} 547 548function scoreSearchField(query: string, tokens: string[], candidateValue?: string): number { 549 const candidate = normalizeSearchValue(candidateValue || ''); 550 if (!query || !candidate) { 551 return 0; 552 } 553 554 let score = 0; 555 if (candidate === query) { 556 score += 170; 557 } else if (candidate.startsWith(query)) { 558 score += 138; 559 } else if (candidate.includes(query)) { 560 score += 108; 561 } 562 563 let matchedTokens = 0; 564 for (const token of tokens) { 565 if (candidate.includes(token)) { 566 matchedTokens += 1; 567 score += token.length >= 4 ? 18 : 12; 568 } 569 } 570 if (tokens.length > 0) { 571 score += (matchedTokens / tokens.length) * 46; 572 } 573 574 score += orderedSubsequenceScore(query, candidate) * 45; 575 score += diceCoefficient(query, candidate) * 52; 576 return score; 577} 578 579function scoreAccountMapping(mapping: AccountMapping, query: string, tokens: string[]): number { 580 const usernameScores = mapping.twitterUsernames.map((username) => scoreSearchField(query, tokens, username) * 1.24); 581 const bestUsernameScore = usernameScores.length > 0 ? Math.max(...usernameScores) : 0; 582 const identifierScore = scoreSearchField(query, tokens, mapping.bskyIdentifier) * 1.2; 583 const ownerScore = scoreSearchField(query, tokens, mapping.owner) * 0.92; 584 const groupScore = scoreSearchField(query, tokens, mapping.groupName) * 0.72; 585 const combined = [bestUsernameScore, identifierScore, ownerScore, groupScore]; 586 const maxScore = Math.max(...combined); 587 return maxScore + (combined.reduce((total, value) => total + value, 0) - maxScore) * 0.24; 588} 589 590const textEncoder = new TextEncoder(); 591const textDecoder = new TextDecoder(); 592const compactNumberFormatter = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }); 593 594type FacetSegment = 595 | { type: 'text'; text: string } 596 | { type: 'link'; text: string; href: string } 597 | { type: 'mention'; text: string; href: string } 598 | { type: 'tag'; text: string; href: string }; 599 600function sliceByBytes(bytes: Uint8Array, start: number, end: number): string { 601 return textDecoder.decode(bytes.slice(start, end)); 602} 603 604function buildFacetSegments(text: string, facets: BskyFacet[]): FacetSegment[] { 605 const bytes = textEncoder.encode(text); 606 const sortedFacets = [...facets].sort((a, b) => (a.index?.byteStart || 0) - (b.index?.byteStart || 0)); 607 const segments: FacetSegment[] = []; 608 let cursor = 0; 609 610 for (const facet of sortedFacets) { 611 const start = Number(facet.index?.byteStart); 612 const end = Number(facet.index?.byteEnd); 613 if (!Number.isFinite(start) || !Number.isFinite(end)) continue; 614 if (start < cursor || end <= start || end > bytes.length) continue; 615 616 if (start > cursor) { 617 segments.push({ type: 'text', text: sliceByBytes(bytes, cursor, start) }); 618 } 619 620 const rawText = sliceByBytes(bytes, start, end); 621 const feature = facet.features?.[0]; 622 if (!feature) { 623 segments.push({ type: 'text', text: rawText }); 624 } else if (feature.$type === 'app.bsky.richtext.facet#link' && feature.uri) { 625 segments.push({ type: 'link', text: rawText, href: feature.uri }); 626 } else if (feature.$type === 'app.bsky.richtext.facet#mention' && feature.did) { 627 segments.push({ type: 'mention', text: rawText, href: `https://bsky.app/profile/${feature.did}` }); 628 } else if (feature.$type === 'app.bsky.richtext.facet#tag' && feature.tag) { 629 segments.push({ 630 type: 'tag', 631 text: rawText, 632 href: `https://bsky.app/hashtag/${encodeURIComponent(feature.tag)}`, 633 }); 634 } else { 635 segments.push({ type: 'text', text: rawText }); 636 } 637 638 cursor = end; 639 } 640 641 if (cursor < bytes.length) { 642 segments.push({ type: 'text', text: sliceByBytes(bytes, cursor, bytes.length) }); 643 } 644 645 if (segments.length === 0) { 646 return [{ type: 'text', text }]; 647 } 648 649 return segments; 650} 651 652function formatCompactNumber(value: number): string { 653 return compactNumberFormatter.format(Math.max(0, value)); 654} 655 656function App() { 657 const [token, setToken] = useState<string | null>(() => localStorage.getItem('token')); 658 const [authView, setAuthView] = useState<AuthView>('login'); 659 const [bootstrapOpen, setBootstrapOpen] = useState(false); 660 const [themeMode, setThemeMode] = useState<ThemeMode>(() => { 661 const saved = localStorage.getItem('theme-mode'); 662 if (saved === 'light' || saved === 'dark' || saved === 'system') { 663 return saved; 664 } 665 return 'system'; 666 }); 667 const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light'); 668 669 const [mappings, setMappings] = useState<AccountMapping[]>([]); 670 const [groups, setGroups] = useState<AccountGroup[]>([]); 671 const [enrichedPosts, setEnrichedPosts] = useState<EnrichedPost[]>([]); 672 const [profilesByActor, setProfilesByActor] = useState<Record<string, BskyProfileView>>({}); 673 const [twitterConfig, setTwitterConfig] = useState<TwitterConfig>({ authToken: '', ct0: '' }); 674 const [aiConfig, setAiConfig] = useState<AIConfig>({ provider: 'gemini', apiKey: '', model: '', baseUrl: '' }); 675 const [recentActivity, setRecentActivity] = useState<ActivityLog[]>([]); 676 const [status, setStatus] = useState<StatusResponse | null>(null); 677 const [runtimeVersion, setRuntimeVersion] = useState<RuntimeVersionInfo | null>(null); 678 const [updateStatus, setUpdateStatus] = useState<UpdateStatusInfo | null>(null); 679 const [countdown, setCountdown] = useState('--'); 680 const [activeTab, setActiveTab] = useState<DashboardTab>(() => { 681 const fromPath = getTabFromPath(window.location.pathname); 682 if (fromPath) { 683 return fromPath; 684 } 685 686 const saved = localStorage.getItem('dashboard-tab'); 687 if ( 688 saved === 'overview' || 689 saved === 'accounts' || 690 saved === 'posts' || 691 saved === 'activity' || 692 saved === 'settings' 693 ) { 694 return saved; 695 } 696 return 'overview'; 697 }); 698 699 const [me, setMe] = useState<AuthUser | null>(null); 700 const [editingMapping, setEditingMapping] = useState<AccountMapping | null>(null); 701 const [newMapping, setNewMapping] = useState<MappingFormState>(defaultMappingForm); 702 const [newTwitterUsers, setNewTwitterUsers] = useState<string[]>([]); 703 const [newTwitterInput, setNewTwitterInput] = useState(''); 704 const [editForm, setEditForm] = useState<MappingFormState>(defaultMappingForm); 705 const [editTwitterUsers, setEditTwitterUsers] = useState<string[]>([]); 706 const [editTwitterInput, setEditTwitterInput] = useState(''); 707 const [newGroupName, setNewGroupName] = useState(''); 708 const [newGroupEmoji, setNewGroupEmoji] = useState(DEFAULT_GROUP_EMOJI); 709 const [isAddAccountSheetOpen, setIsAddAccountSheetOpen] = useState(false); 710 const [addAccountStep, setAddAccountStep] = useState(1); 711 const [settingsSectionOverrides, setSettingsSectionOverrides] = useState<Partial<Record<SettingsSection, boolean>>>( 712 {}, 713 ); 714 const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Record<string, boolean>>(() => { 715 const raw = localStorage.getItem('accounts-collapsed-groups'); 716 if (!raw) return {}; 717 try { 718 const parsed = JSON.parse(raw) as Record<string, boolean>; 719 return parsed && typeof parsed === 'object' ? parsed : {}; 720 } catch { 721 return {}; 722 } 723 }); 724 const [accountsViewMode, setAccountsViewMode] = useState<'grouped' | 'global'>('grouped'); 725 const [accountsSearchQuery, setAccountsSearchQuery] = useState(''); 726 const [postsGroupFilter, setPostsGroupFilter] = useState('all'); 727 const [postsSearchQuery, setPostsSearchQuery] = useState(''); 728 const [localPostSearchResults, setLocalPostSearchResults] = useState<LocalPostSearchResult[]>([]); 729 const [isSearchingLocalPosts, setIsSearchingLocalPosts] = useState(false); 730 const [activityGroupFilter, setActivityGroupFilter] = useState('all'); 731 const [groupDraftsByKey, setGroupDraftsByKey] = useState<Record<string, { name: string; emoji: string }>>({}); 732 const [isGroupActionBusy, setIsGroupActionBusy] = useState(false); 733 const [notice, setNotice] = useState<Notice | null>(null); 734 const [managedUsers, setManagedUsers] = useState<ManagedUser[]>([]); 735 const [accountsCreatorFilter, setAccountsCreatorFilter] = useState('all'); 736 const [newUserForm, setNewUserForm] = useState<UserFormState>(defaultUserForm); 737 const [editingUserId, setEditingUserId] = useState<string | null>(null); 738 const [editingUserForm, setEditingUserForm] = useState<UserFormState>(defaultUserForm); 739 const [emailForm, setEmailForm] = useState<AccountSecurityEmailState>({ 740 currentEmail: '', 741 newEmail: '', 742 password: '', 743 }); 744 const [passwordForm, setPasswordForm] = useState<AccountSecurityPasswordState>({ 745 currentPassword: '', 746 newPassword: '', 747 confirmPassword: '', 748 }); 749 750 const [isBusy, setIsBusy] = useState(false); 751 const [isUpdateBusy, setIsUpdateBusy] = useState(false); 752 const [authError, setAuthError] = useState(''); 753 754 const noticeTimerRef = useRef<number | null>(null); 755 const importInputRef = useRef<HTMLInputElement>(null); 756 const postsSearchRequestRef = useRef(0); 757 758 const isAdmin = me?.isAdmin ?? false; 759 const effectivePermissions = useMemo<UserPermissions>(() => normalizePermissions(me?.permissions), [me?.permissions]); 760 const canManageAllMappings = isAdmin || effectivePermissions.manageAllMappings; 761 const canManageOwnMappings = isAdmin || effectivePermissions.manageOwnMappings; 762 const canCreateMappings = canManageAllMappings || canManageOwnMappings; 763 const canManageGroupsPermission = isAdmin || effectivePermissions.manageGroups; 764 const canQueueBackfillsPermission = isAdmin || effectivePermissions.queueBackfills; 765 const canRunNowPermission = isAdmin || effectivePermissions.runNow; 766 const hasCurrentEmail = Boolean(me?.email && me.email.trim().length > 0); 767 const authHeaders = useMemo(() => (token ? { Authorization: `Bearer ${token}` } : undefined), [token]); 768 769 const showNotice = useCallback((tone: Notice['tone'], message: string) => { 770 setNotice({ tone, message }); 771 if (noticeTimerRef.current) { 772 window.clearTimeout(noticeTimerRef.current); 773 } 774 noticeTimerRef.current = window.setTimeout(() => { 775 setNotice(null); 776 }, 4200); 777 }, []); 778 779 const handleLogout = useCallback(() => { 780 localStorage.removeItem('token'); 781 setToken(null); 782 setMe(null); 783 setMappings([]); 784 setGroups([]); 785 setEnrichedPosts([]); 786 setProfilesByActor({}); 787 setStatus(null); 788 setRuntimeVersion(null); 789 setUpdateStatus(null); 790 setRecentActivity([]); 791 setEditingMapping(null); 792 setNewTwitterUsers([]); 793 setEditTwitterUsers([]); 794 setNewGroupName(''); 795 setNewGroupEmoji(DEFAULT_GROUP_EMOJI); 796 setIsAddAccountSheetOpen(false); 797 setAddAccountStep(1); 798 setSettingsSectionOverrides({}); 799 setAccountsViewMode('grouped'); 800 setAccountsSearchQuery(''); 801 setPostsSearchQuery(''); 802 setLocalPostSearchResults([]); 803 setIsSearchingLocalPosts(false); 804 setGroupDraftsByKey({}); 805 setIsGroupActionBusy(false); 806 setIsUpdateBusy(false); 807 setManagedUsers([]); 808 setAccountsCreatorFilter('all'); 809 setNewUserForm(defaultUserForm()); 810 setEditingUserId(null); 811 setEditingUserForm(defaultUserForm()); 812 setEmailForm({ currentEmail: '', newEmail: '', password: '' }); 813 setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); 814 postsSearchRequestRef.current = 0; 815 setAuthView('login'); 816 }, []); 817 818 const handleAuthFailure = useCallback( 819 (error: unknown, fallbackMessage: string) => { 820 if (axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 403)) { 821 handleLogout(); 822 return; 823 } 824 showNotice('error', getApiErrorMessage(error, fallbackMessage)); 825 }, 826 [handleLogout, showNotice], 827 ); 828 829 const fetchBootstrapStatus = useCallback(async () => { 830 try { 831 const response = await axios.get<BootstrapStatus>('/api/auth/bootstrap-status'); 832 setBootstrapOpen(Boolean(response.data?.bootstrapOpen)); 833 } catch { 834 setBootstrapOpen(false); 835 } 836 }, []); 837 838 const fetchStatus = useCallback(async () => { 839 if (!authHeaders) { 840 return; 841 } 842 843 try { 844 const response = await axios.get<StatusResponse>('/api/status', { headers: authHeaders }); 845 setStatus(response.data); 846 } catch (error) { 847 handleAuthFailure(error, 'Failed to fetch status.'); 848 } 849 }, [authHeaders, handleAuthFailure]); 850 851 const fetchRecentActivity = useCallback(async () => { 852 if (!authHeaders) { 853 return; 854 } 855 856 try { 857 const response = await axios.get<ActivityLog[]>('/api/recent-activity?limit=20', { headers: authHeaders }); 858 setRecentActivity(response.data); 859 } catch (error) { 860 handleAuthFailure(error, 'Failed to fetch activity.'); 861 } 862 }, [authHeaders, handleAuthFailure]); 863 864 const fetchEnrichedPosts = useCallback(async () => { 865 if (!authHeaders) { 866 return; 867 } 868 869 try { 870 const response = await axios.get<EnrichedPost[]>('/api/posts/enriched?limit=36', { headers: authHeaders }); 871 setEnrichedPosts(response.data); 872 } catch (error) { 873 handleAuthFailure(error, 'Failed to fetch Bluesky posts.'); 874 } 875 }, [authHeaders, handleAuthFailure]); 876 877 const fetchGroups = useCallback(async () => { 878 if (!authHeaders) { 879 return; 880 } 881 882 try { 883 const response = await axios.get<AccountGroup[]>('/api/groups', { headers: authHeaders }); 884 setGroups(Array.isArray(response.data) ? response.data : []); 885 } catch (error) { 886 handleAuthFailure(error, 'Failed to fetch account groups.'); 887 } 888 }, [authHeaders, handleAuthFailure]); 889 890 const fetchRuntimeVersion = useCallback(async () => { 891 if (!authHeaders) { 892 return; 893 } 894 895 try { 896 const response = await axios.get<RuntimeVersionInfo>('/api/version', { headers: authHeaders }); 897 setRuntimeVersion(response.data); 898 } catch (error) { 899 handleAuthFailure(error, 'Failed to fetch app version.'); 900 } 901 }, [authHeaders, handleAuthFailure]); 902 903 const fetchUpdateStatus = useCallback(async () => { 904 if (!authHeaders || !isAdmin) { 905 return; 906 } 907 908 try { 909 const response = await axios.get<UpdateStatusInfo>('/api/update-status', { headers: authHeaders }); 910 setUpdateStatus(response.data); 911 } catch (error) { 912 handleAuthFailure(error, 'Failed to fetch update status.'); 913 } 914 }, [authHeaders, handleAuthFailure, isAdmin]); 915 916 const fetchManagedUsers = useCallback(async () => { 917 if (!authHeaders || !isAdmin) { 918 setManagedUsers([]); 919 return; 920 } 921 922 try { 923 const response = await axios.get<ManagedUser[]>('/api/admin/users', { headers: authHeaders }); 924 setManagedUsers(Array.isArray(response.data) ? response.data : []); 925 } catch (error) { 926 handleAuthFailure(error, 'Failed to fetch dashboard users.'); 927 } 928 }, [authHeaders, handleAuthFailure, isAdmin]); 929 930 const fetchProfiles = useCallback( 931 async (actors: string[]) => { 932 if (!authHeaders) { 933 return; 934 } 935 936 const normalizedActors = [...new Set(actors.map(normalizeTwitterUsername).filter((actor) => actor.length > 0))]; 937 if (normalizedActors.length === 0) { 938 setProfilesByActor({}); 939 return; 940 } 941 942 try { 943 const response = await axios.post<Record<string, BskyProfileView>>( 944 '/api/bsky/profiles', 945 { actors: normalizedActors }, 946 { headers: authHeaders }, 947 ); 948 setProfilesByActor(response.data || {}); 949 } catch (error) { 950 handleAuthFailure(error, 'Failed to resolve Bluesky profiles.'); 951 } 952 }, 953 [authHeaders, handleAuthFailure], 954 ); 955 956 const fetchData = useCallback(async () => { 957 if (!authHeaders) { 958 return; 959 } 960 961 try { 962 const [meResponse, mappingsResponse, groupsResponse] = await Promise.all([ 963 axios.get<AuthUser>('/api/me', { headers: authHeaders }), 964 axios.get<AccountMapping[]>('/api/mappings', { headers: authHeaders }), 965 axios.get<AccountGroup[]>('/api/groups', { headers: authHeaders }), 966 ]); 967 968 const profile = meResponse.data; 969 const mappingData = Array.isArray(mappingsResponse.data) ? mappingsResponse.data : []; 970 const groupData = Array.isArray(groupsResponse.data) ? groupsResponse.data : []; 971 setMe({ 972 ...profile, 973 permissions: normalizePermissions(profile.permissions), 974 }); 975 setMappings(mappingData); 976 setGroups(groupData); 977 setEmailForm((previous) => ({ 978 ...previous, 979 currentEmail: profile.email || '', 980 })); 981 const versionResponse = await axios.get<RuntimeVersionInfo>('/api/version', { headers: authHeaders }); 982 setRuntimeVersion(versionResponse.data); 983 984 if (profile.isAdmin) { 985 const [twitterResponse, aiResponse, updateStatusResponse, usersResponse] = await Promise.all([ 986 axios.get<TwitterConfig>('/api/twitter-config', { headers: authHeaders }), 987 axios.get<AIConfig>('/api/ai-config', { headers: authHeaders }), 988 axios.get<UpdateStatusInfo>('/api/update-status', { headers: authHeaders }), 989 axios.get<ManagedUser[]>('/api/admin/users', { headers: authHeaders }), 990 ]); 991 992 setTwitterConfig({ 993 authToken: twitterResponse.data.authToken || '', 994 ct0: twitterResponse.data.ct0 || '', 995 backupAuthToken: twitterResponse.data.backupAuthToken || '', 996 backupCt0: twitterResponse.data.backupCt0 || '', 997 }); 998 999 setAiConfig({ 1000 provider: aiResponse.data.provider || 'gemini', 1001 apiKey: aiResponse.data.apiKey || '', 1002 model: aiResponse.data.model || '', 1003 baseUrl: aiResponse.data.baseUrl || '', 1004 }); 1005 setUpdateStatus(updateStatusResponse.data); 1006 setManagedUsers(Array.isArray(usersResponse.data) ? usersResponse.data : []); 1007 } else { 1008 setUpdateStatus(null); 1009 setManagedUsers([]); 1010 } 1011 1012 await Promise.all([fetchStatus(), fetchRecentActivity(), fetchEnrichedPosts()]); 1013 await fetchProfiles(mappingData.map((mapping) => mapping.bskyIdentifier)); 1014 } catch (error) { 1015 handleAuthFailure(error, 'Failed to load dashboard data.'); 1016 } 1017 }, [authHeaders, fetchEnrichedPosts, fetchProfiles, fetchRecentActivity, fetchStatus, handleAuthFailure]); 1018 1019 useEffect(() => { 1020 localStorage.setItem('theme-mode', themeMode); 1021 }, [themeMode]); 1022 1023 useEffect(() => { 1024 localStorage.setItem('dashboard-tab', activeTab); 1025 }, [activeTab]); 1026 1027 useEffect(() => { 1028 const expectedPath = TAB_PATHS[activeTab]; 1029 const currentPath = normalizePath(window.location.pathname); 1030 if (currentPath !== expectedPath) { 1031 window.history.pushState({ tab: activeTab }, '', expectedPath); 1032 } 1033 }, [activeTab]); 1034 1035 useEffect(() => { 1036 const onPopState = () => { 1037 const tabFromPath = getTabFromPath(window.location.pathname); 1038 if (tabFromPath) { 1039 setActiveTab(tabFromPath); 1040 } else { 1041 setActiveTab('overview'); 1042 } 1043 }; 1044 1045 window.addEventListener('popstate', onPopState); 1046 return () => { 1047 window.removeEventListener('popstate', onPopState); 1048 }; 1049 }, []); 1050 1051 useEffect(() => { 1052 localStorage.setItem('accounts-collapsed-groups', JSON.stringify(collapsedGroupKeys)); 1053 }, [collapsedGroupKeys]); 1054 1055 useEffect(() => { 1056 const media = window.matchMedia('(prefers-color-scheme: dark)'); 1057 1058 const applyTheme = () => { 1059 const next = themeMode === 'system' ? (media.matches ? 'dark' : 'light') : themeMode; 1060 setResolvedTheme(next); 1061 document.documentElement.classList.remove('light', 'dark'); 1062 document.documentElement.classList.add(next); 1063 }; 1064 1065 applyTheme(); 1066 media.addEventListener('change', applyTheme); 1067 1068 return () => { 1069 media.removeEventListener('change', applyTheme); 1070 }; 1071 }, [themeMode]); 1072 1073 useEffect(() => { 1074 if (!token) { 1075 void fetchBootstrapStatus(); 1076 return; 1077 } 1078 1079 void fetchData(); 1080 }, [token, fetchBootstrapStatus, fetchData]); 1081 1082 useEffect(() => { 1083 if (!bootstrapOpen && authView === 'register') { 1084 setAuthView('login'); 1085 } 1086 }, [authView, bootstrapOpen]); 1087 1088 useEffect(() => { 1089 if (!token) { 1090 return; 1091 } 1092 1093 const statusInterval = window.setInterval(() => { 1094 void fetchStatus(); 1095 }, 2000); 1096 1097 const activityInterval = window.setInterval(() => { 1098 void fetchRecentActivity(); 1099 }, 7000); 1100 1101 const postsInterval = window.setInterval(() => { 1102 void fetchEnrichedPosts(); 1103 }, 12000); 1104 1105 return () => { 1106 window.clearInterval(statusInterval); 1107 window.clearInterval(activityInterval); 1108 window.clearInterval(postsInterval); 1109 }; 1110 }, [token, fetchEnrichedPosts, fetchRecentActivity, fetchStatus]); 1111 1112 useEffect(() => { 1113 if (!token) { 1114 return; 1115 } 1116 1117 const versionInterval = window.setInterval(() => { 1118 void fetchRuntimeVersion(); 1119 if (isAdmin) { 1120 void fetchUpdateStatus(); 1121 } 1122 }, 15000); 1123 1124 return () => { 1125 window.clearInterval(versionInterval); 1126 }; 1127 }, [token, isAdmin, fetchRuntimeVersion, fetchUpdateStatus]); 1128 1129 useEffect(() => { 1130 if (!status?.nextCheckTime) { 1131 setCountdown('--'); 1132 return; 1133 } 1134 1135 const updateCountdown = () => { 1136 const ms = status.nextCheckTime - Date.now(); 1137 if (ms <= 0) { 1138 setCountdown('Checking...'); 1139 return; 1140 } 1141 1142 const minutes = Math.floor(ms / 60000); 1143 const seconds = Math.floor((ms % 60000) / 1000); 1144 setCountdown(`${minutes}m ${String(seconds).padStart(2, '0')}s`); 1145 }; 1146 1147 updateCountdown(); 1148 const timer = window.setInterval(updateCountdown, 1000); 1149 1150 return () => { 1151 window.clearInterval(timer); 1152 }; 1153 }, [status?.nextCheckTime]); 1154 1155 useEffect(() => { 1156 return () => { 1157 if (noticeTimerRef.current) { 1158 window.clearTimeout(noticeTimerRef.current); 1159 } 1160 }; 1161 }, []); 1162 1163 useEffect(() => { 1164 if (!isAddAccountSheetOpen) { 1165 return; 1166 } 1167 1168 const onKeyDown = (event: KeyboardEvent) => { 1169 if (event.key === 'Escape') { 1170 closeAddAccountSheet(); 1171 } 1172 }; 1173 1174 window.addEventListener('keydown', onKeyDown); 1175 return () => { 1176 window.removeEventListener('keydown', onKeyDown); 1177 }; 1178 }, [isAddAccountSheetOpen]); 1179 1180 const pendingBackfills = status?.pendingBackfills ?? []; 1181 const currentStatus = status?.currentStatus; 1182 const latestActivity = recentActivity[0]; 1183 const dashboardTabs = useMemo( 1184 () => [ 1185 { id: 'overview' as DashboardTab, label: 'Overview', icon: LayoutDashboard }, 1186 { id: 'accounts' as DashboardTab, label: 'Accounts', icon: Users }, 1187 { id: 'posts' as DashboardTab, label: 'Posts', icon: Newspaper }, 1188 { id: 'activity' as DashboardTab, label: 'Activity', icon: History }, 1189 { id: 'settings' as DashboardTab, label: 'Settings', icon: Settings2 }, 1190 ], 1191 [], 1192 ); 1193 const postedActivity = useMemo(() => enrichedPosts.slice(0, 12), [enrichedPosts]); 1194 const engagementByAccount = useMemo(() => { 1195 const map = new Map<string, { identifier: string; score: number; posts: number }>(); 1196 for (const post of enrichedPosts) { 1197 const key = normalizeTwitterUsername(post.bskyIdentifier); 1198 const existing = map.get(key) || { 1199 identifier: post.bskyIdentifier, 1200 score: 0, 1201 posts: 0, 1202 }; 1203 existing.score += post.stats.engagement || 0; 1204 existing.posts += 1; 1205 map.set(key, existing); 1206 } 1207 return [...map.values()].sort((a, b) => b.score - a.score); 1208 }, [enrichedPosts]); 1209 const topAccount = engagementByAccount[0]; 1210 const getProfileForActor = useCallback( 1211 (actor: string) => profilesByActor[normalizeTwitterUsername(actor)], 1212 [profilesByActor], 1213 ); 1214 const topAccountProfile = topAccount ? getProfileForActor(topAccount.identifier) : undefined; 1215 const mappingsByBskyIdentifier = useMemo(() => { 1216 const map = new Map<string, AccountMapping>(); 1217 for (const mapping of mappings) { 1218 map.set(normalizeTwitterUsername(mapping.bskyIdentifier), mapping); 1219 } 1220 return map; 1221 }, [mappings]); 1222 const mappingsByTwitterUsername = useMemo(() => { 1223 const map = new Map<string, AccountMapping>(); 1224 for (const mapping of mappings) { 1225 for (const username of mapping.twitterUsernames) { 1226 map.set(normalizeTwitterUsername(username), mapping); 1227 } 1228 } 1229 return map; 1230 }, [mappings]); 1231 const groupOptions = useMemo(() => { 1232 const options = new Map<string, { key: string; name: string; emoji: string }>(); 1233 for (const group of groups) { 1234 const meta = getGroupMeta(group.name, group.emoji); 1235 if (meta.key === DEFAULT_GROUP_KEY) { 1236 continue; 1237 } 1238 options.set(meta.key, meta); 1239 } 1240 for (const mapping of mappings) { 1241 const group = getMappingGroupMeta(mapping); 1242 options.set(group.key, options.get(group.key) || group); 1243 } 1244 return [...options.values()].sort((a, b) => { 1245 const aUngrouped = a.name === DEFAULT_GROUP_NAME; 1246 const bUngrouped = b.name === DEFAULT_GROUP_NAME; 1247 if (aUngrouped && !bUngrouped) return 1; 1248 if (!aUngrouped && bUngrouped) return -1; 1249 return a.name.localeCompare(b.name); 1250 }); 1251 }, [groups, mappings]); 1252 const groupOptionsByKey = useMemo(() => new Map(groupOptions.map((group) => [group.key, group])), [groupOptions]); 1253 const reusableGroupOptions = useMemo( 1254 () => groupOptions.filter((group) => group.key !== DEFAULT_GROUP_KEY), 1255 [groupOptions], 1256 ); 1257 const managedUsersById = useMemo(() => new Map(managedUsers.map((user) => [user.id, user])), [managedUsers]); 1258 const accountMappingsForView = useMemo(() => { 1259 if (!isAdmin || accountsCreatorFilter === 'all') { 1260 return mappings; 1261 } 1262 return mappings.filter((mapping) => mapping.createdByUserId === accountsCreatorFilter); 1263 }, [accountsCreatorFilter, isAdmin, mappings]); 1264 const groupedMappings = useMemo(() => { 1265 const groups = new Map<string, { key: string; name: string; emoji: string; mappings: AccountMapping[] }>(); 1266 for (const option of groupOptions) { 1267 groups.set(option.key, { 1268 ...option, 1269 mappings: [], 1270 }); 1271 } 1272 for (const mapping of accountMappingsForView) { 1273 const group = getMappingGroupMeta(mapping); 1274 const existing = groups.get(group.key); 1275 if (!existing) { 1276 groups.set(group.key, { ...group, mappings: [mapping] }); 1277 continue; 1278 } 1279 existing.mappings.push(mapping); 1280 } 1281 1282 return [...groups.values()] 1283 .sort((a, b) => { 1284 const aUngrouped = a.name === DEFAULT_GROUP_NAME; 1285 const bUngrouped = b.name === DEFAULT_GROUP_NAME; 1286 if (aUngrouped && !bUngrouped) return 1; 1287 if (!aUngrouped && bUngrouped) return -1; 1288 return a.name.localeCompare(b.name); 1289 }) 1290 .map((group) => ({ 1291 ...group, 1292 mappings: [...group.mappings].sort((a, b) => 1293 `${(a.owner || '').toLowerCase()}-${a.bskyIdentifier.toLowerCase()}`.localeCompare( 1294 `${(b.owner || '').toLowerCase()}-${b.bskyIdentifier.toLowerCase()}`, 1295 ), 1296 ), 1297 })); 1298 }, [accountMappingsForView, groupOptions]); 1299 const normalizedAccountsQuery = useMemo(() => normalizeSearchValue(accountsSearchQuery), [accountsSearchQuery]); 1300 const accountSearchTokens = useMemo(() => tokenizeSearchValue(normalizedAccountsQuery), [normalizedAccountsQuery]); 1301 const accountSearchScores = useMemo(() => { 1302 const scores = new Map<string, number>(); 1303 if (!normalizedAccountsQuery) { 1304 return scores; 1305 } 1306 1307 for (const mapping of accountMappingsForView) { 1308 scores.set(mapping.id, scoreAccountMapping(mapping, normalizedAccountsQuery, accountSearchTokens)); 1309 } 1310 return scores; 1311 }, [accountMappingsForView, accountSearchTokens, normalizedAccountsQuery]); 1312 const filteredGroupedMappings = useMemo(() => { 1313 const hasQuery = normalizedAccountsQuery.length > 0; 1314 const sortByScore = (items: AccountMapping[]) => { 1315 if (!hasQuery) { 1316 return items; 1317 } 1318 return [...items].sort((a, b) => { 1319 const scoreDelta = (accountSearchScores.get(b.id) || 0) - (accountSearchScores.get(a.id) || 0); 1320 if (scoreDelta !== 0) return scoreDelta; 1321 return `${(a.owner || '').toLowerCase()}-${a.bskyIdentifier.toLowerCase()}`.localeCompare( 1322 `${(b.owner || '').toLowerCase()}-${b.bskyIdentifier.toLowerCase()}`, 1323 ); 1324 }); 1325 }; 1326 1327 const withSearch = groupedMappings 1328 .map((group) => { 1329 const mappingsForGroup = hasQuery 1330 ? group.mappings.filter((mapping) => (accountSearchScores.get(mapping.id) || 0) >= ACCOUNT_SEARCH_MIN_SCORE) 1331 : group.mappings; 1332 return { 1333 ...group, 1334 mappings: sortByScore(mappingsForGroup), 1335 }; 1336 }) 1337 .filter((group) => !hasQuery || group.mappings.length > 0); 1338 1339 if (accountsViewMode === 'grouped') { 1340 return withSearch; 1341 } 1342 1343 const allMappings = sortByScore( 1344 hasQuery 1345 ? accountMappingsForView.filter( 1346 (mapping) => (accountSearchScores.get(mapping.id) || 0) >= ACCOUNT_SEARCH_MIN_SCORE, 1347 ) 1348 : [...accountMappingsForView].sort((a, b) => 1349 `${(a.owner || '').toLowerCase()}-${a.bskyIdentifier.toLowerCase()}`.localeCompare( 1350 `${(b.owner || '').toLowerCase()}-${b.bskyIdentifier.toLowerCase()}`, 1351 ), 1352 ), 1353 ); 1354 1355 return [ 1356 { 1357 key: '__all__', 1358 name: hasQuery ? 'Search Results' : 'All Accounts', 1359 emoji: hasQuery ? '🔎' : '🌐', 1360 mappings: allMappings, 1361 }, 1362 ]; 1363 }, [accountMappingsForView, accountSearchScores, accountsViewMode, groupedMappings, normalizedAccountsQuery]); 1364 const accountMatchesCount = useMemo( 1365 () => filteredGroupedMappings.reduce((total, group) => total + group.mappings.length, 0), 1366 [filteredGroupedMappings], 1367 ); 1368 const groupKeysForCollapse = useMemo(() => groupedMappings.map((group) => group.key), [groupedMappings]); 1369 const allGroupsCollapsed = useMemo( 1370 () => 1371 groupKeysForCollapse.length > 0 && 1372 groupKeysForCollapse.every((groupKey) => collapsedGroupKeys[groupKey] === true), 1373 [collapsedGroupKeys, groupKeysForCollapse], 1374 ); 1375 const resolveMappingForLocalPost = useCallback( 1376 (post: LocalPostSearchResult) => 1377 mappingsByBskyIdentifier.get(normalizeTwitterUsername(post.bskyIdentifier)) || 1378 mappingsByTwitterUsername.get(normalizeTwitterUsername(post.twitterUsername)), 1379 [mappingsByBskyIdentifier, mappingsByTwitterUsername], 1380 ); 1381 const resolveMappingForPost = useCallback( 1382 (post: EnrichedPost) => 1383 mappingsByBskyIdentifier.get(normalizeTwitterUsername(post.bskyIdentifier)) || 1384 mappingsByTwitterUsername.get(normalizeTwitterUsername(post.twitterUsername)), 1385 [mappingsByBskyIdentifier, mappingsByTwitterUsername], 1386 ); 1387 const resolveMappingForActivity = useCallback( 1388 (activity: ActivityLog) => 1389 mappingsByBskyIdentifier.get(normalizeTwitterUsername(activity.bsky_identifier)) || 1390 mappingsByTwitterUsername.get(normalizeTwitterUsername(activity.twitter_username)), 1391 [mappingsByBskyIdentifier, mappingsByTwitterUsername], 1392 ); 1393 const filteredPostedActivity = useMemo( 1394 () => 1395 postedActivity.filter((post) => { 1396 if (postsGroupFilter === 'all') return true; 1397 const mapping = resolveMappingForPost(post); 1398 return getMappingGroupMeta(mapping).key === postsGroupFilter; 1399 }), 1400 [postedActivity, postsGroupFilter, resolveMappingForPost], 1401 ); 1402 const filteredLocalPostSearchResults = useMemo( 1403 () => 1404 localPostSearchResults.filter((post) => { 1405 if (postsGroupFilter === 'all') return true; 1406 const mapping = resolveMappingForLocalPost(post); 1407 return getMappingGroupMeta(mapping).key === postsGroupFilter; 1408 }), 1409 [localPostSearchResults, postsGroupFilter, resolveMappingForLocalPost], 1410 ); 1411 const filteredRecentActivity = useMemo( 1412 () => 1413 recentActivity.filter((activity) => { 1414 if (activityGroupFilter === 'all') return true; 1415 const mapping = resolveMappingForActivity(activity); 1416 return getMappingGroupMeta(mapping).key === activityGroupFilter; 1417 }), 1418 [activityGroupFilter, recentActivity, resolveMappingForActivity], 1419 ); 1420 const canManageMapping = useCallback( 1421 (mapping: AccountMapping) => 1422 canManageAllMappings || 1423 (canManageOwnMappings && (!mapping.createdByUserId || mapping.createdByUserId === me?.id)), 1424 [canManageAllMappings, canManageOwnMappings, me?.id], 1425 ); 1426 const twitterConfigured = Boolean(twitterConfig.authToken && twitterConfig.ct0); 1427 const aiConfigured = Boolean(aiConfig.apiKey); 1428 const sectionDefaultExpanded = useMemo<Record<SettingsSection, boolean>>( 1429 () => ({ 1430 account: true, 1431 users: true, 1432 twitter: !twitterConfigured, 1433 ai: !aiConfigured, 1434 data: false, 1435 }), 1436 [aiConfigured, twitterConfigured], 1437 ); 1438 const isSettingsSectionExpanded = useCallback( 1439 (section: SettingsSection) => settingsSectionOverrides[section] ?? sectionDefaultExpanded[section], 1440 [sectionDefaultExpanded, settingsSectionOverrides], 1441 ); 1442 const toggleSettingsSection = (section: SettingsSection) => { 1443 setSettingsSectionOverrides((previous) => ({ 1444 ...previous, 1445 [section]: !(previous[section] ?? sectionDefaultExpanded[section]), 1446 })); 1447 }; 1448 1449 useEffect(() => { 1450 if (postsGroupFilter !== 'all' && !groupOptions.some((group) => group.key === postsGroupFilter)) { 1451 setPostsGroupFilter('all'); 1452 } 1453 if (activityGroupFilter !== 'all' && !groupOptions.some((group) => group.key === activityGroupFilter)) { 1454 setActivityGroupFilter('all'); 1455 } 1456 }, [activityGroupFilter, groupOptions, postsGroupFilter]); 1457 1458 useEffect(() => { 1459 if (!isAdmin) { 1460 if (accountsCreatorFilter !== 'all') { 1461 setAccountsCreatorFilter('all'); 1462 } 1463 return; 1464 } 1465 1466 if (accountsCreatorFilter !== 'all' && !managedUsers.some((user) => user.id === accountsCreatorFilter)) { 1467 setAccountsCreatorFilter('all'); 1468 } 1469 }, [accountsCreatorFilter, isAdmin, managedUsers]); 1470 1471 useEffect(() => { 1472 setGroupDraftsByKey((previous) => { 1473 const next: Record<string, { name: string; emoji: string }> = {}; 1474 for (const group of reusableGroupOptions) { 1475 const existing = previous[group.key]; 1476 next[group.key] = { 1477 name: existing?.name ?? group.name, 1478 emoji: existing?.emoji ?? group.emoji, 1479 }; 1480 } 1481 return next; 1482 }); 1483 }, [reusableGroupOptions]); 1484 1485 useEffect(() => { 1486 if (!authHeaders) { 1487 setIsSearchingLocalPosts(false); 1488 setLocalPostSearchResults([]); 1489 return; 1490 } 1491 1492 const query = postsSearchQuery.trim(); 1493 if (!query) { 1494 postsSearchRequestRef.current += 1; 1495 setIsSearchingLocalPosts(false); 1496 setLocalPostSearchResults([]); 1497 return; 1498 } 1499 1500 const requestId = postsSearchRequestRef.current + 1; 1501 postsSearchRequestRef.current = requestId; 1502 setIsSearchingLocalPosts(true); 1503 1504 const timer = window.setTimeout(async () => { 1505 try { 1506 const response = await axios.get<LocalPostSearchResult[]>('/api/posts/search', { 1507 params: { q: query, limit: 120 }, 1508 headers: authHeaders, 1509 }); 1510 if (postsSearchRequestRef.current !== requestId) { 1511 return; 1512 } 1513 setLocalPostSearchResults(Array.isArray(response.data) ? response.data : []); 1514 } catch (error) { 1515 if (postsSearchRequestRef.current !== requestId) { 1516 return; 1517 } 1518 setLocalPostSearchResults([]); 1519 handleAuthFailure(error, 'Failed to search local post history.'); 1520 } finally { 1521 if (postsSearchRequestRef.current === requestId) { 1522 setIsSearchingLocalPosts(false); 1523 } 1524 } 1525 }, 220); 1526 1527 return () => { 1528 window.clearTimeout(timer); 1529 }; 1530 }, [authHeaders, handleAuthFailure, postsSearchQuery]); 1531 1532 const isBackfillQueued = useCallback( 1533 (mappingId: string) => pendingBackfills.some((entry) => entry.id === mappingId), 1534 [pendingBackfills], 1535 ); 1536 1537 const getBackfillEntry = useCallback( 1538 (mappingId: string) => pendingBackfills.find((entry) => entry.id === mappingId), 1539 [pendingBackfills], 1540 ); 1541 1542 const isBackfillActive = useCallback( 1543 (mappingId: string) => currentStatus?.state === 'backfilling' && currentStatus.backfillMappingId === mappingId, 1544 [currentStatus], 1545 ); 1546 1547 const progressPercent = useMemo(() => { 1548 if (!currentStatus?.totalCount || currentStatus.totalCount <= 0) { 1549 return 0; 1550 } 1551 const processed = currentStatus.processedCount || 0; 1552 return Math.max(0, Math.min(100, Math.round((processed / currentStatus.totalCount) * 100))); 1553 }, [currentStatus]); 1554 1555 const cycleThemeMode = () => { 1556 setThemeMode((prev) => { 1557 if (prev === 'system') return 'light'; 1558 if (prev === 'light') return 'dark'; 1559 return 'system'; 1560 }); 1561 }; 1562 1563 const themeIcon = 1564 themeMode === 'system' ? ( 1565 <SunMoon className="h-4 w-4" /> 1566 ) : themeMode === 'light' ? ( 1567 <Sun className="h-4 w-4" /> 1568 ) : ( 1569 <Moon className="h-4 w-4" /> 1570 ); 1571 1572 const themeLabel = themeMode === 'system' ? `Theme: system (${resolvedTheme})` : `Theme: ${themeMode}`; 1573 const runtimeVersionLabel = runtimeVersion 1574 ? `v${runtimeVersion.version}${runtimeVersion.commit ? ` (${runtimeVersion.commit})` : ''}` 1575 : 'v--'; 1576 const runtimeBranchLabel = runtimeVersion?.branch ? `branch ${runtimeVersion.branch}` : null; 1577 const updateStateLabel = updateStatus?.running 1578 ? 'Update in progress' 1579 : updateStatus?.finishedAt 1580 ? updateStatus.exitCode === 0 1581 ? 'Last update succeeded' 1582 : 'Last update failed' 1583 : 'No update run recorded'; 1584 1585 const handleLogin = async (event: React.FormEvent<HTMLFormElement>) => { 1586 event.preventDefault(); 1587 setAuthError(''); 1588 setIsBusy(true); 1589 1590 const data = new FormData(event.currentTarget); 1591 const identifier = String(data.get('identifier') || '').trim(); 1592 const password = String(data.get('password') || ''); 1593 1594 try { 1595 const response = await axios.post<{ token: string }>('/api/login', { identifier, password }); 1596 localStorage.setItem('token', response.data.token); 1597 setToken(response.data.token); 1598 showNotice('success', 'Logged in.'); 1599 } catch (error) { 1600 setAuthError(getApiErrorMessage(error, 'Invalid credentials.')); 1601 } finally { 1602 setIsBusy(false); 1603 } 1604 }; 1605 1606 const handleRegister = async (event: React.FormEvent<HTMLFormElement>) => { 1607 event.preventDefault(); 1608 setAuthError(''); 1609 setIsBusy(true); 1610 1611 const data = new FormData(event.currentTarget); 1612 const username = String(data.get('username') || '').trim(); 1613 const email = String(data.get('email') || '').trim(); 1614 const password = String(data.get('password') || ''); 1615 1616 try { 1617 await axios.post('/api/register', { username, email, password }); 1618 setAuthView('login'); 1619 showNotice('success', 'Registration successful. Please log in.'); 1620 await fetchBootstrapStatus(); 1621 } catch (error) { 1622 setAuthError(getApiErrorMessage(error, 'Registration failed.')); 1623 } finally { 1624 setIsBusy(false); 1625 } 1626 }; 1627 1628 const runNow = async () => { 1629 if (!authHeaders) { 1630 return; 1631 } 1632 if (!canRunNowPermission) { 1633 showNotice('error', 'You do not have permission to run checks now.'); 1634 return; 1635 } 1636 1637 try { 1638 await axios.post('/api/run-now', {}, { headers: authHeaders }); 1639 showNotice('info', 'Check triggered.'); 1640 await fetchStatus(); 1641 } catch (error) { 1642 handleAuthFailure(error, 'Failed to trigger a check.'); 1643 } 1644 }; 1645 1646 const clearAllBackfills = async () => { 1647 if (!authHeaders) { 1648 return; 1649 } 1650 1651 const confirmed = window.confirm('Stop all pending and active backfills?'); 1652 if (!confirmed) { 1653 return; 1654 } 1655 1656 try { 1657 await axios.post('/api/backfill/clear-all', {}, { headers: authHeaders }); 1658 showNotice('success', 'Backfill queue cleared.'); 1659 await fetchStatus(); 1660 } catch (error) { 1661 handleAuthFailure(error, 'Failed to clear backfill queue.'); 1662 } 1663 }; 1664 1665 const requestBackfill = async (mappingId: string, mode: 'normal' | 'reset') => { 1666 if (!authHeaders) { 1667 return; 1668 } 1669 const mapping = mappings.find((entry) => entry.id === mappingId); 1670 if (!mapping || !canQueueBackfillsPermission || !canManageMapping(mapping)) { 1671 showNotice('error', 'You do not have permission to queue backfill for this account.'); 1672 return; 1673 } 1674 1675 const busy = pendingBackfills.length > 0 || currentStatus?.state === 'backfilling'; 1676 if (busy) { 1677 const proceed = window.confirm( 1678 'Backfill is already queued or active. This request will replace the existing queue item for this account. Continue?', 1679 ); 1680 if (!proceed) { 1681 return; 1682 } 1683 } 1684 const safeLimit = DEFAULT_BACKFILL_LIMIT; 1685 1686 try { 1687 if (mode === 'reset') { 1688 if (!isAdmin) { 1689 showNotice('error', 'Only admins can reset cache before backfill.'); 1690 return; 1691 } 1692 await axios.delete(`/api/mappings/${mappingId}/cache`, { headers: authHeaders }); 1693 } 1694 1695 await axios.post(`/api/backfill/${mappingId}`, { limit: safeLimit }, { headers: authHeaders }); 1696 showNotice( 1697 'success', 1698 mode === 'reset' 1699 ? `Cache reset and backfill queued (${safeLimit} tweets).` 1700 : `Backfill queued (${safeLimit} tweets).`, 1701 ); 1702 await fetchStatus(); 1703 } catch (error) { 1704 handleAuthFailure(error, 'Failed to queue backfill.'); 1705 } 1706 }; 1707 1708 const handleDeleteAllPosts = async (mappingId: string) => { 1709 if (!authHeaders) { 1710 return; 1711 } 1712 1713 const firstConfirm = window.confirm( 1714 'Danger: this deletes all posts on the mapped Bluesky account and clears local cache. Continue?', 1715 ); 1716 1717 if (!firstConfirm) { 1718 return; 1719 } 1720 1721 const finalConfirm = window.prompt('Type DELETE to confirm:'); 1722 if (finalConfirm !== 'DELETE') { 1723 return; 1724 } 1725 1726 try { 1727 const response = await axios.post<{ message: string }>( 1728 `/api/mappings/${mappingId}/delete-all-posts`, 1729 {}, 1730 { headers: authHeaders }, 1731 ); 1732 showNotice('success', response.data.message); 1733 } catch (error) { 1734 handleAuthFailure(error, 'Failed to delete posts.'); 1735 } 1736 }; 1737 1738 const handleDeleteMapping = async (mappingId: string) => { 1739 if (!authHeaders) { 1740 return; 1741 } 1742 const mapping = mappings.find((entry) => entry.id === mappingId); 1743 if (!mapping || !canManageMapping(mapping)) { 1744 showNotice('error', 'You do not have permission to delete this mapping.'); 1745 return; 1746 } 1747 1748 const confirmed = window.confirm('Delete this mapping?'); 1749 if (!confirmed) { 1750 return; 1751 } 1752 1753 try { 1754 await axios.delete(`/api/mappings/${mappingId}`, { headers: authHeaders }); 1755 setMappings((prev) => prev.filter((mapping) => mapping.id !== mappingId)); 1756 showNotice('success', 'Mapping deleted.'); 1757 await fetchData(); 1758 } catch (error) { 1759 handleAuthFailure(error, 'Failed to delete mapping.'); 1760 } 1761 }; 1762 1763 const addNewTwitterUsername = () => { 1764 setNewTwitterUsers((previous) => addTwitterUsernames(previous, newTwitterInput)); 1765 setNewTwitterInput(''); 1766 }; 1767 1768 const removeNewTwitterUsername = (username: string) => { 1769 setNewTwitterUsers((previous) => 1770 previous.filter((existing) => normalizeTwitterUsername(existing) !== normalizeTwitterUsername(username)), 1771 ); 1772 }; 1773 1774 const addEditTwitterUsername = () => { 1775 setEditTwitterUsers((previous) => addTwitterUsernames(previous, editTwitterInput)); 1776 setEditTwitterInput(''); 1777 }; 1778 1779 const removeEditTwitterUsername = (username: string) => { 1780 setEditTwitterUsers((previous) => 1781 previous.filter((existing) => normalizeTwitterUsername(existing) !== normalizeTwitterUsername(username)), 1782 ); 1783 }; 1784 1785 const toggleGroupCollapsed = (groupKey: string) => { 1786 setCollapsedGroupKeys((previous) => ({ 1787 ...previous, 1788 [groupKey]: !previous[groupKey], 1789 })); 1790 }; 1791 1792 const toggleCollapseAllGroups = () => { 1793 const shouldCollapse = !allGroupsCollapsed; 1794 setCollapsedGroupKeys((previous) => { 1795 const next = { ...previous }; 1796 for (const groupKey of groupKeysForCollapse) { 1797 next[groupKey] = shouldCollapse; 1798 } 1799 return next; 1800 }); 1801 }; 1802 1803 const handleCreateGroup = async (event: React.FormEvent<HTMLFormElement>) => { 1804 event.preventDefault(); 1805 if (!authHeaders) { 1806 return; 1807 } 1808 if (!canManageGroupsPermission) { 1809 showNotice('error', 'You do not have permission to create groups.'); 1810 return; 1811 } 1812 1813 const name = newGroupName.trim(); 1814 const emoji = newGroupEmoji.trim() || DEFAULT_GROUP_EMOJI; 1815 if (!name) { 1816 showNotice('error', 'Enter a group name first.'); 1817 return; 1818 } 1819 1820 setIsBusy(true); 1821 try { 1822 await axios.post('/api/groups', { name, emoji }, { headers: authHeaders }); 1823 setNewGroupName(''); 1824 setNewGroupEmoji(DEFAULT_GROUP_EMOJI); 1825 await fetchGroups(); 1826 showNotice('success', `Group "${name}" created.`); 1827 } catch (error) { 1828 handleAuthFailure(error, 'Failed to create group.'); 1829 } finally { 1830 setIsBusy(false); 1831 } 1832 }; 1833 1834 const handleAssignMappingGroup = async (mapping: AccountMapping, groupKey: string) => { 1835 if (!authHeaders) { 1836 return; 1837 } 1838 if (!canManageMapping(mapping)) { 1839 showNotice('error', 'You do not have permission to update this mapping.'); 1840 return; 1841 } 1842 1843 const selectedGroup = groupOptionsByKey.get(groupKey); 1844 const nextGroupName = selectedGroup?.name || ''; 1845 const nextGroupEmoji = selectedGroup?.emoji || ''; 1846 1847 try { 1848 await axios.put( 1849 `/api/mappings/${mapping.id}`, 1850 { 1851 groupName: nextGroupName, 1852 groupEmoji: nextGroupEmoji, 1853 }, 1854 { headers: authHeaders }, 1855 ); 1856 1857 setMappings((previous) => 1858 previous.map((entry) => 1859 entry.id === mapping.id 1860 ? { 1861 ...entry, 1862 groupName: nextGroupName || undefined, 1863 groupEmoji: nextGroupEmoji || undefined, 1864 } 1865 : entry, 1866 ), 1867 ); 1868 1869 if (nextGroupName) { 1870 setGroups((previous) => { 1871 const key = getGroupKey(nextGroupName); 1872 if (previous.some((group) => getGroupKey(group.name) === key)) { 1873 return previous; 1874 } 1875 return [...previous, { name: nextGroupName, ...(nextGroupEmoji ? { emoji: nextGroupEmoji } : {}) }]; 1876 }); 1877 } 1878 } catch (error) { 1879 handleAuthFailure(error, 'Failed to move account to folder.'); 1880 } 1881 }; 1882 1883 const updateGroupDraft = (groupKey: string, field: 'name' | 'emoji', value: string) => { 1884 setGroupDraftsByKey((previous) => ({ 1885 ...previous, 1886 [groupKey]: { 1887 name: previous[groupKey]?.name ?? '', 1888 emoji: previous[groupKey]?.emoji ?? '', 1889 [field]: value, 1890 }, 1891 })); 1892 }; 1893 1894 const handleRenameGroup = async (groupKey: string) => { 1895 if (!authHeaders) { 1896 return; 1897 } 1898 if (!canManageGroupsPermission) { 1899 showNotice('error', 'You do not have permission to rename groups.'); 1900 return; 1901 } 1902 1903 const draft = groupDraftsByKey[groupKey]; 1904 if (!draft || !draft.name.trim()) { 1905 showNotice('error', 'Group name is required.'); 1906 return; 1907 } 1908 1909 setIsGroupActionBusy(true); 1910 try { 1911 await axios.put( 1912 `/api/groups/${encodeURIComponent(groupKey)}`, 1913 { 1914 name: draft.name.trim(), 1915 emoji: draft.emoji.trim(), 1916 }, 1917 { headers: authHeaders }, 1918 ); 1919 showNotice('success', 'Group updated.'); 1920 await fetchData(); 1921 } catch (error) { 1922 handleAuthFailure(error, 'Failed to update group.'); 1923 } finally { 1924 setIsGroupActionBusy(false); 1925 } 1926 }; 1927 1928 const handleDeleteGroup = async (groupKey: string) => { 1929 if (!authHeaders) { 1930 return; 1931 } 1932 if (!canManageGroupsPermission) { 1933 showNotice('error', 'You do not have permission to delete groups.'); 1934 return; 1935 } 1936 1937 const group = groupOptionsByKey.get(groupKey); 1938 if (!group) { 1939 showNotice('error', 'Group not found.'); 1940 return; 1941 } 1942 1943 const confirmed = window.confirm( 1944 `Delete "${group.name}"? Mappings in this folder will move to ${DEFAULT_GROUP_NAME}.`, 1945 ); 1946 if (!confirmed) { 1947 return; 1948 } 1949 1950 setIsGroupActionBusy(true); 1951 try { 1952 const response = await axios.delete<{ reassignedCount?: number }>(`/api/groups/${encodeURIComponent(groupKey)}`, { 1953 headers: authHeaders, 1954 }); 1955 const reassignedCount = response.data?.reassignedCount || 0; 1956 showNotice('success', `Group deleted. ${reassignedCount} account${reassignedCount === 1 ? '' : 's'} moved.`); 1957 await fetchData(); 1958 } catch (error) { 1959 handleAuthFailure(error, 'Failed to delete group.'); 1960 } finally { 1961 setIsGroupActionBusy(false); 1962 } 1963 }; 1964 1965 const resetAddAccountDraft = () => { 1966 setNewMapping({ 1967 ...defaultMappingForm(), 1968 owner: getUserLabel(me), 1969 }); 1970 setNewTwitterUsers([]); 1971 setNewTwitterInput(''); 1972 setAddAccountStep(1); 1973 }; 1974 1975 const openAddAccountSheet = () => { 1976 if (!canCreateMappings) { 1977 showNotice('error', 'You do not have permission to add mappings.'); 1978 return; 1979 } 1980 resetAddAccountDraft(); 1981 setIsAddAccountSheetOpen(true); 1982 }; 1983 1984 const closeAddAccountSheet = () => { 1985 setIsAddAccountSheetOpen(false); 1986 resetAddAccountDraft(); 1987 }; 1988 1989 const applyGroupPresetToNewMapping = (groupKey: string) => { 1990 const group = groupOptionsByKey.get(groupKey); 1991 if (!group || group.key === DEFAULT_GROUP_KEY) { 1992 return; 1993 } 1994 setNewMapping((previous) => ({ 1995 ...previous, 1996 groupName: group.name, 1997 groupEmoji: group.emoji, 1998 })); 1999 }; 2000 2001 const submitNewMapping = async () => { 2002 if (!authHeaders) { 2003 return; 2004 } 2005 if (!canCreateMappings) { 2006 showNotice('error', 'You do not have permission to add mappings.'); 2007 return; 2008 } 2009 2010 if (newTwitterUsers.length === 0) { 2011 showNotice('error', 'Add at least one Twitter username.'); 2012 return; 2013 } 2014 2015 setIsBusy(true); 2016 2017 try { 2018 await axios.post( 2019 '/api/mappings', 2020 { 2021 owner: newMapping.owner.trim(), 2022 twitterUsernames: newTwitterUsers, 2023 bskyIdentifier: newMapping.bskyIdentifier.trim(), 2024 bskyPassword: newMapping.bskyPassword, 2025 bskyServiceUrl: newMapping.bskyServiceUrl.trim(), 2026 groupName: newMapping.groupName.trim(), 2027 groupEmoji: newMapping.groupEmoji.trim(), 2028 }, 2029 { headers: authHeaders }, 2030 ); 2031 2032 setNewMapping(defaultMappingForm()); 2033 setNewTwitterUsers([]); 2034 setNewTwitterInput(''); 2035 setIsAddAccountSheetOpen(false); 2036 setAddAccountStep(1); 2037 showNotice('success', 'Account mapping added.'); 2038 await fetchData(); 2039 } catch (error) { 2040 handleAuthFailure(error, 'Failed to add account mapping.'); 2041 } finally { 2042 setIsBusy(false); 2043 } 2044 }; 2045 2046 const advanceAddAccountStep = () => { 2047 if (addAccountStep === 1) { 2048 if (!newMapping.owner.trim()) { 2049 showNotice('error', 'Owner is required.'); 2050 return; 2051 } 2052 setAddAccountStep(2); 2053 return; 2054 } 2055 2056 if (addAccountStep === 2) { 2057 if (newTwitterUsers.length === 0) { 2058 showNotice('error', 'Add at least one Twitter username.'); 2059 return; 2060 } 2061 setAddAccountStep(3); 2062 return; 2063 } 2064 2065 if (addAccountStep === 3) { 2066 if (!newMapping.bskyIdentifier.trim() || !newMapping.bskyPassword.trim()) { 2067 showNotice('error', 'Bluesky identifier and app password are required.'); 2068 return; 2069 } 2070 setAddAccountStep(4); 2071 } 2072 }; 2073 2074 const retreatAddAccountStep = () => { 2075 setAddAccountStep((previous) => Math.max(1, previous - 1)); 2076 }; 2077 2078 const startEditMapping = (mapping: AccountMapping) => { 2079 if (!canManageMapping(mapping)) { 2080 showNotice('error', 'You do not have permission to edit this mapping.'); 2081 return; 2082 } 2083 setEditingMapping(mapping); 2084 setEditForm({ 2085 owner: mapping.owner || '', 2086 bskyIdentifier: mapping.bskyIdentifier, 2087 bskyPassword: '', 2088 bskyServiceUrl: mapping.bskyServiceUrl || 'https://bsky.social', 2089 groupName: mapping.groupName || '', 2090 groupEmoji: mapping.groupEmoji || '📁', 2091 }); 2092 setEditTwitterUsers(mapping.twitterUsernames); 2093 setEditTwitterInput(''); 2094 }; 2095 2096 const handleUpdateMapping = async (event: React.FormEvent<HTMLFormElement>) => { 2097 event.preventDefault(); 2098 if (!authHeaders || !editingMapping) { 2099 return; 2100 } 2101 if (!canManageMapping(editingMapping)) { 2102 showNotice('error', 'You do not have permission to edit this mapping.'); 2103 return; 2104 } 2105 2106 if (editTwitterUsers.length === 0) { 2107 showNotice('error', 'At least one Twitter username is required.'); 2108 return; 2109 } 2110 2111 setIsBusy(true); 2112 2113 try { 2114 await axios.put( 2115 `/api/mappings/${editingMapping.id}`, 2116 { 2117 owner: editForm.owner.trim(), 2118 twitterUsernames: editTwitterUsers, 2119 bskyIdentifier: editForm.bskyIdentifier.trim(), 2120 bskyPassword: editForm.bskyPassword, 2121 bskyServiceUrl: editForm.bskyServiceUrl.trim(), 2122 groupName: editForm.groupName.trim(), 2123 groupEmoji: editForm.groupEmoji.trim(), 2124 }, 2125 { headers: authHeaders }, 2126 ); 2127 2128 setEditingMapping(null); 2129 setEditForm(defaultMappingForm()); 2130 setEditTwitterUsers([]); 2131 setEditTwitterInput(''); 2132 showNotice('success', 'Mapping updated.'); 2133 await fetchData(); 2134 } catch (error) { 2135 handleAuthFailure(error, 'Failed to update mapping.'); 2136 } finally { 2137 setIsBusy(false); 2138 } 2139 }; 2140 2141 const handleSaveTwitterConfig = async (event: React.FormEvent<HTMLFormElement>) => { 2142 event.preventDefault(); 2143 if (!authHeaders) { 2144 return; 2145 } 2146 2147 setIsBusy(true); 2148 2149 try { 2150 await axios.post( 2151 '/api/twitter-config', 2152 { 2153 authToken: twitterConfig.authToken, 2154 ct0: twitterConfig.ct0, 2155 backupAuthToken: twitterConfig.backupAuthToken, 2156 backupCt0: twitterConfig.backupCt0, 2157 }, 2158 { headers: authHeaders }, 2159 ); 2160 showNotice('success', 'Twitter credentials saved.'); 2161 await fetchData(); 2162 } catch (error) { 2163 handleAuthFailure(error, 'Failed to save Twitter credentials.'); 2164 } finally { 2165 setIsBusy(false); 2166 } 2167 }; 2168 2169 const handleSaveAiConfig = async (event: React.FormEvent<HTMLFormElement>) => { 2170 event.preventDefault(); 2171 if (!authHeaders) { 2172 return; 2173 } 2174 2175 setIsBusy(true); 2176 2177 try { 2178 await axios.post( 2179 '/api/ai-config', 2180 { 2181 provider: aiConfig.provider, 2182 apiKey: aiConfig.apiKey, 2183 model: aiConfig.model, 2184 baseUrl: aiConfig.baseUrl, 2185 }, 2186 { headers: authHeaders }, 2187 ); 2188 showNotice('success', 'AI settings saved.'); 2189 await fetchData(); 2190 } catch (error) { 2191 handleAuthFailure(error, 'Failed to save AI settings.'); 2192 } finally { 2193 setIsBusy(false); 2194 } 2195 }; 2196 2197 const handleExportConfig = async () => { 2198 if (!authHeaders) { 2199 return; 2200 } 2201 2202 try { 2203 const response = await axios.get<Blob>('/api/config/export', { 2204 headers: authHeaders, 2205 responseType: 'blob', 2206 }); 2207 2208 const blobUrl = window.URL.createObjectURL(new Blob([response.data])); 2209 const link = document.createElement('a'); 2210 link.href = blobUrl; 2211 link.download = `tweets-2-bsky-config-${new Date().toISOString().slice(0, 10)}.json`; 2212 document.body.appendChild(link); 2213 link.click(); 2214 link.remove(); 2215 window.URL.revokeObjectURL(blobUrl); 2216 showNotice('success', 'Configuration exported.'); 2217 } catch (error) { 2218 handleAuthFailure(error, 'Failed to export configuration.'); 2219 } 2220 }; 2221 2222 const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => { 2223 if (!authHeaders) { 2224 return; 2225 } 2226 2227 const file = event.target.files?.[0]; 2228 if (!file) { 2229 return; 2230 } 2231 2232 const confirmed = window.confirm( 2233 'This will overwrite accounts/settings (except user logins). Continue with import?', 2234 ); 2235 2236 if (!confirmed) { 2237 event.target.value = ''; 2238 return; 2239 } 2240 2241 try { 2242 const text = await file.text(); 2243 const json = JSON.parse(text); 2244 2245 await axios.post('/api/config/import', json, { headers: authHeaders }); 2246 showNotice('success', 'Configuration imported.'); 2247 await fetchData(); 2248 } catch (error) { 2249 handleAuthFailure(error, 'Failed to import configuration.'); 2250 } finally { 2251 event.target.value = ''; 2252 } 2253 }; 2254 2255 const handleRunUpdate = async () => { 2256 if (!authHeaders || !isAdmin) { 2257 return; 2258 } 2259 2260 const confirmed = window.confirm( 2261 'Run ./update.sh now? The service may restart automatically after update completes.', 2262 ); 2263 if (!confirmed) { 2264 return; 2265 } 2266 2267 setIsUpdateBusy(true); 2268 try { 2269 const response = await axios.post<{ message?: string }>('/api/update', {}, { headers: authHeaders }); 2270 showNotice('info', response.data?.message || 'Update started. Service may restart soon.'); 2271 await Promise.all([fetchRuntimeVersion(), fetchUpdateStatus()]); 2272 } catch (error) { 2273 handleAuthFailure(error, 'Failed to start update.'); 2274 } finally { 2275 setIsUpdateBusy(false); 2276 } 2277 }; 2278 2279 const beginEditUser = (user: ManagedUser) => { 2280 setEditingUserId(user.id); 2281 setEditingUserForm({ 2282 username: user.username || '', 2283 email: user.email || '', 2284 password: '', 2285 isAdmin: user.isAdmin, 2286 permissions: normalizePermissions(user.permissions), 2287 }); 2288 }; 2289 2290 const resetEditingUser = () => { 2291 setEditingUserId(null); 2292 setEditingUserForm(defaultUserForm()); 2293 }; 2294 2295 const handleCreateUser = async (event: React.FormEvent<HTMLFormElement>) => { 2296 event.preventDefault(); 2297 if (!authHeaders || !isAdmin) { 2298 return; 2299 } 2300 2301 const username = normalizeUsername(newUserForm.username); 2302 const email = normalizeEmail(newUserForm.email); 2303 if (!username && !email) { 2304 showNotice('error', 'Provide at least a username or email.'); 2305 return; 2306 } 2307 if (!newUserForm.password || newUserForm.password.length < 8) { 2308 showNotice('error', 'Password must be at least 8 characters.'); 2309 return; 2310 } 2311 2312 setIsBusy(true); 2313 try { 2314 await axios.post( 2315 '/api/admin/users', 2316 { 2317 username: username || undefined, 2318 email: email || undefined, 2319 password: newUserForm.password, 2320 isAdmin: newUserForm.isAdmin, 2321 permissions: newUserForm.permissions, 2322 }, 2323 { headers: authHeaders }, 2324 ); 2325 setNewUserForm(defaultUserForm()); 2326 showNotice('success', 'User account created.'); 2327 await fetchManagedUsers(); 2328 } catch (error) { 2329 handleAuthFailure(error, 'Failed to create user.'); 2330 } finally { 2331 setIsBusy(false); 2332 } 2333 }; 2334 2335 const handleSaveEditedUser = async (userId: string) => { 2336 if (!authHeaders || !isAdmin) { 2337 return; 2338 } 2339 2340 const username = normalizeUsername(editingUserForm.username); 2341 const email = normalizeEmail(editingUserForm.email); 2342 if (!username && !email) { 2343 showNotice('error', 'Provide at least a username or email.'); 2344 return; 2345 } 2346 2347 setIsBusy(true); 2348 try { 2349 await axios.put( 2350 `/api/admin/users/${userId}`, 2351 { 2352 username: username || undefined, 2353 email: email || undefined, 2354 isAdmin: editingUserForm.isAdmin, 2355 permissions: editingUserForm.permissions, 2356 }, 2357 { headers: authHeaders }, 2358 ); 2359 showNotice('success', 'User updated.'); 2360 resetEditingUser(); 2361 await Promise.all([fetchManagedUsers(), fetchData()]); 2362 } catch (error) { 2363 handleAuthFailure(error, 'Failed to update user.'); 2364 } finally { 2365 setIsBusy(false); 2366 } 2367 }; 2368 2369 const handleResetUserPassword = async (userId: string) => { 2370 if (!authHeaders || !isAdmin) { 2371 return; 2372 } 2373 2374 const newPassword = window.prompt('Enter a new password (min 8 chars):'); 2375 if (!newPassword) { 2376 return; 2377 } 2378 if (newPassword.length < 8) { 2379 showNotice('error', 'Password must be at least 8 characters.'); 2380 return; 2381 } 2382 2383 setIsBusy(true); 2384 try { 2385 await axios.post( 2386 `/api/admin/users/${userId}/reset-password`, 2387 { 2388 newPassword, 2389 }, 2390 { headers: authHeaders }, 2391 ); 2392 showNotice('success', 'Password reset.'); 2393 } catch (error) { 2394 handleAuthFailure(error, 'Failed to reset password.'); 2395 } finally { 2396 setIsBusy(false); 2397 } 2398 }; 2399 2400 const handleDeleteUser = async (user: ManagedUser) => { 2401 if (!authHeaders || !isAdmin) { 2402 return; 2403 } 2404 2405 const confirmed = window.confirm( 2406 `Delete ${user.username || user.email || user.id}? Their mapped accounts will be disabled.`, 2407 ); 2408 if (!confirmed) { 2409 return; 2410 } 2411 2412 setIsBusy(true); 2413 try { 2414 await axios.delete(`/api/admin/users/${user.id}`, { headers: authHeaders }); 2415 showNotice('success', 'User deleted and owned mappings disabled.'); 2416 if (accountsCreatorFilter === user.id) { 2417 setAccountsCreatorFilter('all'); 2418 } 2419 await Promise.all([fetchManagedUsers(), fetchData()]); 2420 } catch (error) { 2421 handleAuthFailure(error, 'Failed to delete user.'); 2422 } finally { 2423 setIsBusy(false); 2424 } 2425 }; 2426 2427 const handleChangeOwnEmail = async (event: React.FormEvent<HTMLFormElement>) => { 2428 event.preventDefault(); 2429 if (!authHeaders) { 2430 return; 2431 } 2432 2433 if (!emailForm.newEmail.trim() || !emailForm.password || (hasCurrentEmail && !emailForm.currentEmail.trim())) { 2434 showNotice('error', hasCurrentEmail ? 'Fill in current email, new email, and password.' : 'Fill in new email and password.'); 2435 return; 2436 } 2437 2438 setIsBusy(true); 2439 try { 2440 const response = await axios.post<{ token?: string; me?: AuthUser }>( 2441 '/api/me/change-email', 2442 { 2443 currentEmail: emailForm.currentEmail, 2444 newEmail: emailForm.newEmail, 2445 password: emailForm.password, 2446 }, 2447 { headers: authHeaders }, 2448 ); 2449 2450 if (response.data?.token) { 2451 localStorage.setItem('token', response.data.token); 2452 setToken(response.data.token); 2453 } 2454 if (response.data?.me) { 2455 setMe({ 2456 ...response.data.me, 2457 permissions: normalizePermissions(response.data.me.permissions), 2458 }); 2459 } 2460 setEmailForm((previous) => ({ 2461 currentEmail: previous.newEmail, 2462 newEmail: '', 2463 password: '', 2464 })); 2465 showNotice('success', 'Email updated.'); 2466 await fetchData(); 2467 } catch (error) { 2468 handleAuthFailure(error, 'Failed to update email.'); 2469 } finally { 2470 setIsBusy(false); 2471 } 2472 }; 2473 2474 const handleChangeOwnPassword = async (event: React.FormEvent<HTMLFormElement>) => { 2475 event.preventDefault(); 2476 if (!authHeaders) { 2477 return; 2478 } 2479 2480 if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) { 2481 showNotice('error', 'Complete all password fields.'); 2482 return; 2483 } 2484 if (passwordForm.newPassword.length < 8) { 2485 showNotice('error', 'New password must be at least 8 characters.'); 2486 return; 2487 } 2488 if (passwordForm.newPassword !== passwordForm.confirmPassword) { 2489 showNotice('error', 'New password and confirmation do not match.'); 2490 return; 2491 } 2492 2493 setIsBusy(true); 2494 try { 2495 await axios.post( 2496 '/api/me/change-password', 2497 { 2498 currentPassword: passwordForm.currentPassword, 2499 newPassword: passwordForm.newPassword, 2500 }, 2501 { headers: authHeaders }, 2502 ); 2503 setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); 2504 showNotice('success', 'Password updated.'); 2505 } catch (error) { 2506 handleAuthFailure(error, 'Failed to update password.'); 2507 } finally { 2508 setIsBusy(false); 2509 } 2510 }; 2511 2512 if (!token) { 2513 return ( 2514 <main className="flex min-h-screen items-center justify-center p-4"> 2515 <Card className="w-full max-w-md animate-slide-up border-border/80 bg-card/95"> 2516 <CardHeader className="space-y-1"> 2517 <CardTitle className="text-2xl">Tweets-2-Bsky</CardTitle> 2518 <CardDescription> 2519 {authView === 'login' 2520 ? 'Sign in to manage mappings, status, and account settings.' 2521 : 'Create your first dashboard account.'} 2522 </CardDescription> 2523 </CardHeader> 2524 <CardContent> 2525 {authError ? ( 2526 <div className="mb-4 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-500 dark:text-red-300"> 2527 {authError} 2528 </div> 2529 ) : null} 2530 2531 <form className="space-y-4" onSubmit={authView === 'login' ? handleLogin : handleRegister}> 2532 {authView === 'login' ? ( 2533 <div className="space-y-2"> 2534 <Label htmlFor="identifier">Email or Username</Label> 2535 <Input id="identifier" name="identifier" autoComplete="username" required /> 2536 </div> 2537 ) : ( 2538 <> 2539 <div className="space-y-2"> 2540 <Label htmlFor="username">Username</Label> 2541 <Input id="username" name="username" autoComplete="username" placeholder="optional" /> 2542 </div> 2543 <div className="space-y-2"> 2544 <Label htmlFor="email">Email</Label> 2545 <Input id="email" name="email" type="email" autoComplete="email" placeholder="optional" /> 2546 </div> 2547 </> 2548 )} 2549 2550 <div className="space-y-2"> 2551 <Label htmlFor="password">Password</Label> 2552 <Input id="password" name="password" type="password" autoComplete="current-password" required /> 2553 </div> 2554 2555 <Button className="w-full" type="submit" disabled={isBusy}> 2556 {isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null} 2557 {authView === 'login' ? 'Sign in' : 'Create account'} 2558 </Button> 2559 </form> 2560 2561 {bootstrapOpen || authView === 'register' ? ( 2562 <Button 2563 className="mt-4 w-full" 2564 variant="ghost" 2565 onClick={() => { 2566 setAuthError(''); 2567 setAuthView(authView === 'login' ? 'register' : 'login'); 2568 }} 2569 type="button" 2570 > 2571 {authView === 'login' ? 'Need an account? Register' : 'Have an account? Sign in'} 2572 </Button> 2573 ) : ( 2574 <p className="mt-4 text-center text-xs text-muted-foreground"> 2575 Account creation is disabled. Ask an admin to create your user. 2576 </p> 2577 )} 2578 </CardContent> 2579 </Card> 2580 </main> 2581 ); 2582 } 2583 2584 return ( 2585 <main className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8"> 2586 <div className="mb-6 animate-slide-up"> 2587 <Card className="border-border/80 bg-card/90"> 2588 <CardContent className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5"> 2589 <div className="space-y-1"> 2590 <p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">Dashboard</p> 2591 <h1 className="text-xl font-semibold sm:text-2xl">Tweets-2-Bsky Control Panel</h1> 2592 <p className="flex items-center gap-2 text-sm text-muted-foreground"> 2593 <Clock3 className="h-4 w-4" /> 2594 Next run in <span className="font-mono text-foreground">{countdown}</span> 2595 </p> 2596 <p className="text-xs text-muted-foreground"> 2597 Version <span className="font-mono text-foreground">{runtimeVersionLabel}</span> 2598 {runtimeBranchLabel ? <span className="ml-2">{runtimeBranchLabel}</span> : null} 2599 </p> 2600 </div> 2601 2602 <div className="flex flex-wrap items-center gap-2"> 2603 <Button variant="outline" size="sm" onClick={cycleThemeMode} title={themeLabel}> 2604 {themeIcon} 2605 <span className="ml-2 hidden sm:inline">{themeLabel}</span> 2606 </Button> 2607 {canCreateMappings ? ( 2608 <Button 2609 size="sm" 2610 variant="outline" 2611 onClick={() => { 2612 setActiveTab('settings'); 2613 openAddAccountSheet(); 2614 }} 2615 > 2616 <Plus className="mr-2 h-4 w-4" /> 2617 Add account 2618 </Button> 2619 ) : null} 2620 <Button size="sm" onClick={runNow} disabled={!canRunNowPermission}> 2621 <Play className="mr-2 h-4 w-4" /> 2622 Run now 2623 </Button> 2624 {isAdmin && pendingBackfills.length > 0 ? ( 2625 <Button size="sm" variant="destructive" onClick={clearAllBackfills}> 2626 <Trash2 className="mr-2 h-4 w-4" /> 2627 Clear queue 2628 </Button> 2629 ) : null} 2630 <Button size="sm" variant="ghost" onClick={handleLogout}> 2631 <LogOut className="mr-2 h-4 w-4" /> 2632 Logout 2633 </Button> 2634 </div> 2635 </CardContent> 2636 </Card> 2637 </div> 2638 2639 {notice ? ( 2640 <div 2641 className={cn( 2642 'mb-5 animate-pop-in rounded-md border px-4 py-2 text-sm', 2643 notice.tone === 'success' && 2644 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/30 dark:text-emerald-300', 2645 notice.tone === 'error' && 2646 'border-red-500/40 bg-red-500/10 text-red-700 dark:border-red-500/30 dark:text-red-300', 2647 notice.tone === 'info' && 'border-border bg-muted text-muted-foreground', 2648 )} 2649 > 2650 {notice.message} 2651 </div> 2652 ) : null} 2653 2654 {currentStatus && currentStatus.state !== 'idle' ? ( 2655 <Card className="mb-6 animate-fade-in border-border/80"> 2656 <div className="h-1 overflow-hidden rounded-t-xl bg-muted"> 2657 <div 2658 className={cn( 2659 'h-full transition-all duration-300', 2660 currentStatus.state === 'backfilling' ? 'bg-amber-500' : 'bg-emerald-500', 2661 )} 2662 style={{ width: `${progressPercent || 100}%` }} 2663 /> 2664 </div> 2665 <CardContent className="flex flex-wrap items-center justify-between gap-3 p-4"> 2666 <div className="space-y-1"> 2667 <p className="text-sm font-semibold">{formatState(currentStatus.state)} in progress</p> 2668 <p className="text-sm text-muted-foreground"> 2669 {currentStatus.currentAccount ? `@${currentStatus.currentAccount}` : ''} 2670 {currentStatus.message || 'Working through account queue.'} 2671 </p> 2672 </div> 2673 <div className="text-right"> 2674 <p className="text-lg font-semibold">{progressPercent || 0}%</p> 2675 <p className="text-xs text-muted-foreground"> 2676 {(currentStatus.processedCount || 0).toLocaleString()} /{' '} 2677 {(currentStatus.totalCount || 0).toLocaleString()} 2678 </p> 2679 </div> 2680 </CardContent> 2681 </Card> 2682 ) : null} 2683 2684 <div className="mb-6 animate-fade-in overflow-x-auto pb-1"> 2685 <div className="inline-flex min-w-full gap-2 rounded-xl border border-border/70 bg-card/90 p-2 sm:min-w-0"> 2686 {dashboardTabs.map((tab) => { 2687 const Icon = tab.icon; 2688 const isActive = activeTab === tab.id; 2689 return ( 2690 <button 2691 key={tab.id} 2692 className={cn( 2693 'inline-flex h-11 min-w-[8rem] touch-manipulation items-center justify-center gap-2 rounded-lg px-4 text-sm font-medium transition-[transform,background-color,color,box-shadow] duration-200 ease-out motion-reduce:transition-none motion-safe:hover:-translate-y-0.5', 2694 isActive 2695 ? 'bg-foreground text-background shadow-sm' 2696 : 'bg-background text-muted-foreground hover:bg-muted hover:text-foreground hover:shadow-sm', 2697 )} 2698 onClick={() => setActiveTab(tab.id)} 2699 type="button" 2700 > 2701 <Icon className="h-4 w-4" /> 2702 {tab.label} 2703 </button> 2704 ); 2705 })} 2706 </div> 2707 </div> 2708 2709 {activeTab === 'overview' ? ( 2710 <section className="space-y-6 animate-fade-in"> 2711 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5"> 2712 <Card className="animate-slide-up"> 2713 <CardContent className="p-4"> 2714 <p className="text-xs uppercase tracking-wide text-muted-foreground">Mapped Accounts</p> 2715 <p className="mt-2 text-2xl font-semibold">{mappings.length}</p> 2716 </CardContent> 2717 </Card> 2718 <Card className="animate-slide-up"> 2719 <CardContent className="p-4"> 2720 <p className="text-xs uppercase tracking-wide text-muted-foreground">Backfill Queue</p> 2721 <p className="mt-2 text-2xl font-semibold">{pendingBackfills.length}</p> 2722 </CardContent> 2723 </Card> 2724 <Card className="animate-slide-up"> 2725 <CardContent className="p-4"> 2726 <p className="text-xs uppercase tracking-wide text-muted-foreground">Current State</p> 2727 <p className="mt-2 text-2xl font-semibold">{formatState(currentStatus?.state || 'idle')}</p> 2728 </CardContent> 2729 </Card> 2730 <Card className="animate-slide-up"> 2731 <CardContent className="p-4"> 2732 <p className="text-xs uppercase tracking-wide text-muted-foreground">Latest Activity</p> 2733 <p className="mt-2 text-sm font-medium text-foreground"> 2734 {latestActivity?.created_at 2735 ? new Date(latestActivity.created_at).toLocaleString() 2736 : 'No activity yet'} 2737 </p> 2738 </CardContent> 2739 </Card> 2740 <Card className="animate-slide-up"> 2741 <CardContent className="p-4"> 2742 <p className="text-xs uppercase tracking-wide text-muted-foreground">Top Account (Engagement)</p> 2743 {topAccount ? ( 2744 <div className="mt-2 flex items-center gap-3"> 2745 {topAccountProfile?.avatar ? ( 2746 <img 2747 className="h-9 w-9 rounded-full border border-border/70 object-cover" 2748 src={topAccountProfile.avatar} 2749 alt={topAccountProfile.handle || topAccount.identifier} 2750 loading="lazy" 2751 /> 2752 ) : ( 2753 <div className="flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-muted text-muted-foreground"> 2754 <UserRound className="h-4 w-4" /> 2755 </div> 2756 )} 2757 <div className="min-w-0"> 2758 <p className="truncate text-sm font-semibold"> 2759 @{topAccountProfile?.handle || topAccount.identifier} 2760 </p> 2761 <p className="truncate text-xs text-muted-foreground"> 2762 {formatCompactNumber(topAccount.score)} interactions {topAccount.posts} posts 2763 </p> 2764 </div> 2765 </div> 2766 ) : ( 2767 <p className="mt-2 text-sm text-muted-foreground">No engagement data yet.</p> 2768 )} 2769 </CardContent> 2770 </Card> 2771 </div> 2772 2773 <Card className="animate-slide-up"> 2774 <CardHeader> 2775 <CardTitle>Quick Navigation</CardTitle> 2776 <CardDescription>Use tabs to focus one workflow at a time, especially on mobile.</CardDescription> 2777 </CardHeader> 2778 <CardContent className="flex flex-wrap gap-2 pt-0"> 2779 {dashboardTabs 2780 .filter((tab) => tab.id !== 'overview') 2781 .map((tab) => { 2782 const Icon = tab.icon; 2783 return ( 2784 <Button key={`overview-${tab.id}`} variant="outline" onClick={() => setActiveTab(tab.id)}> 2785 <Icon className="mr-2 h-4 w-4" /> 2786 Open {tab.label} 2787 </Button> 2788 ); 2789 })} 2790 </CardContent> 2791 </Card> 2792 </section> 2793 ) : null} 2794 2795 {activeTab === 'accounts' ? ( 2796 <section className="space-y-6 animate-fade-in"> 2797 <Card className="animate-slide-up"> 2798 <CardHeader className="pb-3"> 2799 <div className="flex items-center justify-between"> 2800 <div className="space-y-1"> 2801 <CardTitle>Active Accounts</CardTitle> 2802 <CardDescription>Organize mappings into folders and collapse/expand groups.</CardDescription> 2803 </div> 2804 <div className="flex items-center gap-2"> 2805 {canCreateMappings ? ( 2806 <Button size="sm" variant="outline" onClick={openAddAccountSheet}> 2807 <Plus className="mr-2 h-4 w-4" /> 2808 Add account 2809 </Button> 2810 ) : null} 2811 <Badge variant="outline">{accountMappingsForView.length} configured</Badge> 2812 </div> 2813 </div> 2814 </CardHeader> 2815 <CardContent className="space-y-4 pt-0"> 2816 {canManageGroupsPermission ? ( 2817 <form 2818 className="rounded-lg border border-border/70 bg-muted/30 p-3" 2819 onSubmit={(event) => { 2820 void handleCreateGroup(event); 2821 }} 2822 > 2823 <p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> 2824 Create Folder 2825 </p> 2826 <div className="flex flex-wrap items-end gap-2"> 2827 <div className="min-w-[180px] flex-1 space-y-1"> 2828 <Label htmlFor="accounts-group-name">Folder name</Label> 2829 <Input 2830 id="accounts-group-name" 2831 value={newGroupName} 2832 onChange={(event) => setNewGroupName(event.target.value)} 2833 placeholder="Gaming, News, Sports..." 2834 /> 2835 </div> 2836 <div className="w-20 space-y-1"> 2837 <Label htmlFor="accounts-group-emoji">Emoji</Label> 2838 <Input 2839 id="accounts-group-emoji" 2840 value={newGroupEmoji} 2841 onChange={(event) => setNewGroupEmoji(event.target.value)} 2842 placeholder="📁" 2843 maxLength={8} 2844 /> 2845 </div> 2846 <Button type="submit" size="sm" disabled={isBusy || newGroupName.trim().length === 0}> 2847 <Plus className="mr-2 h-4 w-4" /> 2848 Create 2849 </Button> 2850 </div> 2851 </form> 2852 ) : null} 2853 2854 <div className="grid gap-2 md:grid-cols-[1fr_auto]"> 2855 <div className="space-y-1"> 2856 <Label htmlFor="accounts-search">Search accounts</Label> 2857 <Input 2858 id="accounts-search" 2859 value={accountsSearchQuery} 2860 onChange={(event) => setAccountsSearchQuery(event.target.value)} 2861 placeholder="Find by @username, owner, Bluesky handle, or folder" 2862 /> 2863 {normalizedAccountsQuery ? ( 2864 <p className="text-xs text-muted-foreground"> 2865 {accountMatchesCount} result{accountMatchesCount === 1 ? '' : 's'} ranked by relevance 2866 </p> 2867 ) : null} 2868 {isAdmin ? ( 2869 <div className="mt-2 space-y-1"> 2870 <Label htmlFor="accounts-creator-filter">Created by user</Label> 2871 <select 2872 id="accounts-creator-filter" 2873 className={cn(selectClassName, 'h-9 text-xs')} 2874 value={accountsCreatorFilter} 2875 onChange={(event) => setAccountsCreatorFilter(event.target.value)} 2876 > 2877 <option value="all">All users</option> 2878 {managedUsers.map((user) => ( 2879 <option key={`creator-filter-${user.id}`} value={user.id}> 2880 {user.username || user.email || user.id} 2881 </option> 2882 ))} 2883 </select> 2884 </div> 2885 ) : null} 2886 </div> 2887 <div className="flex flex-wrap items-end justify-end gap-2"> 2888 {accountsViewMode === 'grouped' ? ( 2889 <Button 2890 size="sm" 2891 variant="outline" 2892 onClick={toggleCollapseAllGroups} 2893 disabled={groupKeysForCollapse.length === 0} 2894 > 2895 {allGroupsCollapsed ? 'Expand all' : 'Collapse all'} 2896 </Button> 2897 ) : null} 2898 <Button 2899 size="sm" 2900 variant="outline" 2901 onClick={() => setAccountsViewMode((previous) => (previous === 'grouped' ? 'global' : 'grouped'))} 2902 > 2903 {accountsViewMode === 'grouped' ? 'View all' : 'Grouped view'} 2904 </Button> 2905 </div> 2906 </div> 2907 2908 {filteredGroupedMappings.length === 0 ? ( 2909 <div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground"> 2910 {normalizedAccountsQuery ? 'No accounts matched this search.' : 'No mappings yet.'} 2911 {canCreateMappings ? ( 2912 <div className="mt-3"> 2913 <Button size="sm" variant="outline" onClick={openAddAccountSheet}> 2914 <Plus className="mr-2 h-4 w-4" /> 2915 Create your first account 2916 </Button> 2917 </div> 2918 ) : null} 2919 </div> 2920 ) : ( 2921 <div className="space-y-3"> 2922 {filteredGroupedMappings.map((group, groupIndex) => { 2923 const canCollapseGroup = accountsViewMode === 'grouped'; 2924 const collapsed = canCollapseGroup ? collapsedGroupKeys[group.key] === true : false; 2925 2926 return ( 2927 <div 2928 key={group.key} 2929 className="overflow-hidden rounded-lg border border-border/70 bg-card/70 animate-slide-up [animation-fill-mode:both]" 2930 style={{ animationDelay: `${Math.min(groupIndex * 45, 220)}ms` }} 2931 > 2932 <button 2933 className={cn( 2934 'group flex w-full items-center justify-between bg-muted/40 px-3 py-2 text-left transition-[background-color,padding] duration-200', 2935 canCollapseGroup ? 'hover:bg-muted/70' : '', 2936 )} 2937 onClick={() => { 2938 if (canCollapseGroup) { 2939 toggleGroupCollapsed(group.key); 2940 } 2941 }} 2942 type="button" 2943 > 2944 <div className="flex items-center gap-2"> 2945 <Folder className="h-4 w-4 text-muted-foreground" /> 2946 <span className="text-base">{group.emoji}</span> 2947 <span className="font-medium">{group.name}</span> 2948 <Badge variant="outline">{group.mappings.length}</Badge> 2949 </div> 2950 {canCollapseGroup ? ( 2951 <ChevronDown 2952 className={cn( 2953 'h-4 w-4 transition-transform duration-200 motion-reduce:transition-none', 2954 collapsed ? '-rotate-90' : 'rotate-0', 2955 )} 2956 /> 2957 ) : null} 2958 </button> 2959 2960 <div 2961 className={cn( 2962 'grid transition-[grid-template-rows,opacity] duration-300 ease-out motion-reduce:transition-none', 2963 collapsed ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr] opacity-100', 2964 )} 2965 > 2966 <div className="min-h-0 overflow-hidden"> 2967 {group.mappings.length === 0 ? ( 2968 <div className="border-t border-border/60 p-4 text-sm text-muted-foreground"> 2969 No accounts in this folder yet. 2970 </div> 2971 ) : ( 2972 <div className="overflow-x-auto"> 2973 <table className="min-w-full text-left text-sm"> 2974 <thead className="border-b border-border text-xs uppercase tracking-wide text-muted-foreground"> 2975 <tr> 2976 <th className="px-2 py-3">Owner</th> 2977 {isAdmin ? <th className="px-2 py-3">Created By</th> : null} 2978 <th className="px-2 py-3">Twitter Sources</th> 2979 <th className="px-2 py-3">Bluesky Target</th> 2980 <th className="px-2 py-3">Status</th> 2981 <th className="px-2 py-3 text-right">Actions</th> 2982 </tr> 2983 </thead> 2984 <tbody> 2985 {group.mappings.map((mapping) => { 2986 const queued = isBackfillQueued(mapping.id); 2987 const active = isBackfillActive(mapping.id); 2988 const queuePosition = getBackfillEntry(mapping.id)?.position; 2989 const profile = getProfileForActor(mapping.bskyIdentifier); 2990 const profileHandle = profile?.handle || mapping.bskyIdentifier; 2991 const profileName = profile?.displayName || profileHandle; 2992 const mappingGroup = getMappingGroupMeta(mapping); 2993 2994 return ( 2995 <tr 2996 key={mapping.id} 2997 className="interactive-row border-b border-border/60 last:border-0" 2998 > 2999 <td className="px-2 py-3 align-top"> 3000 <div className="flex items-center gap-2 font-medium"> 3001 <UserRound className="h-4 w-4 text-muted-foreground" /> 3002 {mapping.owner || 'System'} 3003 </div> 3004 </td> 3005 {isAdmin ? ( 3006 <td className="px-2 py-3 align-top text-xs text-muted-foreground"> 3007 {mapping.createdByLabel || 3008 mapping.createdByUser?.username || 3009 mapping.createdByUser?.email || 3010 '--'} 3011 </td> 3012 ) : null} 3013 <td className="px-2 py-3 align-top"> 3014 <div className="flex flex-wrap gap-2"> 3015 {mapping.twitterUsernames.map((username) => ( 3016 <Badge key={username} variant="secondary"> 3017 @{username} 3018 </Badge> 3019 ))} 3020 </div> 3021 </td> 3022 <td className="px-2 py-3 align-top"> 3023 <div className="flex items-center gap-2"> 3024 {profile?.avatar ? ( 3025 <img 3026 className="h-8 w-8 rounded-full border border-border/70 object-cover" 3027 src={profile.avatar} 3028 alt={profileName} 3029 loading="lazy" 3030 /> 3031 ) : ( 3032 <div className="flex h-8 w-8 items-center justify-center rounded-full border border-border/70 bg-muted text-muted-foreground"> 3033 <UserRound className="h-4 w-4" /> 3034 </div> 3035 )} 3036 <div className="min-w-0"> 3037 <p className="truncate text-sm font-medium">{profileName}</p> 3038 <p className="truncate font-mono text-xs text-muted-foreground"> 3039 {profileHandle} 3040 </p> 3041 </div> 3042 </div> 3043 </td> 3044 <td className="px-2 py-3 align-top"> 3045 {active ? ( 3046 <Badge variant="warning">Backfilling</Badge> 3047 ) : queued ? ( 3048 <Badge variant="warning"> 3049 Queued {queuePosition ? `#${queuePosition}` : ''} 3050 </Badge> 3051 ) : ( 3052 <Badge variant="success">Active</Badge> 3053 )} 3054 </td> 3055 <td className="px-2 py-3 align-top"> 3056 <div className="flex flex-wrap justify-end gap-1"> 3057 <select 3058 className={cn(selectClassName, 'h-9 w-44 px-2 py-1 text-xs')} 3059 value={mappingGroup.key} 3060 disabled={!canManageMapping(mapping) || !canManageGroupsPermission} 3061 onChange={(event) => { 3062 void handleAssignMappingGroup(mapping, event.target.value); 3063 }} 3064 > 3065 <option value={DEFAULT_GROUP_KEY}> 3066 {DEFAULT_GROUP_EMOJI} {DEFAULT_GROUP_NAME} 3067 </option> 3068 {groupOptions 3069 .filter((option) => option.key !== DEFAULT_GROUP_KEY) 3070 .map((option) => ( 3071 <option 3072 key={`group-move-${mapping.id}-${option.key}`} 3073 value={option.key} 3074 > 3075 {option.emoji} {option.name} 3076 </option> 3077 ))} 3078 </select> 3079 {canManageMapping(mapping) ? ( 3080 <> 3081 <Button 3082 variant="outline" 3083 size="sm" 3084 onClick={() => startEditMapping(mapping)} 3085 > 3086 Edit 3087 </Button> 3088 {canQueueBackfillsPermission ? ( 3089 <> 3090 <Button 3091 variant="outline" 3092 size="sm" 3093 onClick={() => { 3094 void requestBackfill(mapping.id, 'normal'); 3095 }} 3096 > 3097 Backfill 3098 </Button> 3099 {isAdmin ? ( 3100 <Button 3101 variant="subtle" 3102 size="sm" 3103 onClick={() => { 3104 void requestBackfill(mapping.id, 'reset'); 3105 }} 3106 > 3107 Reset + Backfill 3108 </Button> 3109 ) : null} 3110 </> 3111 ) : null} 3112 {isAdmin ? ( 3113 <Button 3114 variant="destructive" 3115 size="sm" 3116 onClick={() => { 3117 void handleDeleteAllPosts(mapping.id); 3118 }} 3119 > 3120 Delete Posts 3121 </Button> 3122 ) : null} 3123 </> 3124 ) : null} 3125 {canManageMapping(mapping) ? ( 3126 <Button 3127 variant="ghost" 3128 size="sm" 3129 onClick={() => { 3130 void handleDeleteMapping(mapping.id); 3131 }} 3132 > 3133 <Trash2 className="mr-1 h-4 w-4" /> 3134 Remove 3135 </Button> 3136 ) : null} 3137 </div> 3138 </td> 3139 </tr> 3140 ); 3141 })} 3142 </tbody> 3143 </table> 3144 </div> 3145 )} 3146 </div> 3147 </div> 3148 </div> 3149 ); 3150 })} 3151 </div> 3152 )} 3153 </CardContent> 3154 </Card> 3155 3156 {canManageGroupsPermission ? ( 3157 <Card className="animate-slide-up"> 3158 <CardHeader className="pb-3"> 3159 <CardTitle>Group Manager</CardTitle> 3160 <CardDescription>Edit folder names/emojis or delete a group.</CardDescription> 3161 </CardHeader> 3162 <CardContent className="pt-0"> 3163 {reusableGroupOptions.length === 0 ? ( 3164 <div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground"> 3165 No custom folders yet. 3166 </div> 3167 ) : ( 3168 <div className="space-y-2"> 3169 {reusableGroupOptions.map((group) => { 3170 const draft = groupDraftsByKey[group.key] || { name: group.name, emoji: group.emoji }; 3171 return ( 3172 <div 3173 key={`group-manager-${group.key}`} 3174 className="grid gap-2 rounded-lg border border-border/70 bg-muted/20 p-3 md:grid-cols-[90px_minmax(0,1fr)_auto_auto]" 3175 > 3176 <div className="space-y-1"> 3177 <Label htmlFor={`group-manager-emoji-${group.key}`}>Emoji</Label> 3178 <Input 3179 id={`group-manager-emoji-${group.key}`} 3180 value={draft.emoji} 3181 onChange={(event) => updateGroupDraft(group.key, 'emoji', event.target.value)} 3182 maxLength={8} 3183 /> 3184 </div> 3185 <div className="space-y-1"> 3186 <Label htmlFor={`group-manager-name-${group.key}`}>Name</Label> 3187 <Input 3188 id={`group-manager-name-${group.key}`} 3189 value={draft.name} 3190 onChange={(event) => updateGroupDraft(group.key, 'name', event.target.value)} 3191 /> 3192 </div> 3193 <Button 3194 variant="outline" 3195 size="sm" 3196 className="self-end" 3197 disabled={isGroupActionBusy || !draft.name.trim()} 3198 onClick={() => { 3199 void handleRenameGroup(group.key); 3200 }} 3201 > 3202 Save 3203 </Button> 3204 <Button 3205 variant="ghost" 3206 size="sm" 3207 className="self-end text-red-600 hover:text-red-500 dark:text-red-300 dark:hover:text-red-200" 3208 disabled={isGroupActionBusy} 3209 onClick={() => { 3210 void handleDeleteGroup(group.key); 3211 }} 3212 > 3213 Delete 3214 </Button> 3215 </div> 3216 ); 3217 })} 3218 </div> 3219 )} 3220 <p className="mt-3 text-xs text-muted-foreground"> 3221 Deleting a folder keeps mappings intact and moves them to {DEFAULT_GROUP_NAME}. 3222 </p> 3223 </CardContent> 3224 </Card> 3225 ) : null} 3226 </section> 3227 ) : null} 3228 3229 {activeTab === 'posts' ? ( 3230 <section className="space-y-6 animate-fade-in"> 3231 <Card className="animate-slide-up"> 3232 <CardHeader className="pb-3"> 3233 <div className="flex flex-wrap items-center justify-between gap-3"> 3234 <div className="space-y-1"> 3235 <CardTitle>Already Posted</CardTitle> 3236 <CardDescription> 3237 Native-styled feed plus local SQLite search across all crossposted history. 3238 </CardDescription> 3239 </div> 3240 <div className="grid w-full gap-2 md:max-w-2xl md:grid-cols-[1fr_240px]"> 3241 <div className="space-y-1"> 3242 <Label htmlFor="posts-search">Search crossposted posts</Label> 3243 <div className="relative"> 3244 <Input 3245 id="posts-search" 3246 value={postsSearchQuery} 3247 onChange={(event) => setPostsSearchQuery(event.target.value)} 3248 placeholder="Search by text, @username, tweet id, or Bluesky handle" 3249 /> 3250 {isSearchingLocalPosts ? ( 3251 <Loader2 className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" /> 3252 ) : null} 3253 </div> 3254 </div> 3255 <div className="space-y-1"> 3256 <Label htmlFor="posts-group-filter">Filter group</Label> 3257 <select 3258 id="posts-group-filter" 3259 className={selectClassName} 3260 value={postsGroupFilter} 3261 onChange={(event) => setPostsGroupFilter(event.target.value)} 3262 > 3263 <option value="all">All folders</option> 3264 {groupOptions.map((group) => ( 3265 <option key={`posts-filter-${group.key}`} value={group.key}> 3266 {group.emoji} {group.name} 3267 </option> 3268 ))} 3269 </select> 3270 </div> 3271 </div> 3272 </div> 3273 </CardHeader> 3274 <CardContent className="pt-0"> 3275 {postsSearchQuery.trim() ? ( 3276 filteredLocalPostSearchResults.length === 0 ? ( 3277 <div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground"> 3278 {isSearchingLocalPosts ? 'Searching local history...' : 'No local crossposted posts matched.'} 3279 </div> 3280 ) : ( 3281 <div className="space-y-2"> 3282 {filteredLocalPostSearchResults.map((post) => { 3283 const mapping = resolveMappingForLocalPost(post); 3284 const groupMeta = getMappingGroupMeta(mapping); 3285 const sourceTweetUrl = post.twitterUrl || getTwitterPostUrl(post.twitterUsername, post.twitterId); 3286 const postUrl = 3287 post.postUrl || 3288 (post.bskyUri 3289 ? `https://bsky.app/profile/${post.bskyIdentifier}/post/${ 3290 post.bskyUri.split('/').filter(Boolean).pop() || '' 3291 }` 3292 : undefined); 3293 3294 return ( 3295 <article 3296 key={`${post.twitterId}-${post.bskyIdentifier}-${post.bskyCid || post.createdAt || 'result'}`} 3297 className="rounded-xl border border-border/70 bg-background/80 p-4 shadow-sm" 3298 > 3299 <div className="mb-2 flex flex-wrap items-center justify-between gap-2"> 3300 <div className="min-w-0"> 3301 <p className="truncate text-sm font-semibold"> 3302 @{post.bskyIdentifier}{' '} 3303 <span className="text-muted-foreground">from @{post.twitterUsername}</span> 3304 </p> 3305 <p className="text-xs text-muted-foreground"> 3306 {post.createdAt ? new Date(post.createdAt).toLocaleString() : 'Unknown time'} 3307 </p> 3308 </div> 3309 <div className="flex items-center gap-2"> 3310 <Badge variant="outline"> 3311 {groupMeta.emoji} {groupMeta.name} 3312 </Badge> 3313 <Badge variant="secondary">Relevance {Math.round(post.score)}</Badge> 3314 </div> 3315 </div> 3316 <p className="mb-2 whitespace-pre-wrap break-words text-sm leading-relaxed"> 3317 {post.tweetText || 'No local tweet text stored for this record.'} 3318 </p> 3319 <div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground"> 3320 <span className="font-mono">Tweet ID: {post.twitterId}</span> 3321 {sourceTweetUrl ? ( 3322 <a 3323 className="inline-flex items-center text-foreground underline-offset-4 hover:underline" 3324 href={sourceTweetUrl} 3325 target="_blank" 3326 rel="noreferrer" 3327 > 3328 Source 3329 <ArrowUpRight className="ml-1 h-3 w-3" /> 3330 </a> 3331 ) : null} 3332 {postUrl ? ( 3333 <a 3334 className="inline-flex items-center text-foreground underline-offset-4 hover:underline" 3335 href={postUrl} 3336 target="_blank" 3337 rel="noreferrer" 3338 > 3339 Bluesky 3340 <ArrowUpRight className="ml-1 h-3 w-3" /> 3341 </a> 3342 ) : null} 3343 </div> 3344 </article> 3345 ); 3346 })} 3347 </div> 3348 ) 3349 ) : filteredPostedActivity.length === 0 ? ( 3350 <div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground"> 3351 No posted entries yet. 3352 </div> 3353 ) : ( 3354 <div className="grid gap-3 md:grid-cols-2"> 3355 {filteredPostedActivity.map((post, index) => { 3356 const postUrl = 3357 post.postUrl || 3358 (post.bskyUri 3359 ? `https://bsky.app/profile/${post.bskyIdentifier}/post/${ 3360 post.bskyUri.split('/').filter(Boolean).pop() || '' 3361 }` 3362 : undefined); 3363 const sourceTweetUrl = post.twitterUrl || getTwitterPostUrl(post.twitterUsername, post.twitterId); 3364 const segments = buildFacetSegments(post.text, post.facets || []); 3365 const mapping = resolveMappingForPost(post); 3366 const groupMeta = getMappingGroupMeta(mapping); 3367 const statItems: Array<{ 3368 key: 'likes' | 'reposts' | 'replies' | 'quotes'; 3369 value: number; 3370 icon: typeof Heart; 3371 }> = [ 3372 { key: 'likes', value: post.stats.likes, icon: Heart }, 3373 { key: 'reposts', value: post.stats.reposts, icon: Repeat2 }, 3374 { key: 'replies', value: post.stats.replies, icon: MessageCircle }, 3375 { key: 'quotes', value: post.stats.quotes, icon: Quote }, 3376 ].filter((item) => item.value > 0); 3377 const authorAvatar = post.author.avatar || getProfileForActor(post.author.handle)?.avatar; 3378 const authorHandle = post.author.handle || post.bskyIdentifier; 3379 const authorName = post.author.displayName || authorHandle; 3380 3381 return ( 3382 <article 3383 key={post.bskyUri || `${post.bskyCid || 'post'}-${post.createdAt || index}`} 3384 className="rounded-xl border border-border/70 bg-background/80 p-4 shadow-sm transition-[transform,box-shadow,border-color,background-color] duration-200 ease-out motion-reduce:transition-none motion-safe:hover:-translate-y-0.5 motion-safe:hover:shadow-md animate-slide-up [animation-fill-mode:both]" 3385 style={{ animationDelay: `${Math.min(index * 45, 260)}ms` }} 3386 > 3387 <div className="mb-3 flex items-start justify-between gap-3"> 3388 <div className="flex items-center gap-2"> 3389 {authorAvatar ? ( 3390 <img 3391 className="h-9 w-9 rounded-full border border-border/70 object-cover" 3392 src={authorAvatar} 3393 alt={authorName} 3394 loading="lazy" 3395 /> 3396 ) : ( 3397 <div className="flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-muted text-muted-foreground"> 3398 <UserRound className="h-4 w-4" /> 3399 </div> 3400 )} 3401 <div> 3402 <p className="text-sm font-semibold">{authorName}</p> 3403 <p className="text-xs text-muted-foreground"> 3404 @{authorHandle} from @{post.twitterUsername} 3405 </p> 3406 </div> 3407 </div> 3408 <div className="flex items-center gap-2"> 3409 <Badge variant="outline"> 3410 {groupMeta.emoji} {groupMeta.name} 3411 </Badge> 3412 <Badge variant="success">Posted</Badge> 3413 </div> 3414 </div> 3415 3416 <p className="mb-3 whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground"> 3417 {segments.map((segment, segmentIndex) => { 3418 if (segment.type === 'text') { 3419 return <span key={`${post.bskyUri}-segment-${segmentIndex}`}>{segment.text}</span>; 3420 } 3421 3422 const linkTone = 3423 segment.type === 'mention' 3424 ? 'text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200' 3425 : segment.type === 'tag' 3426 ? 'text-indigo-600 hover:text-indigo-500 dark:text-indigo-300 dark:hover:text-indigo-200' 3427 : 'text-sky-600 hover:text-sky-500 dark:text-sky-300 dark:hover:text-sky-200'; 3428 3429 return ( 3430 <a 3431 key={`${post.bskyUri}-segment-${segmentIndex}`} 3432 className={cn( 3433 'underline decoration-transparent transition hover:decoration-current', 3434 linkTone, 3435 )} 3436 href={segment.href} 3437 target="_blank" 3438 rel="noreferrer" 3439 > 3440 {segment.text} 3441 </a> 3442 ); 3443 })} 3444 </p> 3445 3446 {post.media.length > 0 ? ( 3447 <div className="mb-3 space-y-2"> 3448 {post.media.map((media, mediaIndex) => { 3449 if (media.type === 'image') { 3450 const imageSrc = media.url || media.thumb; 3451 if (!imageSrc) return null; 3452 return ( 3453 <a 3454 key={`${post.bskyUri}-media-${mediaIndex}`} 3455 className="group block overflow-hidden rounded-lg border border-border/70 bg-muted" 3456 href={imageSrc} 3457 target="_blank" 3458 rel="noreferrer" 3459 > 3460 <img 3461 className="h-56 w-full object-cover transition-transform duration-300 ease-out motion-reduce:transition-none motion-safe:group-hover:scale-[1.02]" 3462 src={imageSrc} 3463 alt={media.alt || 'Bluesky media'} 3464 loading="lazy" 3465 /> 3466 </a> 3467 ); 3468 } 3469 3470 if (media.type === 'video') { 3471 const videoHref = media.url || media.thumb; 3472 return ( 3473 <div 3474 key={`${post.bskyUri}-media-${mediaIndex}`} 3475 className="group overflow-hidden rounded-lg border border-border/70 bg-muted" 3476 > 3477 {media.thumb ? ( 3478 <img 3479 className="h-56 w-full object-cover transition-transform duration-300 ease-out motion-reduce:transition-none motion-safe:group-hover:scale-[1.02]" 3480 src={media.thumb} 3481 alt={media.alt || 'Video thumbnail'} 3482 loading="lazy" 3483 /> 3484 ) : ( 3485 <div className="flex h-44 items-center justify-center text-sm text-muted-foreground"> 3486 Video attachment 3487 </div> 3488 )} 3489 {videoHref ? ( 3490 <div className="border-t border-border/70 p-2 text-right"> 3491 <a 3492 className="inline-flex items-center text-xs text-foreground underline-offset-4 hover:underline" 3493 href={videoHref} 3494 target="_blank" 3495 rel="noreferrer" 3496 > 3497 Open video 3498 <ArrowUpRight className="ml-1 h-3 w-3" /> 3499 </a> 3500 </div> 3501 ) : null} 3502 </div> 3503 ); 3504 } 3505 3506 if (media.type === 'external') { 3507 if (!media.url) return null; 3508 return ( 3509 <a 3510 key={`${post.bskyUri}-media-${mediaIndex}`} 3511 className="group block overflow-hidden rounded-lg border border-border/70 bg-background transition-colors hover:bg-muted/60" 3512 href={media.url} 3513 target="_blank" 3514 rel="noreferrer" 3515 > 3516 {media.thumb ? ( 3517 <img 3518 className="h-40 w-full object-cover transition-transform duration-300 ease-out motion-reduce:transition-none motion-safe:group-hover:scale-[1.02]" 3519 src={media.thumb} 3520 alt={media.title || media.url} 3521 loading="lazy" 3522 /> 3523 ) : null} 3524 <div className="space-y-1 p-3"> 3525 <p className="truncate text-sm font-medium">{media.title || media.url}</p> 3526 {media.description ? ( 3527 <p className="max-h-10 overflow-hidden text-xs text-muted-foreground"> 3528 {media.description} 3529 </p> 3530 ) : null} 3531 </div> 3532 </a> 3533 ); 3534 } 3535 3536 return null; 3537 })} 3538 </div> 3539 ) : null} 3540 3541 {statItems.length > 0 ? ( 3542 <div className="mb-3 flex flex-wrap gap-2"> 3543 {statItems.map((stat) => { 3544 const Icon = stat.icon; 3545 return ( 3546 <span 3547 key={`${post.bskyUri}-stat-${stat.key}`} 3548 className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-muted px-2 py-1 text-xs text-muted-foreground" 3549 > 3550 <Icon className="h-3.5 w-3.5" /> 3551 {formatCompactNumber(stat.value)} 3552 </span> 3553 ); 3554 })} 3555 </div> 3556 ) : null} 3557 3558 <div className="flex items-center justify-between gap-3 text-xs text-muted-foreground"> 3559 <span>{post.createdAt ? new Date(post.createdAt).toLocaleString() : 'Unknown time'}</span> 3560 <div className="flex items-center gap-3"> 3561 {sourceTweetUrl ? ( 3562 <a 3563 className="inline-flex items-center text-foreground underline-offset-4 hover:underline" 3564 href={sourceTweetUrl} 3565 target="_blank" 3566 rel="noreferrer" 3567 > 3568 Source 3569 <ArrowUpRight className="ml-1 h-3 w-3" /> 3570 </a> 3571 ) : null} 3572 {postUrl ? ( 3573 <a 3574 className="inline-flex items-center text-foreground underline-offset-4 hover:underline" 3575 href={postUrl} 3576 target="_blank" 3577 rel="noreferrer" 3578 > 3579 Bluesky 3580 <ArrowUpRight className="ml-1 h-3 w-3" /> 3581 </a> 3582 ) : ( 3583 <span>Missing URI</span> 3584 )} 3585 </div> 3586 </div> 3587 </article> 3588 ); 3589 })} 3590 </div> 3591 )} 3592 </CardContent> 3593 </Card> 3594 </section> 3595 ) : null} 3596 3597 {activeTab === 'activity' ? ( 3598 <section className="space-y-6 animate-fade-in"> 3599 <Card className="animate-slide-up"> 3600 <CardHeader className="pb-3"> 3601 <div className="flex flex-wrap items-center justify-between gap-3"> 3602 <div className="space-y-1"> 3603 <CardTitle className="flex items-center gap-2"> 3604 <History className="h-4 w-4" /> 3605 Recent Activity 3606 </CardTitle> 3607 <CardDescription>Latest migration outcomes from the processing database.</CardDescription> 3608 </div> 3609 <div className="w-full max-w-xs"> 3610 <Label htmlFor="activity-group-filter">Filter group</Label> 3611 <select 3612 id="activity-group-filter" 3613 className={selectClassName} 3614 value={activityGroupFilter} 3615 onChange={(event) => setActivityGroupFilter(event.target.value)} 3616 > 3617 <option value="all">All folders</option> 3618 {groupOptions.map((group) => ( 3619 <option key={`activity-filter-${group.key}`} value={group.key}> 3620 {group.emoji} {group.name} 3621 </option> 3622 ))} 3623 </select> 3624 </div> 3625 </div> 3626 </CardHeader> 3627 <CardContent className="pt-0"> 3628 <div className="overflow-x-auto"> 3629 <table className="min-w-full text-left text-sm"> 3630 <thead className="border-b border-border text-xs uppercase tracking-wide text-muted-foreground"> 3631 <tr> 3632 <th className="px-2 py-3">Time</th> 3633 <th className="px-2 py-3">Twitter User</th> 3634 <th className="px-2 py-3">Group</th> 3635 <th className="px-2 py-3">Status</th> 3636 <th className="px-2 py-3">Details</th> 3637 <th className="px-2 py-3 text-right">Link</th> 3638 </tr> 3639 </thead> 3640 <tbody> 3641 {filteredRecentActivity.map((activity, index) => { 3642 const href = getBskyPostUrl(activity); 3643 const sourceTweetUrl = getTwitterPostUrl(activity.twitter_username, activity.twitter_id); 3644 const mapping = resolveMappingForActivity(activity); 3645 const groupMeta = getMappingGroupMeta(mapping); 3646 3647 return ( 3648 <tr 3649 key={`${activity.twitter_id}-${activity.created_at || index}`} 3650 className="interactive-row border-b border-border/60 last:border-0" 3651 > 3652 <td className="px-2 py-3 align-top text-xs text-muted-foreground"> 3653 {activity.created_at 3654 ? new Date(activity.created_at).toLocaleTimeString([], { 3655 hour: '2-digit', 3656 minute: '2-digit', 3657 }) 3658 : '--'} 3659 </td> 3660 <td className="px-2 py-3 align-top font-medium">@{activity.twitter_username}</td> 3661 <td className="px-2 py-3 align-top"> 3662 <Badge variant="outline"> 3663 {groupMeta.emoji} {groupMeta.name} 3664 </Badge> 3665 </td> 3666 <td className="px-2 py-3 align-top"> 3667 {activity.status === 'migrated' ? ( 3668 <Badge variant="success">Migrated</Badge> 3669 ) : activity.status === 'skipped' ? ( 3670 <Badge variant="outline">Skipped</Badge> 3671 ) : ( 3672 <Badge variant="danger">Failed</Badge> 3673 )} 3674 </td> 3675 <td className="px-2 py-3 align-top text-xs text-muted-foreground"> 3676 <div className="max-w-[340px] truncate"> 3677 {activity.tweet_text || `Tweet ID: ${activity.twitter_id}`} 3678 </div> 3679 </td> 3680 <td className="px-2 py-3 align-top text-right"> 3681 <div className="flex flex-col items-end gap-1"> 3682 {sourceTweetUrl ? ( 3683 <a 3684 className="inline-flex items-center text-xs text-foreground underline-offset-4 hover:underline" 3685 href={sourceTweetUrl} 3686 target="_blank" 3687 rel="noreferrer" 3688 > 3689 Source 3690 <ArrowUpRight className="ml-1 h-3 w-3" /> 3691 </a> 3692 ) : null} 3693 {href ? ( 3694 <a 3695 className="inline-flex items-center text-xs text-foreground underline-offset-4 hover:underline" 3696 href={href} 3697 target="_blank" 3698 rel="noreferrer" 3699 > 3700 Bluesky 3701 <ArrowUpRight className="ml-1 h-3 w-3" /> 3702 </a> 3703 ) : ( 3704 <span className="text-xs text-muted-foreground">--</span> 3705 )} 3706 </div> 3707 </td> 3708 </tr> 3709 ); 3710 })} 3711 {filteredRecentActivity.length === 0 ? ( 3712 <tr> 3713 <td className="px-2 py-6 text-center text-sm text-muted-foreground" colSpan={6}> 3714 No activity for this filter. 3715 </td> 3716 </tr> 3717 ) : null} 3718 </tbody> 3719 </table> 3720 </div> 3721 </CardContent> 3722 </Card> 3723 </section> 3724 ) : null} 3725 3726 {activeTab === 'settings' ? ( 3727 <section className="space-y-6 animate-fade-in"> 3728 <Card className="animate-slide-up"> 3729 <button 3730 className="flex w-full items-center justify-between px-5 py-4 text-left" 3731 onClick={() => toggleSettingsSection('account')} 3732 type="button" 3733 > 3734 <div> 3735 <p className="text-sm font-semibold">Account Security</p> 3736 <p className="text-xs text-muted-foreground">Update your own email/password with verification.</p> 3737 </div> 3738 <ChevronDown 3739 className={cn( 3740 'h-4 w-4 transition-transform duration-200', 3741 isSettingsSectionExpanded('account') ? 'rotate-0' : '-rotate-90', 3742 )} 3743 /> 3744 </button> 3745 <div 3746 className={cn( 3747 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 3748 isSettingsSectionExpanded('account') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 3749 )} 3750 > 3751 <div className="min-h-0 overflow-hidden"> 3752 <CardContent className="grid gap-4 border-t border-border/70 pt-4 lg:grid-cols-2"> 3753 <form className="space-y-3" onSubmit={handleChangeOwnEmail}> 3754 <p className="text-sm font-semibold">Change Email</p> 3755 <div className="space-y-2"> 3756 <Label htmlFor="account-current-email">Current Email</Label> 3757 <Input 3758 id="account-current-email" 3759 type="email" 3760 value={emailForm.currentEmail} 3761 onChange={(event) => { 3762 setEmailForm((previous) => ({ ...previous, currentEmail: event.target.value })); 3763 }} 3764 placeholder={hasCurrentEmail ? undefined : 'No current email on this account'} 3765 required={hasCurrentEmail} 3766 disabled={!hasCurrentEmail} 3767 /> 3768 </div> 3769 <div className="space-y-2"> 3770 <Label htmlFor="account-new-email">New Email</Label> 3771 <Input 3772 id="account-new-email" 3773 type="email" 3774 value={emailForm.newEmail} 3775 onChange={(event) => { 3776 setEmailForm((previous) => ({ ...previous, newEmail: event.target.value })); 3777 }} 3778 required 3779 /> 3780 </div> 3781 <div className="space-y-2"> 3782 <Label htmlFor="account-email-password">Current Password</Label> 3783 <Input 3784 id="account-email-password" 3785 type="password" 3786 value={emailForm.password} 3787 onChange={(event) => { 3788 setEmailForm((previous) => ({ ...previous, password: event.target.value })); 3789 }} 3790 required 3791 /> 3792 </div> 3793 <Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}> 3794 <Save className="mr-2 h-4 w-4" /> 3795 Save Email 3796 </Button> 3797 </form> 3798 3799 <form className="space-y-3" onSubmit={handleChangeOwnPassword}> 3800 <p className="text-sm font-semibold">Change Password</p> 3801 <div className="space-y-2"> 3802 <Label htmlFor="account-current-password">Current Password</Label> 3803 <Input 3804 id="account-current-password" 3805 type="password" 3806 value={passwordForm.currentPassword} 3807 onChange={(event) => { 3808 setPasswordForm((previous) => ({ ...previous, currentPassword: event.target.value })); 3809 }} 3810 required 3811 /> 3812 </div> 3813 <div className="space-y-2"> 3814 <Label htmlFor="account-new-password">New Password</Label> 3815 <Input 3816 id="account-new-password" 3817 type="password" 3818 value={passwordForm.newPassword} 3819 onChange={(event) => { 3820 setPasswordForm((previous) => ({ ...previous, newPassword: event.target.value })); 3821 }} 3822 required 3823 /> 3824 </div> 3825 <div className="space-y-2"> 3826 <Label htmlFor="account-confirm-password">Confirm New Password</Label> 3827 <Input 3828 id="account-confirm-password" 3829 type="password" 3830 value={passwordForm.confirmPassword} 3831 onChange={(event) => { 3832 setPasswordForm((previous) => ({ ...previous, confirmPassword: event.target.value })); 3833 }} 3834 required 3835 /> 3836 </div> 3837 <Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}> 3838 <Save className="mr-2 h-4 w-4" /> 3839 Save Password 3840 </Button> 3841 </form> 3842 </CardContent> 3843 </div> 3844 </div> 3845 </Card> 3846 3847 {isAdmin ? ( 3848 <> 3849 <Card className="animate-slide-up"> 3850 <CardHeader> 3851 <CardTitle className="flex items-center gap-2"> 3852 <Settings2 className="h-4 w-4" /> 3853 Admin Settings 3854 </CardTitle> 3855 <CardDescription>Configured sections stay collapsed so adding accounts is one click.</CardDescription> 3856 </CardHeader> 3857 <CardContent className="space-y-4 pt-0"> 3858 <div className="rounded-lg border border-border/70 bg-muted/20 p-3"> 3859 <div className="flex flex-wrap items-start justify-between gap-3"> 3860 <div className="space-y-1"> 3861 <p className="text-sm font-semibold">Running Version</p> 3862 <p className="font-mono text-sm text-foreground">{runtimeVersionLabel}</p> 3863 {runtimeBranchLabel ? ( 3864 <p className="text-xs text-muted-foreground">{runtimeBranchLabel}</p> 3865 ) : null} 3866 <p className="text-xs text-muted-foreground">{updateStateLabel}</p> 3867 </div> 3868 <div className="flex flex-wrap gap-2"> 3869 <Button 3870 variant="outline" 3871 onClick={() => { 3872 void handleRunUpdate(); 3873 }} 3874 disabled={isUpdateBusy || updateStatus?.running} 3875 > 3876 {isUpdateBusy || updateStatus?.running ? ( 3877 <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 3878 ) : ( 3879 <RefreshCw className="mr-2 h-4 w-4" /> 3880 )} 3881 {updateStatus?.running ? 'Updating...' : 'Update'} 3882 </Button> 3883 {canCreateMappings ? ( 3884 <Button className="w-full sm:w-auto" onClick={openAddAccountSheet}> 3885 <Plus className="mr-2 h-4 w-4" /> 3886 Add Account 3887 </Button> 3888 ) : null} 3889 </div> 3890 </div> 3891 {updateStatus?.logTail && updateStatus.logTail.length > 0 ? ( 3892 <details className="mt-3"> 3893 <summary className="cursor-pointer text-xs font-medium text-muted-foreground"> 3894 Update log 3895 </summary> 3896 <pre className="mt-2 max-h-44 overflow-auto rounded-md bg-background p-2 font-mono text-[11px] leading-relaxed text-muted-foreground"> 3897 {updateStatus.logTail.join('\n')} 3898 </pre> 3899 </details> 3900 ) : null} 3901 </div> 3902 </CardContent> 3903 </Card> 3904 3905 <Card className="animate-slide-up"> 3906 <button 3907 className="flex w-full items-center justify-between px-5 py-4 text-left" 3908 onClick={() => toggleSettingsSection('users')} 3909 type="button" 3910 > 3911 <div> 3912 <p className="text-sm font-semibold">User Access Manager</p> 3913 <p className="text-xs text-muted-foreground">Create users and control what they can see/manage.</p> 3914 </div> 3915 <ChevronDown 3916 className={cn( 3917 'h-4 w-4 transition-transform duration-200', 3918 isSettingsSectionExpanded('users') ? 'rotate-0' : '-rotate-90', 3919 )} 3920 /> 3921 </button> 3922 <div 3923 className={cn( 3924 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 3925 isSettingsSectionExpanded('users') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 3926 )} 3927 > 3928 <div className="min-h-0 overflow-hidden"> 3929 <CardContent className="space-y-4 border-t border-border/70 pt-4"> 3930 <form 3931 className="space-y-3 rounded-lg border border-border/70 bg-muted/20 p-3" 3932 onSubmit={handleCreateUser} 3933 > 3934 <p className="text-sm font-semibold">Create User</p> 3935 <div className="grid gap-3 md:grid-cols-3"> 3936 <div className="space-y-2"> 3937 <Label htmlFor="new-user-username">Username</Label> 3938 <Input 3939 id="new-user-username" 3940 value={newUserForm.username} 3941 onChange={(event) => { 3942 setNewUserForm((previous) => ({ ...previous, username: event.target.value })); 3943 }} 3944 placeholder="operator" 3945 /> 3946 </div> 3947 <div className="space-y-2"> 3948 <Label htmlFor="new-user-email">Email</Label> 3949 <Input 3950 id="new-user-email" 3951 type="email" 3952 value={newUserForm.email} 3953 onChange={(event) => { 3954 setNewUserForm((previous) => ({ ...previous, email: event.target.value })); 3955 }} 3956 placeholder="operator@example.com" 3957 /> 3958 </div> 3959 <div className="space-y-2"> 3960 <Label htmlFor="new-user-password">Password</Label> 3961 <Input 3962 id="new-user-password" 3963 type="password" 3964 value={newUserForm.password} 3965 onChange={(event) => { 3966 setNewUserForm((previous) => ({ ...previous, password: event.target.value })); 3967 }} 3968 placeholder="Minimum 8 characters" 3969 required 3970 /> 3971 </div> 3972 </div> 3973 3974 <label className="inline-flex items-center gap-2 text-sm font-medium"> 3975 <input 3976 type="checkbox" 3977 checked={newUserForm.isAdmin} 3978 onChange={(event) => { 3979 setNewUserForm((previous) => ({ 3980 ...previous, 3981 isAdmin: event.target.checked, 3982 })); 3983 }} 3984 /> 3985 Make admin 3986 </label> 3987 3988 {!newUserForm.isAdmin ? ( 3989 <div className="grid gap-2 md:grid-cols-2"> 3990 {PERMISSION_OPTIONS.map((permission) => ( 3991 <label 3992 key={`new-user-permission-${permission.key}`} 3993 className="rounded-md border border-border/70 bg-background/80 px-3 py-2 text-xs" 3994 > 3995 <span className="flex items-center justify-between gap-2"> 3996 <span className="font-medium">{permission.label}</span> 3997 <input 3998 type="checkbox" 3999 checked={newUserForm.permissions[permission.key]} 4000 onChange={(event) => { 4001 const checked = event.target.checked; 4002 setNewUserForm((previous) => ({ 4003 ...previous, 4004 permissions: { 4005 ...previous.permissions, 4006 [permission.key]: checked, 4007 }, 4008 })); 4009 }} 4010 /> 4011 </span> 4012 <span className="mt-1 block text-muted-foreground">{permission.help}</span> 4013 </label> 4014 ))} 4015 </div> 4016 ) : ( 4017 <p className="text-xs text-muted-foreground">Admins always get full access.</p> 4018 )} 4019 4020 <Button size="sm" type="submit" disabled={isBusy}> 4021 <Plus className="mr-2 h-4 w-4" /> 4022 Create user 4023 </Button> 4024 </form> 4025 4026 {managedUsers.length === 0 ? ( 4027 <div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground"> 4028 No user accounts created yet. 4029 </div> 4030 ) : ( 4031 <div className="space-y-2"> 4032 {managedUsers.map((user) => { 4033 const isEditing = editingUserId === user.id; 4034 const displayName = user.username || user.email || user.id; 4035 return ( 4036 <div 4037 key={`managed-user-${user.id}`} 4038 className="rounded-lg border border-border/70 bg-card/60 p-3" 4039 > 4040 {isEditing ? ( 4041 <div className="space-y-3"> 4042 <div className="grid gap-3 md:grid-cols-2"> 4043 <div className="space-y-1"> 4044 <Label htmlFor={`edit-user-username-${user.id}`}>Username</Label> 4045 <Input 4046 id={`edit-user-username-${user.id}`} 4047 value={editingUserForm.username} 4048 onChange={(event) => { 4049 setEditingUserForm((previous) => ({ 4050 ...previous, 4051 username: event.target.value, 4052 })); 4053 }} 4054 /> 4055 </div> 4056 <div className="space-y-1"> 4057 <Label htmlFor={`edit-user-email-${user.id}`}>Email</Label> 4058 <Input 4059 id={`edit-user-email-${user.id}`} 4060 type="email" 4061 value={editingUserForm.email} 4062 onChange={(event) => { 4063 setEditingUserForm((previous) => ({ 4064 ...previous, 4065 email: event.target.value, 4066 })); 4067 }} 4068 /> 4069 </div> 4070 </div> 4071 4072 <label className="inline-flex items-center gap-2 text-sm font-medium"> 4073 <input 4074 type="checkbox" 4075 checked={editingUserForm.isAdmin} 4076 onChange={(event) => { 4077 setEditingUserForm((previous) => ({ 4078 ...previous, 4079 isAdmin: event.target.checked, 4080 })); 4081 }} 4082 /> 4083 Admin access 4084 </label> 4085 4086 {!editingUserForm.isAdmin ? ( 4087 <div className="grid gap-2 md:grid-cols-2"> 4088 {PERMISSION_OPTIONS.map((permission) => ( 4089 <label 4090 key={`edit-user-permission-${user.id}-${permission.key}`} 4091 className="rounded-md border border-border/70 bg-background/80 px-3 py-2 text-xs" 4092 > 4093 <span className="flex items-center justify-between gap-2"> 4094 <span className="font-medium">{permission.label}</span> 4095 <input 4096 type="checkbox" 4097 checked={editingUserForm.permissions[permission.key]} 4098 onChange={(event) => { 4099 const checked = event.target.checked; 4100 setEditingUserForm((previous) => ({ 4101 ...previous, 4102 permissions: { 4103 ...previous.permissions, 4104 [permission.key]: checked, 4105 }, 4106 })); 4107 }} 4108 /> 4109 </span> 4110 <span className="mt-1 block text-muted-foreground">{permission.help}</span> 4111 </label> 4112 ))} 4113 </div> 4114 ) : null} 4115 4116 <div className="flex flex-wrap justify-end gap-2"> 4117 <Button size="sm" variant="ghost" onClick={resetEditingUser} type="button"> 4118 Cancel 4119 </Button> 4120 <Button 4121 size="sm" 4122 onClick={() => { 4123 void handleSaveEditedUser(user.id); 4124 }} 4125 type="button" 4126 disabled={isBusy} 4127 > 4128 Save user 4129 </Button> 4130 </div> 4131 </div> 4132 ) : ( 4133 <div className="space-y-3"> 4134 <div className="flex flex-wrap items-start justify-between gap-3"> 4135 <div className="space-y-1"> 4136 <p className="text-sm font-semibold">{displayName}</p> 4137 <p className="text-xs text-muted-foreground"> 4138 {user.email ? `Email: ${user.email}` : 'No email set'} 4139 </p> 4140 <p className="text-xs text-muted-foreground"> 4141 {user.mappingCount} mappings ({user.activeMappingCount} active) 4142 </p> 4143 </div> 4144 <div className="flex flex-wrap gap-2"> 4145 <Badge variant={user.isAdmin ? 'success' : 'outline'}> 4146 {user.isAdmin ? 'Admin' : 'User'} 4147 </Badge> 4148 {user.id === me?.id ? <Badge variant="secondary">You</Badge> : null} 4149 </div> 4150 </div> 4151 4152 {!user.isAdmin ? ( 4153 <div className="flex flex-wrap gap-2"> 4154 {PERMISSION_OPTIONS.filter( 4155 (permission) => user.permissions[permission.key], 4156 ).map((permission) => ( 4157 <Badge key={`user-perm-${user.id}-${permission.key}`} variant="outline"> 4158 {permission.label} 4159 </Badge> 4160 ))} 4161 </div> 4162 ) : null} 4163 4164 <div className="flex flex-wrap gap-2"> 4165 <Button 4166 size="sm" 4167 variant="outline" 4168 onClick={() => { 4169 setAccountsCreatorFilter(user.id); 4170 setActiveTab('accounts'); 4171 }} 4172 > 4173 View Accounts 4174 </Button> 4175 <Button size="sm" variant="outline" onClick={() => beginEditUser(user)}> 4176 Edit 4177 </Button> 4178 <Button 4179 size="sm" 4180 variant="outline" 4181 onClick={() => { 4182 void handleResetUserPassword(user.id); 4183 }} 4184 > 4185 Reset Password 4186 </Button> 4187 {user.id !== me?.id ? ( 4188 <Button 4189 size="sm" 4190 variant="ghost" 4191 className="text-red-600 hover:text-red-500 dark:text-red-300 dark:hover:text-red-200" 4192 onClick={() => { 4193 void handleDeleteUser(user); 4194 }} 4195 > 4196 Delete 4197 </Button> 4198 ) : null} 4199 </div> 4200 </div> 4201 )} 4202 </div> 4203 ); 4204 })} 4205 </div> 4206 )} 4207 </CardContent> 4208 </div> 4209 </div> 4210 </Card> 4211 4212 <Card className="animate-slide-up"> 4213 <button 4214 className="flex w-full items-center justify-between px-5 py-4 text-left" 4215 onClick={() => toggleSettingsSection('twitter')} 4216 type="button" 4217 > 4218 <div> 4219 <p className="text-sm font-semibold">Twitter Credentials</p> 4220 <p className="text-xs text-muted-foreground">Primary and backup cookie values.</p> 4221 </div> 4222 <div className="flex items-center gap-2"> 4223 <Badge variant={twitterConfigured ? 'success' : 'outline'}> 4224 {twitterConfigured ? 'Configured' : 'Missing'} 4225 </Badge> 4226 <ChevronDown 4227 className={cn( 4228 'h-4 w-4 transition-transform duration-200', 4229 isSettingsSectionExpanded('twitter') ? 'rotate-0' : '-rotate-90', 4230 )} 4231 /> 4232 </div> 4233 </button> 4234 <div 4235 className={cn( 4236 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 4237 isSettingsSectionExpanded('twitter') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 4238 )} 4239 > 4240 <div className="min-h-0 overflow-hidden"> 4241 <CardContent className="space-y-3 border-t border-border/70 pt-4"> 4242 <form className="space-y-3" onSubmit={handleSaveTwitterConfig}> 4243 <div className="space-y-2"> 4244 <Label htmlFor="authToken">Primary Auth Token</Label> 4245 <Input 4246 id="authToken" 4247 value={twitterConfig.authToken} 4248 onChange={(event) => { 4249 setTwitterConfig((prev) => ({ ...prev, authToken: event.target.value })); 4250 }} 4251 required 4252 /> 4253 </div> 4254 <div className="space-y-2"> 4255 <Label htmlFor="ct0">Primary CT0</Label> 4256 <Input 4257 id="ct0" 4258 value={twitterConfig.ct0} 4259 onChange={(event) => { 4260 setTwitterConfig((prev) => ({ ...prev, ct0: event.target.value })); 4261 }} 4262 required 4263 /> 4264 </div> 4265 4266 <div className="grid gap-3 sm:grid-cols-2"> 4267 <div className="space-y-2"> 4268 <Label htmlFor="backupAuthToken">Backup Auth Token</Label> 4269 <Input 4270 id="backupAuthToken" 4271 value={twitterConfig.backupAuthToken || ''} 4272 onChange={(event) => { 4273 setTwitterConfig((prev) => ({ ...prev, backupAuthToken: event.target.value })); 4274 }} 4275 /> 4276 </div> 4277 <div className="space-y-2"> 4278 <Label htmlFor="backupCt0">Backup CT0</Label> 4279 <Input 4280 id="backupCt0" 4281 value={twitterConfig.backupCt0 || ''} 4282 onChange={(event) => { 4283 setTwitterConfig((prev) => ({ ...prev, backupCt0: event.target.value })); 4284 }} 4285 /> 4286 </div> 4287 </div> 4288 4289 <Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}> 4290 <Save className="mr-2 h-4 w-4" /> 4291 Save Twitter Credentials 4292 </Button> 4293 </form> 4294 </CardContent> 4295 </div> 4296 </div> 4297 </Card> 4298 4299 <Card className="animate-slide-up"> 4300 <button 4301 className="flex w-full items-center justify-between px-5 py-4 text-left" 4302 onClick={() => toggleSettingsSection('ai')} 4303 type="button" 4304 > 4305 <div> 4306 <p className="text-sm font-semibold">AI Settings</p> 4307 <p className="text-xs text-muted-foreground">Optional enrichment and rewrite provider config.</p> 4308 </div> 4309 <div className="flex items-center gap-2"> 4310 <Badge variant={aiConfigured ? 'success' : 'outline'}> 4311 {aiConfigured ? 'Configured' : 'Optional'} 4312 </Badge> 4313 <ChevronDown 4314 className={cn( 4315 'h-4 w-4 transition-transform duration-200', 4316 isSettingsSectionExpanded('ai') ? 'rotate-0' : '-rotate-90', 4317 )} 4318 /> 4319 </div> 4320 </button> 4321 <div 4322 className={cn( 4323 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 4324 isSettingsSectionExpanded('ai') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 4325 )} 4326 > 4327 <div className="min-h-0 overflow-hidden"> 4328 <CardContent className="space-y-3 border-t border-border/70 pt-4"> 4329 <form className="space-y-3" onSubmit={handleSaveAiConfig}> 4330 <div className="space-y-2"> 4331 <Label htmlFor="provider">Provider</Label> 4332 <select 4333 className={selectClassName} 4334 id="provider" 4335 value={aiConfig.provider} 4336 onChange={(event) => { 4337 setAiConfig((prev) => ({ 4338 ...prev, 4339 provider: event.target.value as AIConfig['provider'], 4340 })); 4341 }} 4342 > 4343 <option value="gemini">Google Gemini</option> 4344 <option value="openai">OpenAI / OpenRouter</option> 4345 <option value="anthropic">Anthropic</option> 4346 <option value="custom">Custom</option> 4347 </select> 4348 </div> 4349 <div className="space-y-2"> 4350 <Label htmlFor="apiKey">API Key</Label> 4351 <Input 4352 id="apiKey" 4353 type="password" 4354 value={aiConfig.apiKey || ''} 4355 onChange={(event) => { 4356 setAiConfig((prev) => ({ ...prev, apiKey: event.target.value })); 4357 }} 4358 /> 4359 </div> 4360 {aiConfig.provider !== 'gemini' ? ( 4361 <> 4362 <div className="space-y-2"> 4363 <Label htmlFor="model">Model ID</Label> 4364 <Input 4365 id="model" 4366 value={aiConfig.model || ''} 4367 onChange={(event) => { 4368 setAiConfig((prev) => ({ ...prev, model: event.target.value })); 4369 }} 4370 placeholder="gpt-4o" 4371 /> 4372 </div> 4373 <div className="space-y-2"> 4374 <Label htmlFor="baseUrl">Base URL</Label> 4375 <Input 4376 id="baseUrl" 4377 value={aiConfig.baseUrl || ''} 4378 onChange={(event) => { 4379 setAiConfig((prev) => ({ ...prev, baseUrl: event.target.value })); 4380 }} 4381 placeholder="https://api.example.com/v1" 4382 /> 4383 </div> 4384 </> 4385 ) : null} 4386 4387 <Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}> 4388 <Bot className="mr-2 h-4 w-4" /> 4389 Save AI Settings 4390 </Button> 4391 </form> 4392 </CardContent> 4393 </div> 4394 </div> 4395 </Card> 4396 4397 <Card className="animate-slide-up"> 4398 <button 4399 className="flex w-full items-center justify-between px-5 py-4 text-left" 4400 onClick={() => toggleSettingsSection('data')} 4401 type="button" 4402 > 4403 <div> 4404 <p className="text-sm font-semibold">Data Management</p> 4405 <p className="text-xs text-muted-foreground">Export/import mappings and provider settings.</p> 4406 </div> 4407 <ChevronDown 4408 className={cn( 4409 'h-4 w-4 transition-transform duration-200', 4410 isSettingsSectionExpanded('data') ? 'rotate-0' : '-rotate-90', 4411 )} 4412 /> 4413 </button> 4414 <div 4415 className={cn( 4416 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 4417 isSettingsSectionExpanded('data') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 4418 )} 4419 > 4420 <div className="min-h-0 overflow-hidden"> 4421 <CardContent className="space-y-3 border-t border-border/70 pt-4"> 4422 <Button className="w-full sm:w-auto" variant="outline" onClick={handleExportConfig}> 4423 <Download className="mr-2 h-4 w-4" /> 4424 Export configuration 4425 </Button> 4426 <input 4427 ref={importInputRef} 4428 className="hidden" 4429 type="file" 4430 accept="application/json,.json" 4431 onChange={(event) => { 4432 void handleImportConfig(event); 4433 }} 4434 /> 4435 <Button 4436 className="w-full sm:w-auto" 4437 variant="outline" 4438 onClick={() => { 4439 importInputRef.current?.click(); 4440 }} 4441 > 4442 <Upload className="mr-2 h-4 w-4" /> 4443 Import configuration 4444 </Button> 4445 <p className="text-xs text-muted-foreground"> 4446 Imports preserve dashboard users and passwords while replacing mappings, provider keys, and 4447 scheduler settings. 4448 </p> 4449 </CardContent> 4450 </div> 4451 </div> 4452 </Card> 4453 </> 4454 ) : ( 4455 <Card className="animate-slide-up"> 4456 <CardHeader> 4457 <CardTitle>Access Scope</CardTitle> 4458 <CardDescription>Your current account permissions.</CardDescription> 4459 </CardHeader> 4460 <CardContent className="flex flex-wrap gap-2 pt-0"> 4461 {PERMISSION_OPTIONS.filter((permission) => effectivePermissions[permission.key]).map((permission) => ( 4462 <Badge key={`self-perm-${permission.key}`} variant="outline"> 4463 {permission.label} 4464 </Badge> 4465 ))} 4466 </CardContent> 4467 </Card> 4468 )} 4469 </section> 4470 ) : null} 4471 {isAddAccountSheetOpen ? ( 4472 <div 4473 className="fixed inset-0 z-50 flex items-end justify-center bg-black/55 p-0 backdrop-blur-sm sm:items-stretch sm:justify-end" 4474 onClick={closeAddAccountSheet} 4475 > 4476 <aside 4477 className="flex h-[95vh] w-full max-w-xl flex-col rounded-t-2xl border border-border/80 bg-card shadow-2xl sm:h-full sm:rounded-none sm:rounded-l-2xl" 4478 onClick={(event) => event.stopPropagation()} 4479 > 4480 <div className="flex items-start justify-between border-b border-border/70 px-5 py-4"> 4481 <div> 4482 <p className="text-xs uppercase tracking-[0.15em] text-muted-foreground">Add Account</p> 4483 <h2 className="text-lg font-semibold">Create Crosspost Mapping</h2> 4484 </div> 4485 <Button variant="ghost" size="icon" onClick={closeAddAccountSheet} aria-label="Close add account flow"> 4486 <X className="h-4 w-4" /> 4487 </Button> 4488 </div> 4489 4490 <div className="border-b border-border/70 px-5 py-3"> 4491 <div className="flex items-center gap-2"> 4492 {ADD_ACCOUNT_STEPS.map((label, index) => { 4493 const step = index + 1; 4494 const active = step === addAccountStep; 4495 const complete = step < addAccountStep; 4496 return ( 4497 <div key={label} className="flex min-w-0 flex-1 items-center gap-2"> 4498 <div 4499 className={cn( 4500 'flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold', 4501 complete && 'border-foreground bg-foreground text-background', 4502 active && 'border-foreground text-foreground', 4503 !active && !complete && 'border-border text-muted-foreground', 4504 )} 4505 > 4506 {step} 4507 </div> 4508 <span 4509 className={cn( 4510 'truncate text-xs', 4511 active ? 'text-foreground' : complete ? 'text-foreground/90' : 'text-muted-foreground', 4512 )} 4513 > 4514 {label} 4515 </span> 4516 {step < ADD_ACCOUNT_STEP_COUNT ? <div className="h-px flex-1 bg-border/70" /> : null} 4517 </div> 4518 ); 4519 })} 4520 </div> 4521 </div> 4522 4523 <div className="flex-1 overflow-y-auto px-5 py-4"> 4524 {addAccountStep === 1 ? ( 4525 <div className="space-y-4 animate-fade-in"> 4526 <div className="space-y-1"> 4527 <p className="text-sm font-semibold">Who owns this mapping?</p> 4528 <p className="text-xs text-muted-foreground">Set a label so account rows stay easy to scan.</p> 4529 </div> 4530 <div className="space-y-2"> 4531 <Label htmlFor="add-account-owner">Owner</Label> 4532 <Input 4533 id="add-account-owner" 4534 value={newMapping.owner} 4535 onChange={(event) => { 4536 setNewMapping((previous) => ({ ...previous, owner: event.target.value })); 4537 }} 4538 placeholder="jack" 4539 /> 4540 </div> 4541 <div className="space-y-2"> 4542 <Label>Use Existing Folder (Optional)</Label> 4543 {reusableGroupOptions.length === 0 ? ( 4544 <p className="rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground"> 4545 No folders yet. Create one below or from the Accounts tab. 4546 </p> 4547 ) : ( 4548 <div className="flex flex-wrap gap-2"> 4549 {reusableGroupOptions.map((group) => { 4550 const selected = getGroupKey(newMapping.groupName) === group.key; 4551 return ( 4552 <button 4553 key={`preset-group-${group.key}`} 4554 className={cn( 4555 'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs transition-colors', 4556 selected 4557 ? 'border-foreground bg-foreground text-background' 4558 : 'border-border bg-background text-foreground hover:bg-muted', 4559 )} 4560 onClick={() => applyGroupPresetToNewMapping(group.key)} 4561 type="button" 4562 > 4563 <span>{group.emoji}</span> 4564 <span>{group.name}</span> 4565 </button> 4566 ); 4567 })} 4568 </div> 4569 )} 4570 </div> 4571 <div className="grid gap-3 sm:grid-cols-[1fr_auto]"> 4572 <div className="space-y-2"> 4573 <Label htmlFor="add-account-group-name">Folder / Group Name (Optional)</Label> 4574 <Input 4575 id="add-account-group-name" 4576 value={newMapping.groupName} 4577 onChange={(event) => { 4578 setNewMapping((previous) => ({ ...previous, groupName: event.target.value })); 4579 }} 4580 placeholder="Gaming, News, Sports..." 4581 /> 4582 </div> 4583 <div className="space-y-2"> 4584 <Label htmlFor="add-account-group-emoji">Emoji</Label> 4585 <Input 4586 id="add-account-group-emoji" 4587 value={newMapping.groupEmoji} 4588 onChange={(event) => { 4589 setNewMapping((previous) => ({ ...previous, groupEmoji: event.target.value })); 4590 }} 4591 maxLength={8} 4592 placeholder={DEFAULT_GROUP_EMOJI} 4593 /> 4594 </div> 4595 </div> 4596 </div> 4597 ) : null} 4598 4599 {addAccountStep === 2 ? ( 4600 <div className="space-y-4 animate-fade-in"> 4601 <div className="space-y-1"> 4602 <p className="text-sm font-semibold">Choose Twitter sources</p> 4603 <p className="text-xs text-muted-foreground"> 4604 Add one or many usernames. Press Enter or comma to add quickly. 4605 </p> 4606 </div> 4607 <div className="space-y-2"> 4608 <Label htmlFor="add-account-twitter-usernames">Twitter Usernames</Label> 4609 <div className="flex gap-2"> 4610 <Input 4611 id="add-account-twitter-usernames" 4612 value={newTwitterInput} 4613 onChange={(event) => { 4614 setNewTwitterInput(event.target.value); 4615 }} 4616 onKeyDown={(event) => { 4617 if (event.key === 'Enter' || event.key === ',') { 4618 event.preventDefault(); 4619 addNewTwitterUsername(); 4620 } 4621 }} 4622 placeholder="@accountname" 4623 /> 4624 <Button 4625 variant="outline" 4626 type="button" 4627 disabled={normalizeTwitterUsername(newTwitterInput).length === 0} 4628 onClick={addNewTwitterUsername} 4629 > 4630 Add 4631 </Button> 4632 </div> 4633 </div> 4634 <div className="flex min-h-7 flex-wrap gap-2"> 4635 {newTwitterUsers.length === 0 ? ( 4636 <p className="text-xs text-muted-foreground">No source usernames added yet.</p> 4637 ) : ( 4638 newTwitterUsers.map((username) => ( 4639 <Badge key={`new-${username}`} variant="secondary" className="gap-1 pr-1"> 4640 @{username} 4641 <button 4642 type="button" 4643 className="rounded-full px-1 text-muted-foreground transition hover:bg-background hover:text-foreground" 4644 onClick={() => removeNewTwitterUsername(username)} 4645 aria-label={`Remove @${username}`} 4646 > 4647 × 4648 </button> 4649 </Badge> 4650 )) 4651 )} 4652 </div> 4653 </div> 4654 ) : null} 4655 4656 {addAccountStep === 3 ? ( 4657 <div className="space-y-4 animate-fade-in"> 4658 <div className="space-y-1"> 4659 <p className="text-sm font-semibold">Target Bluesky account</p> 4660 <p className="text-xs text-muted-foreground">Use an app password for the destination account.</p> 4661 </div> 4662 <div className="space-y-2"> 4663 <Label htmlFor="add-account-bsky-identifier">Bluesky Identifier</Label> 4664 <Input 4665 id="add-account-bsky-identifier" 4666 value={newMapping.bskyIdentifier} 4667 onChange={(event) => { 4668 setNewMapping((previous) => ({ ...previous, bskyIdentifier: event.target.value })); 4669 }} 4670 placeholder="example.bsky.social" 4671 /> 4672 </div> 4673 <div className="space-y-2"> 4674 <Label htmlFor="add-account-bsky-password">Bluesky App Password</Label> 4675 <Input 4676 id="add-account-bsky-password" 4677 type="password" 4678 value={newMapping.bskyPassword} 4679 onChange={(event) => { 4680 setNewMapping((previous) => ({ ...previous, bskyPassword: event.target.value })); 4681 }} 4682 /> 4683 </div> 4684 <div className="space-y-2"> 4685 <Label htmlFor="add-account-bsky-url">Bluesky Service URL</Label> 4686 <Input 4687 id="add-account-bsky-url" 4688 value={newMapping.bskyServiceUrl} 4689 onChange={(event) => { 4690 setNewMapping((previous) => ({ ...previous, bskyServiceUrl: event.target.value })); 4691 }} 4692 placeholder="https://bsky.social" 4693 /> 4694 </div> 4695 </div> 4696 ) : null} 4697 4698 {addAccountStep === 4 ? ( 4699 <div className="space-y-4 animate-fade-in"> 4700 <div className="space-y-1"> 4701 <p className="text-sm font-semibold">Review and create</p> 4702 <p className="text-xs text-muted-foreground">Confirm details before saving this mapping.</p> 4703 </div> 4704 <div className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm"> 4705 <p> 4706 <span className="font-medium">Owner:</span> {newMapping.owner || '--'} 4707 </p> 4708 <p> 4709 <span className="font-medium">Twitter Sources:</span>{' '} 4710 {newTwitterUsers.length > 0 ? newTwitterUsers.map((username) => `@${username}`).join(', ') : '--'} 4711 </p> 4712 <p> 4713 <span className="font-medium">Bluesky Target:</span> {newMapping.bskyIdentifier || '--'} 4714 </p> 4715 <p> 4716 <span className="font-medium">Folder:</span>{' '} 4717 {newMapping.groupName.trim() 4718 ? `${newMapping.groupEmoji.trim() || DEFAULT_GROUP_EMOJI} ${newMapping.groupName.trim()}` 4719 : `${DEFAULT_GROUP_EMOJI} ${DEFAULT_GROUP_NAME}`} 4720 </p> 4721 </div> 4722 </div> 4723 ) : null} 4724 </div> 4725 4726 <div className="flex items-center justify-between gap-2 border-t border-border/70 px-5 py-4"> 4727 <Button variant="outline" onClick={retreatAddAccountStep} disabled={addAccountStep === 1 || isBusy}> 4728 <ChevronLeft className="mr-2 h-4 w-4" /> 4729 Back 4730 </Button> 4731 {addAccountStep < ADD_ACCOUNT_STEP_COUNT ? ( 4732 <Button onClick={advanceAddAccountStep}> 4733 Next 4734 <ChevronRight className="ml-2 h-4 w-4" /> 4735 </Button> 4736 ) : ( 4737 <Button onClick={() => void submitNewMapping()} disabled={isBusy}> 4738 {isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />} 4739 Create Account 4740 </Button> 4741 )} 4742 </div> 4743 </aside> 4744 </div> 4745 ) : null} 4746 4747 {editingMapping ? ( 4748 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"> 4749 <Card className="w-full max-w-xl animate-slide-up border-border/90 bg-card"> 4750 <CardHeader> 4751 <CardTitle>Edit Mapping</CardTitle> 4752 <CardDescription>Update ownership, handles, and target credentials.</CardDescription> 4753 </CardHeader> 4754 <CardContent> 4755 <form className="space-y-3" onSubmit={handleUpdateMapping}> 4756 <div className="space-y-2"> 4757 <Label htmlFor="edit-owner">Owner</Label> 4758 <Input 4759 id="edit-owner" 4760 value={editForm.owner} 4761 onChange={(event) => { 4762 setEditForm((prev) => ({ ...prev, owner: event.target.value })); 4763 }} 4764 required 4765 /> 4766 </div> 4767 <div className="grid gap-3 sm:grid-cols-[1fr_auto]"> 4768 <div className="space-y-2"> 4769 <Label htmlFor="edit-groupName">Folder / Group Name</Label> 4770 <Input 4771 id="edit-groupName" 4772 value={editForm.groupName} 4773 onChange={(event) => { 4774 setEditForm((prev) => ({ ...prev, groupName: event.target.value })); 4775 }} 4776 placeholder="Gaming, News, Sports..." 4777 /> 4778 </div> 4779 <div className="space-y-2"> 4780 <Label htmlFor="edit-groupEmoji">Emoji</Label> 4781 <Input 4782 id="edit-groupEmoji" 4783 value={editForm.groupEmoji} 4784 onChange={(event) => { 4785 setEditForm((prev) => ({ ...prev, groupEmoji: event.target.value })); 4786 }} 4787 placeholder="📁" 4788 maxLength={8} 4789 /> 4790 </div> 4791 </div> 4792 <div className="space-y-2"> 4793 <Label htmlFor="edit-twitterUsernames">Twitter Usernames</Label> 4794 <div className="flex gap-2"> 4795 <Input 4796 id="edit-twitterUsernames" 4797 value={editTwitterInput} 4798 onChange={(event) => { 4799 setEditTwitterInput(event.target.value); 4800 }} 4801 onKeyDown={(event) => { 4802 if (event.key === 'Enter' || event.key === ',') { 4803 event.preventDefault(); 4804 addEditTwitterUsername(); 4805 } 4806 }} 4807 placeholder="@accountname" 4808 /> 4809 <Button 4810 variant="outline" 4811 size="sm" 4812 type="button" 4813 disabled={normalizeTwitterUsername(editTwitterInput).length === 0} 4814 onClick={addEditTwitterUsername} 4815 > 4816 Add 4817 </Button> 4818 </div> 4819 <div className="flex min-h-7 flex-wrap gap-2"> 4820 {editTwitterUsers.map((username) => ( 4821 <Badge key={`edit-${username}`} variant="secondary" className="gap-1 pr-1"> 4822 @{username} 4823 <button 4824 type="button" 4825 className="rounded-full px-1 text-muted-foreground transition hover:bg-background hover:text-foreground" 4826 onClick={() => removeEditTwitterUsername(username)} 4827 aria-label={`Remove @${username}`} 4828 > 4829 × 4830 </button> 4831 </Badge> 4832 ))} 4833 </div> 4834 </div> 4835 <div className="space-y-2"> 4836 <Label htmlFor="edit-bskyIdentifier">Bluesky Identifier</Label> 4837 <Input 4838 id="edit-bskyIdentifier" 4839 value={editForm.bskyIdentifier} 4840 onChange={(event) => { 4841 setEditForm((prev) => ({ ...prev, bskyIdentifier: event.target.value })); 4842 }} 4843 required 4844 /> 4845 </div> 4846 <div className="space-y-2"> 4847 <Label htmlFor="edit-bskyPassword">New App Password (optional)</Label> 4848 <Input 4849 id="edit-bskyPassword" 4850 type="password" 4851 value={editForm.bskyPassword} 4852 onChange={(event) => { 4853 setEditForm((prev) => ({ ...prev, bskyPassword: event.target.value })); 4854 }} 4855 placeholder="Leave blank to keep existing" 4856 /> 4857 </div> 4858 <div className="space-y-2"> 4859 <Label htmlFor="edit-bskyServiceUrl">Service URL</Label> 4860 <Input 4861 id="edit-bskyServiceUrl" 4862 value={editForm.bskyServiceUrl} 4863 onChange={(event) => { 4864 setEditForm((prev) => ({ ...prev, bskyServiceUrl: event.target.value })); 4865 }} 4866 /> 4867 </div> 4868 4869 <div className="flex flex-wrap justify-end gap-2 pt-2"> 4870 <Button 4871 variant="ghost" 4872 type="button" 4873 onClick={() => { 4874 setEditingMapping(null); 4875 }} 4876 > 4877 Cancel 4878 </Button> 4879 <Button type="submit" disabled={isBusy}> 4880 {isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null} 4881 Save changes 4882 </Button> 4883 </div> 4884 </form> 4885 </CardContent> 4886 </Card> 4887 </div> 4888 ) : null} 4889 </main> 4890 ); 4891} 4892 4893export default App;