forked from
j4ck.xyz/tweets2bsky
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.
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;