tangled
alpha
login
or
join now
j4ck.xyz
/
tweets2bsky
4
fork
atom
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.
4
fork
atom
overview
issues
pulls
pipelines
feat: add fuzzy search, global account view, and group manager
jack
3 weeks ago
f349fd21
f7b5720c
+929
-33
3 changed files
expand all
collapse all
unified
split
src
db.ts
server.ts
web
src
App.tsx
+156
src/db.ts
···
147
147
created_at?: string;
148
148
}
149
149
150
150
+
export interface ProcessedTweetSearchResult extends ProcessedTweet {
151
151
+
score: number;
152
152
+
}
153
153
+
154
154
+
function normalizeSearchValue(value: string): string {
155
155
+
return value
156
156
+
.toLowerCase()
157
157
+
.replace(/[^a-z0-9@#._\-\s]+/g, ' ')
158
158
+
.replace(/\s+/g, ' ')
159
159
+
.trim();
160
160
+
}
161
161
+
162
162
+
function tokenizeSearchValue(value: string): string[] {
163
163
+
if (!value) {
164
164
+
return [];
165
165
+
}
166
166
+
return value.split(' ').filter((token) => token.length > 0);
167
167
+
}
168
168
+
169
169
+
function orderedSubsequenceScore(query: string, candidate: string): number {
170
170
+
if (!query || !candidate) {
171
171
+
return 0;
172
172
+
}
173
173
+
174
174
+
let matched = 0;
175
175
+
let searchIndex = 0;
176
176
+
for (const char of query) {
177
177
+
const foundIndex = candidate.indexOf(char, searchIndex);
178
178
+
if (foundIndex === -1) {
179
179
+
continue;
180
180
+
}
181
181
+
matched += 1;
182
182
+
searchIndex = foundIndex + 1;
183
183
+
}
184
184
+
185
185
+
return matched / query.length;
186
186
+
}
187
187
+
188
188
+
function buildBigrams(value: string): Set<string> {
189
189
+
const result = new Set<string>();
190
190
+
if (value.length < 2) {
191
191
+
if (value.length === 1) {
192
192
+
result.add(value);
193
193
+
}
194
194
+
return result;
195
195
+
}
196
196
+
197
197
+
for (let i = 0; i < value.length - 1; i += 1) {
198
198
+
result.add(value.slice(i, i + 2));
199
199
+
}
200
200
+
201
201
+
return result;
202
202
+
}
203
203
+
204
204
+
function diceCoefficient(a: string, b: string): number {
205
205
+
const aBigrams = buildBigrams(a);
206
206
+
const bBigrams = buildBigrams(b);
207
207
+
if (aBigrams.size === 0 || bBigrams.size === 0) {
208
208
+
return 0;
209
209
+
}
210
210
+
211
211
+
let overlap = 0;
212
212
+
for (const gram of aBigrams) {
213
213
+
if (bBigrams.has(gram)) {
214
214
+
overlap += 1;
215
215
+
}
216
216
+
}
217
217
+
218
218
+
return (2 * overlap) / (aBigrams.size + bBigrams.size);
219
219
+
}
220
220
+
221
221
+
function scoreCandidateField(query: string, tokens: string[], candidateValue?: string): number {
222
222
+
const candidate = normalizeSearchValue(candidateValue || '');
223
223
+
if (!query || !candidate) {
224
224
+
return 0;
225
225
+
}
226
226
+
227
227
+
let score = 0;
228
228
+
if (candidate === query) {
229
229
+
score += 170;
230
230
+
} else if (candidate.startsWith(query)) {
231
231
+
score += 140;
232
232
+
} else if (candidate.includes(query)) {
233
233
+
score += 112;
234
234
+
}
235
235
+
236
236
+
let matchedTokens = 0;
237
237
+
for (const token of tokens) {
238
238
+
if (candidate.includes(token)) {
239
239
+
matchedTokens += 1;
240
240
+
score += token.length >= 4 ? 18 : 12;
241
241
+
}
242
242
+
}
243
243
+
244
244
+
if (tokens.length > 0) {
245
245
+
score += (matchedTokens / tokens.length) * 48;
246
246
+
}
247
247
+
248
248
+
score += orderedSubsequenceScore(query, candidate) * 46;
249
249
+
score += diceCoefficient(query, candidate) * 55;
250
250
+
251
251
+
return score;
252
252
+
}
253
253
+
254
254
+
function scoreProcessedTweet(tweet: ProcessedTweet, query: string, tokens: string[]): number {
255
255
+
const usernameScore = scoreCandidateField(query, tokens, tweet.twitter_username) * 1.25;
256
256
+
const identifierScore = scoreCandidateField(query, tokens, tweet.bsky_identifier) * 1.18;
257
257
+
const textScore = scoreCandidateField(query, tokens, tweet.tweet_text) * 0.98;
258
258
+
const idScore = scoreCandidateField(query, tokens, tweet.twitter_id) * 0.72;
259
259
+
260
260
+
const maxScore = Math.max(usernameScore, identifierScore, textScore, idScore);
261
261
+
const blendedScore = maxScore + (usernameScore + identifierScore + textScore + idScore - maxScore) * 0.22;
262
262
+
263
263
+
const recencyBoost = (() => {
264
264
+
if (!tweet.created_at) return 0;
265
265
+
const timestamp = Date.parse(tweet.created_at);
266
266
+
if (!Number.isFinite(timestamp)) return 0;
267
267
+
const ageDays = (Date.now() - timestamp) / (24 * 60 * 60 * 1000);
268
268
+
return Math.max(0, 7 - ageDays);
269
269
+
})();
270
270
+
271
271
+
return blendedScore + recencyBoost;
272
272
+
}
273
273
+
150
274
export const dbService = {
151
275
getTweet(twitterId: string, bskyIdentifier: string): ProcessedTweet | null {
152
276
const stmt = db.prepare('SELECT * FROM processed_tweets WHERE twitter_id = ? AND bsky_identifier = ?');
···
226
350
getRecentProcessedTweets(limit = 50): ProcessedTweet[] {
227
351
const stmt = db.prepare('SELECT * FROM processed_tweets ORDER BY datetime(created_at) DESC, rowid DESC LIMIT ?');
228
352
return stmt.all(limit) as ProcessedTweet[];
353
353
+
},
354
354
+
355
355
+
searchMigratedTweets(query: string, limit = 60, scanLimit = 3000): ProcessedTweetSearchResult[] {
356
356
+
const normalizedQuery = normalizeSearchValue(query || '');
357
357
+
if (!normalizedQuery) {
358
358
+
return [];
359
359
+
}
360
360
+
361
361
+
const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(limit, 200)) : 60;
362
362
+
const safeScanLimit = Number.isFinite(scanLimit) ? Math.max(safeLimit, Math.min(scanLimit, 8000)) : 3000;
363
363
+
const tokens = tokenizeSearchValue(normalizedQuery);
364
364
+
365
365
+
const stmt = db.prepare(
366
366
+
'SELECT * FROM processed_tweets WHERE status = "migrated" ORDER BY datetime(created_at) DESC, rowid DESC LIMIT ?',
367
367
+
);
368
368
+
const rows = stmt.all(safeScanLimit) as ProcessedTweet[];
369
369
+
370
370
+
return rows
371
371
+
.map((row) => ({
372
372
+
...row,
373
373
+
score: scoreProcessedTweet(row, normalizedQuery, tokens),
374
374
+
}))
375
375
+
.filter((row) => row.score >= 22)
376
376
+
.sort((a, b) => {
377
377
+
if (b.score !== a.score) {
378
378
+
return b.score - a.score;
379
379
+
}
380
380
+
const aTime = a.created_at ? Date.parse(a.created_at) : 0;
381
381
+
const bTime = b.created_at ? Date.parse(b.created_at) : 0;
382
382
+
return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0);
383
383
+
})
384
384
+
.slice(0, safeLimit);
229
385
},
230
386
231
387
deleteTweetsByUsername(username: string) {
+165
-4
src/server.ts
···
23
23
const BSKY_APPVIEW_URL = process.env.BSKY_APPVIEW_URL || 'https://public.api.bsky.app';
24
24
const POST_VIEW_CACHE_TTL_MS = 60_000;
25
25
const PROFILE_CACHE_TTL_MS = 5 * 60_000;
26
26
+
const RESERVED_UNGROUPED_KEY = 'ungrouped';
26
27
27
28
interface CacheEntry<T> {
28
29
value: T;
···
74
75
media: EnrichedPostMedia[];
75
76
}
76
77
78
78
+
interface LocalPostSearchResult {
79
79
+
twitterId: string;
80
80
+
twitterUsername: string;
81
81
+
bskyIdentifier: string;
82
82
+
tweetText?: string;
83
83
+
bskyUri?: string;
84
84
+
bskyCid?: string;
85
85
+
createdAt?: string;
86
86
+
postUrl?: string;
87
87
+
twitterUrl?: string;
88
88
+
score: number;
89
89
+
}
90
90
+
77
91
const postViewCache = new Map<string, CacheEntry<any>>();
78
92
const profileCache = new Map<string, CacheEntry<BskyProfileView>>();
79
93
···
114
128
return typeof value === 'string' ? value.trim() : '';
115
129
}
116
130
131
131
+
function getNormalizedGroupKey(value: unknown): string {
132
132
+
return normalizeGroupName(value).toLowerCase();
133
133
+
}
134
134
+
117
135
function ensureGroupExists(config: AppConfig, name?: string, emoji?: string) {
118
136
const normalizedName = normalizeGroupName(name);
119
119
-
if (!normalizedName) return;
137
137
+
if (!normalizedName || getNormalizedGroupKey(normalizedName) === RESERVED_UNGROUPED_KEY) return;
120
138
121
139
if (!Array.isArray(config.groups)) {
122
140
config.groups = [];
123
141
}
124
142
125
125
-
const existingIndex = config.groups.findIndex((group) => normalizeGroupName(group.name).toLowerCase() === normalizedName.toLowerCase());
143
143
+
const existingIndex = config.groups.findIndex((group) => getNormalizedGroupKey(group.name) === getNormalizedGroupKey(normalizedName));
126
144
const normalizedEmoji = normalizeGroupEmoji(emoji);
127
145
128
146
if (existingIndex === -1) {
···
444
462
445
463
app.get('/api/groups', authenticateToken, (_req, res) => {
446
464
const config = getConfig();
447
447
-
res.json(Array.isArray(config.groups) ? config.groups : []);
465
465
+
const groups = Array.isArray(config.groups)
466
466
+
? config.groups.filter((group) => getNormalizedGroupKey(group.name) !== RESERVED_UNGROUPED_KEY)
467
467
+
: [];
468
468
+
res.json(groups);
448
469
});
449
470
450
471
app.post('/api/groups', authenticateToken, (req, res) => {
···
457
478
return;
458
479
}
459
480
481
481
+
if (getNormalizedGroupKey(normalizedName) === RESERVED_UNGROUPED_KEY) {
482
482
+
res.status(400).json({ error: '"Ungrouped" is reserved for default behavior.' });
483
483
+
return;
484
484
+
}
485
485
+
460
486
ensureGroupExists(config, normalizedName, normalizedEmoji);
461
487
saveConfig(config);
462
488
463
463
-
const group = config.groups.find((entry) => normalizeGroupName(entry.name).toLowerCase() === normalizedName.toLowerCase());
489
489
+
const group = config.groups.find((entry) => getNormalizedGroupKey(entry.name) === getNormalizedGroupKey(normalizedName));
464
490
res.json(group || { name: normalizedName, ...(normalizedEmoji ? { emoji: normalizedEmoji } : {}) });
465
491
});
466
492
493
493
+
app.put('/api/groups/:groupKey', authenticateToken, (req, res) => {
494
494
+
const currentGroupKey = getNormalizedGroupKey(req.params.groupKey);
495
495
+
if (!currentGroupKey || currentGroupKey === RESERVED_UNGROUPED_KEY) {
496
496
+
res.status(400).json({ error: 'Invalid group key.' });
497
497
+
return;
498
498
+
}
499
499
+
500
500
+
const requestedName = normalizeGroupName(req.body?.name);
501
501
+
const requestedEmoji = normalizeGroupEmoji(req.body?.emoji);
502
502
+
if (!requestedName) {
503
503
+
res.status(400).json({ error: 'Group name is required.' });
504
504
+
return;
505
505
+
}
506
506
+
507
507
+
const requestedGroupKey = getNormalizedGroupKey(requestedName);
508
508
+
if (requestedGroupKey === RESERVED_UNGROUPED_KEY) {
509
509
+
res.status(400).json({ error: '"Ungrouped" is reserved and cannot be edited.' });
510
510
+
return;
511
511
+
}
512
512
+
513
513
+
const config = getConfig();
514
514
+
if (!Array.isArray(config.groups)) {
515
515
+
config.groups = [];
516
516
+
}
517
517
+
518
518
+
const groupIndex = config.groups.findIndex((group) => getNormalizedGroupKey(group.name) === currentGroupKey);
519
519
+
if (groupIndex === -1) {
520
520
+
res.status(404).json({ error: 'Group not found.' });
521
521
+
return;
522
522
+
}
523
523
+
524
524
+
const mergeIndex = config.groups.findIndex(
525
525
+
(group, index) => index !== groupIndex && getNormalizedGroupKey(group.name) === requestedGroupKey,
526
526
+
);
527
527
+
528
528
+
let finalName = requestedName;
529
529
+
let finalEmoji = requestedEmoji || normalizeGroupEmoji(config.groups[groupIndex]?.emoji);
530
530
+
if (mergeIndex !== -1) {
531
531
+
finalName = normalizeGroupName(config.groups[mergeIndex]?.name) || requestedName;
532
532
+
finalEmoji = requestedEmoji || normalizeGroupEmoji(config.groups[mergeIndex]?.emoji) || finalEmoji;
533
533
+
534
534
+
config.groups[mergeIndex] = {
535
535
+
name: finalName,
536
536
+
...(finalEmoji ? { emoji: finalEmoji } : {}),
537
537
+
};
538
538
+
config.groups.splice(groupIndex, 1);
539
539
+
} else {
540
540
+
config.groups[groupIndex] = {
541
541
+
name: finalName,
542
542
+
...(finalEmoji ? { emoji: finalEmoji } : {}),
543
543
+
};
544
544
+
}
545
545
+
546
546
+
const keysToRewrite = new Set([currentGroupKey, requestedGroupKey]);
547
547
+
config.mappings = config.mappings.map((mapping) => {
548
548
+
const mappingGroupKey = getNormalizedGroupKey(mapping.groupName);
549
549
+
if (!keysToRewrite.has(mappingGroupKey)) {
550
550
+
return mapping;
551
551
+
}
552
552
+
return {
553
553
+
...mapping,
554
554
+
groupName: finalName,
555
555
+
groupEmoji: finalEmoji || undefined,
556
556
+
};
557
557
+
});
558
558
+
559
559
+
saveConfig(config);
560
560
+
res.json({
561
561
+
name: finalName,
562
562
+
...(finalEmoji ? { emoji: finalEmoji } : {}),
563
563
+
});
564
564
+
});
565
565
+
566
566
+
app.delete('/api/groups/:groupKey', authenticateToken, (req, res) => {
567
567
+
const groupKey = getNormalizedGroupKey(req.params.groupKey);
568
568
+
if (!groupKey || groupKey === RESERVED_UNGROUPED_KEY) {
569
569
+
res.status(400).json({ error: 'Invalid group key.' });
570
570
+
return;
571
571
+
}
572
572
+
573
573
+
const config = getConfig();
574
574
+
if (!Array.isArray(config.groups)) {
575
575
+
config.groups = [];
576
576
+
}
577
577
+
578
578
+
const beforeCount = config.groups.length;
579
579
+
config.groups = config.groups.filter((group) => getNormalizedGroupKey(group.name) !== groupKey);
580
580
+
if (config.groups.length === beforeCount) {
581
581
+
res.status(404).json({ error: 'Group not found.' });
582
582
+
return;
583
583
+
}
584
584
+
585
585
+
let reassigned = 0;
586
586
+
config.mappings = config.mappings.map((mapping) => {
587
587
+
if (getNormalizedGroupKey(mapping.groupName) !== groupKey) {
588
588
+
return mapping;
589
589
+
}
590
590
+
reassigned += 1;
591
591
+
return {
592
592
+
...mapping,
593
593
+
groupName: undefined,
594
594
+
groupEmoji: undefined,
595
595
+
};
596
596
+
});
597
597
+
598
598
+
saveConfig(config);
599
599
+
res.json({ success: true, reassignedCount: reassigned });
600
600
+
});
601
601
+
467
602
app.post('/api/mappings', authenticateToken, (req, res) => {
468
603
const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner, groupName, groupEmoji } = req.body;
469
604
const config = getConfig();
···
790
925
const limitedActors = actors.slice(0, 200);
791
926
const profiles = await fetchProfilesByActor(limitedActors);
792
927
res.json(profiles);
928
928
+
});
929
929
+
930
930
+
app.get('/api/posts/search', authenticateToken, (req, res) => {
931
931
+
const query = typeof req.query.q === 'string' ? req.query.q : '';
932
932
+
if (!query.trim()) {
933
933
+
res.json([]);
934
934
+
return;
935
935
+
}
936
936
+
937
937
+
const requestedLimit = req.query.limit ? Number(req.query.limit) : 80;
938
938
+
const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 200)) : 80;
939
939
+
940
940
+
const results = dbService.searchMigratedTweets(query, limit).map<LocalPostSearchResult>((row) => ({
941
941
+
twitterId: row.twitter_id,
942
942
+
twitterUsername: row.twitter_username,
943
943
+
bskyIdentifier: row.bsky_identifier,
944
944
+
tweetText: row.tweet_text,
945
945
+
bskyUri: row.bsky_uri,
946
946
+
bskyCid: row.bsky_cid,
947
947
+
createdAt: row.created_at,
948
948
+
postUrl: buildPostUrl(row.bsky_identifier, row.bsky_uri),
949
949
+
twitterUrl: buildTwitterPostUrl(row.twitter_username, row.twitter_id),
950
950
+
score: Number(row.score.toFixed(2)),
951
951
+
}));
952
952
+
953
953
+
res.json(results);
793
954
});
794
955
795
956
app.get('/api/posts/enriched', authenticateToken, async (req, res) => {
+608
-29
web/src/App.tsx
···
150
150
media: EnrichedPostMedia[];
151
151
}
152
152
153
153
+
interface LocalPostSearchResult {
154
154
+
twitterId: string;
155
155
+
twitterUsername: string;
156
156
+
bskyIdentifier: string;
157
157
+
tweetText?: string;
158
158
+
bskyUri?: string;
159
159
+
bskyCid?: string;
160
160
+
createdAt?: string;
161
161
+
postUrl?: string;
162
162
+
twitterUrl?: string;
163
163
+
score: number;
164
164
+
}
165
165
+
153
166
interface BskyProfileView {
154
167
did?: string;
155
168
handle?: string;
···
226
239
};
227
240
const ADD_ACCOUNT_STEP_COUNT = 4;
228
241
const ADD_ACCOUNT_STEPS = ['Owner', 'Sources', 'Bluesky', 'Confirm'] as const;
242
242
+
const ACCOUNT_SEARCH_MIN_SCORE = 22;
229
243
230
244
const selectClassName =
231
245
'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';
···
343
357
return next;
344
358
}
345
359
360
360
+
function normalizeSearchValue(value: string): string {
361
361
+
return value
362
362
+
.toLowerCase()
363
363
+
.replace(/[^a-z0-9@#._\-\s]+/g, ' ')
364
364
+
.replace(/\s+/g, ' ')
365
365
+
.trim();
366
366
+
}
367
367
+
368
368
+
function tokenizeSearchValue(value: string): string[] {
369
369
+
if (!value) {
370
370
+
return [];
371
371
+
}
372
372
+
return value.split(' ').filter((token) => token.length > 0);
373
373
+
}
374
374
+
375
375
+
function orderedSubsequenceScore(query: string, candidate: string): number {
376
376
+
if (!query || !candidate) {
377
377
+
return 0;
378
378
+
}
379
379
+
380
380
+
let matched = 0;
381
381
+
let searchIndex = 0;
382
382
+
for (const char of query) {
383
383
+
const foundIndex = candidate.indexOf(char, searchIndex);
384
384
+
if (foundIndex === -1) {
385
385
+
continue;
386
386
+
}
387
387
+
matched += 1;
388
388
+
searchIndex = foundIndex + 1;
389
389
+
}
390
390
+
391
391
+
return matched / query.length;
392
392
+
}
393
393
+
394
394
+
function buildBigrams(value: string): Set<string> {
395
395
+
const result = new Set<string>();
396
396
+
if (value.length < 2) {
397
397
+
if (value.length === 1) {
398
398
+
result.add(value);
399
399
+
}
400
400
+
return result;
401
401
+
}
402
402
+
for (let i = 0; i < value.length - 1; i += 1) {
403
403
+
result.add(value.slice(i, i + 2));
404
404
+
}
405
405
+
return result;
406
406
+
}
407
407
+
408
408
+
function diceCoefficient(a: string, b: string): number {
409
409
+
const aBigrams = buildBigrams(a);
410
410
+
const bBigrams = buildBigrams(b);
411
411
+
if (aBigrams.size === 0 || bBigrams.size === 0) {
412
412
+
return 0;
413
413
+
}
414
414
+
let overlap = 0;
415
415
+
for (const gram of aBigrams) {
416
416
+
if (bBigrams.has(gram)) {
417
417
+
overlap += 1;
418
418
+
}
419
419
+
}
420
420
+
return (2 * overlap) / (aBigrams.size + bBigrams.size);
421
421
+
}
422
422
+
423
423
+
function scoreSearchField(query: string, tokens: string[], candidateValue?: string): number {
424
424
+
const candidate = normalizeSearchValue(candidateValue || '');
425
425
+
if (!query || !candidate) {
426
426
+
return 0;
427
427
+
}
428
428
+
429
429
+
let score = 0;
430
430
+
if (candidate === query) {
431
431
+
score += 170;
432
432
+
} else if (candidate.startsWith(query)) {
433
433
+
score += 138;
434
434
+
} else if (candidate.includes(query)) {
435
435
+
score += 108;
436
436
+
}
437
437
+
438
438
+
let matchedTokens = 0;
439
439
+
for (const token of tokens) {
440
440
+
if (candidate.includes(token)) {
441
441
+
matchedTokens += 1;
442
442
+
score += token.length >= 4 ? 18 : 12;
443
443
+
}
444
444
+
}
445
445
+
if (tokens.length > 0) {
446
446
+
score += (matchedTokens / tokens.length) * 46;
447
447
+
}
448
448
+
449
449
+
score += orderedSubsequenceScore(query, candidate) * 45;
450
450
+
score += diceCoefficient(query, candidate) * 52;
451
451
+
return score;
452
452
+
}
453
453
+
454
454
+
function scoreAccountMapping(mapping: AccountMapping, query: string, tokens: string[]): number {
455
455
+
const usernameScores = mapping.twitterUsernames.map((username) => scoreSearchField(query, tokens, username) * 1.24);
456
456
+
const bestUsernameScore = usernameScores.length > 0 ? Math.max(...usernameScores) : 0;
457
457
+
const identifierScore = scoreSearchField(query, tokens, mapping.bskyIdentifier) * 1.2;
458
458
+
const ownerScore = scoreSearchField(query, tokens, mapping.owner) * 0.92;
459
459
+
const groupScore = scoreSearchField(query, tokens, mapping.groupName) * 0.72;
460
460
+
const combined = [bestUsernameScore, identifierScore, ownerScore, groupScore];
461
461
+
const maxScore = Math.max(...combined);
462
462
+
return maxScore + (combined.reduce((total, value) => total + value, 0) - maxScore) * 0.24;
463
463
+
}
464
464
+
346
465
const textEncoder = new TextEncoder();
347
466
const textDecoder = new TextDecoder();
348
467
const compactNumberFormatter = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
···
468
587
return {};
469
588
}
470
589
});
590
590
+
const [accountsViewMode, setAccountsViewMode] = useState<'grouped' | 'global'>('grouped');
591
591
+
const [accountsSearchQuery, setAccountsSearchQuery] = useState('');
471
592
const [postsGroupFilter, setPostsGroupFilter] = useState('all');
593
593
+
const [postsSearchQuery, setPostsSearchQuery] = useState('');
594
594
+
const [localPostSearchResults, setLocalPostSearchResults] = useState<LocalPostSearchResult[]>([]);
595
595
+
const [isSearchingLocalPosts, setIsSearchingLocalPosts] = useState(false);
472
596
const [activityGroupFilter, setActivityGroupFilter] = useState('all');
597
597
+
const [groupDraftsByKey, setGroupDraftsByKey] = useState<Record<string, { name: string; emoji: string }>>({});
598
598
+
const [isGroupActionBusy, setIsGroupActionBusy] = useState(false);
473
599
const [notice, setNotice] = useState<Notice | null>(null);
474
600
475
601
const [isBusy, setIsBusy] = useState(false);
···
477
603
478
604
const noticeTimerRef = useRef<number | null>(null);
479
605
const importInputRef = useRef<HTMLInputElement>(null);
606
606
+
const postsSearchRequestRef = useRef(0);
480
607
481
608
const isAdmin = me?.isAdmin ?? false;
482
609
const authHeaders = useMemo(() => (token ? { Authorization: `Bearer ${token}` } : undefined), [token]);
···
509
636
setIsAddAccountSheetOpen(false);
510
637
setAddAccountStep(1);
511
638
setSettingsSectionOverrides({});
639
639
+
setAccountsViewMode('grouped');
640
640
+
setAccountsSearchQuery('');
641
641
+
setPostsSearchQuery('');
642
642
+
setLocalPostSearchResults([]);
643
643
+
setIsSearchingLocalPosts(false);
644
644
+
setGroupDraftsByKey({});
645
645
+
setIsGroupActionBusy(false);
646
646
+
postsSearchRequestRef.current = 0;
512
647
setAuthView('login');
513
648
}, []);
514
649
···
907
1042
),
908
1043
}));
909
1044
}, [groupOptions, mappings]);
1045
1045
+
const normalizedAccountsQuery = useMemo(() => normalizeSearchValue(accountsSearchQuery), [accountsSearchQuery]);
1046
1046
+
const accountSearchTokens = useMemo(() => tokenizeSearchValue(normalizedAccountsQuery), [normalizedAccountsQuery]);
1047
1047
+
const accountSearchScores = useMemo(() => {
1048
1048
+
const scores = new Map<string, number>();
1049
1049
+
if (!normalizedAccountsQuery) {
1050
1050
+
return scores;
1051
1051
+
}
1052
1052
+
1053
1053
+
for (const mapping of mappings) {
1054
1054
+
scores.set(mapping.id, scoreAccountMapping(mapping, normalizedAccountsQuery, accountSearchTokens));
1055
1055
+
}
1056
1056
+
return scores;
1057
1057
+
}, [accountSearchTokens, mappings, normalizedAccountsQuery]);
1058
1058
+
const filteredGroupedMappings = useMemo(() => {
1059
1059
+
const hasQuery = normalizedAccountsQuery.length > 0;
1060
1060
+
const sortByScore = (items: AccountMapping[]) => {
1061
1061
+
if (!hasQuery) {
1062
1062
+
return items;
1063
1063
+
}
1064
1064
+
return [...items].sort((a, b) => {
1065
1065
+
const scoreDelta = (accountSearchScores.get(b.id) || 0) - (accountSearchScores.get(a.id) || 0);
1066
1066
+
if (scoreDelta !== 0) return scoreDelta;
1067
1067
+
return `${(a.owner || '').toLowerCase()}-${a.bskyIdentifier.toLowerCase()}`.localeCompare(
1068
1068
+
`${(b.owner || '').toLowerCase()}-${b.bskyIdentifier.toLowerCase()}`,
1069
1069
+
);
1070
1070
+
});
1071
1071
+
};
1072
1072
+
1073
1073
+
const withSearch = groupedMappings
1074
1074
+
.map((group) => {
1075
1075
+
const mappingsForGroup = hasQuery
1076
1076
+
? group.mappings.filter((mapping) => (accountSearchScores.get(mapping.id) || 0) >= ACCOUNT_SEARCH_MIN_SCORE)
1077
1077
+
: group.mappings;
1078
1078
+
return {
1079
1079
+
...group,
1080
1080
+
mappings: sortByScore(mappingsForGroup),
1081
1081
+
};
1082
1082
+
})
1083
1083
+
.filter((group) => !hasQuery || group.mappings.length > 0);
1084
1084
+
1085
1085
+
if (accountsViewMode === 'grouped') {
1086
1086
+
return withSearch;
1087
1087
+
}
1088
1088
+
1089
1089
+
const allMappings = sortByScore(
1090
1090
+
hasQuery
1091
1091
+
? mappings.filter((mapping) => (accountSearchScores.get(mapping.id) || 0) >= ACCOUNT_SEARCH_MIN_SCORE)
1092
1092
+
: [...mappings].sort((a, b) =>
1093
1093
+
`${(a.owner || '').toLowerCase()}-${a.bskyIdentifier.toLowerCase()}`.localeCompare(
1094
1094
+
`${(b.owner || '').toLowerCase()}-${b.bskyIdentifier.toLowerCase()}`,
1095
1095
+
),
1096
1096
+
),
1097
1097
+
);
1098
1098
+
1099
1099
+
return [
1100
1100
+
{
1101
1101
+
key: '__all__',
1102
1102
+
name: hasQuery ? 'Search Results' : 'All Accounts',
1103
1103
+
emoji: hasQuery ? '🔎' : '🌐',
1104
1104
+
mappings: allMappings,
1105
1105
+
},
1106
1106
+
];
1107
1107
+
}, [accountSearchScores, accountsViewMode, groupedMappings, mappings, normalizedAccountsQuery]);
1108
1108
+
const accountMatchesCount = useMemo(
1109
1109
+
() => filteredGroupedMappings.reduce((total, group) => total + group.mappings.length, 0),
1110
1110
+
[filteredGroupedMappings],
1111
1111
+
);
1112
1112
+
const groupKeysForCollapse = useMemo(
1113
1113
+
() => groupedMappings.map((group) => group.key),
1114
1114
+
[groupedMappings],
1115
1115
+
);
1116
1116
+
const allGroupsCollapsed = useMemo(
1117
1117
+
() => groupKeysForCollapse.length > 0 && groupKeysForCollapse.every((groupKey) => collapsedGroupKeys[groupKey] === true),
1118
1118
+
[collapsedGroupKeys, groupKeysForCollapse],
1119
1119
+
);
1120
1120
+
const resolveMappingForLocalPost = useCallback(
1121
1121
+
(post: LocalPostSearchResult) =>
1122
1122
+
mappingsByBskyIdentifier.get(normalizeTwitterUsername(post.bskyIdentifier)) ||
1123
1123
+
mappingsByTwitterUsername.get(normalizeTwitterUsername(post.twitterUsername)),
1124
1124
+
[mappingsByBskyIdentifier, mappingsByTwitterUsername],
1125
1125
+
);
910
1126
const resolveMappingForPost = useCallback(
911
1127
(post: EnrichedPost) =>
912
1128
mappingsByBskyIdentifier.get(normalizeTwitterUsername(post.bskyIdentifier)) ||
···
928
1144
}),
929
1145
[postedActivity, postsGroupFilter, resolveMappingForPost],
930
1146
);
1147
1147
+
const filteredLocalPostSearchResults = useMemo(
1148
1148
+
() =>
1149
1149
+
localPostSearchResults.filter((post) => {
1150
1150
+
if (postsGroupFilter === 'all') return true;
1151
1151
+
const mapping = resolveMappingForLocalPost(post);
1152
1152
+
return getMappingGroupMeta(mapping).key === postsGroupFilter;
1153
1153
+
}),
1154
1154
+
[localPostSearchResults, postsGroupFilter, resolveMappingForLocalPost],
1155
1155
+
);
931
1156
const filteredRecentActivity = useMemo(
932
1157
() =>
933
1158
recentActivity.filter((activity) => {
···
967
1192
}
968
1193
}, [activityGroupFilter, groupOptions, postsGroupFilter]);
969
1194
1195
1195
+
useEffect(() => {
1196
1196
+
setGroupDraftsByKey((previous) => {
1197
1197
+
const next: Record<string, { name: string; emoji: string }> = {};
1198
1198
+
for (const group of reusableGroupOptions) {
1199
1199
+
const existing = previous[group.key];
1200
1200
+
next[group.key] = {
1201
1201
+
name: existing?.name ?? group.name,
1202
1202
+
emoji: existing?.emoji ?? group.emoji,
1203
1203
+
};
1204
1204
+
}
1205
1205
+
return next;
1206
1206
+
});
1207
1207
+
}, [reusableGroupOptions]);
1208
1208
+
1209
1209
+
useEffect(() => {
1210
1210
+
if (!authHeaders) {
1211
1211
+
setIsSearchingLocalPosts(false);
1212
1212
+
setLocalPostSearchResults([]);
1213
1213
+
return;
1214
1214
+
}
1215
1215
+
1216
1216
+
const query = postsSearchQuery.trim();
1217
1217
+
if (!query) {
1218
1218
+
postsSearchRequestRef.current += 1;
1219
1219
+
setIsSearchingLocalPosts(false);
1220
1220
+
setLocalPostSearchResults([]);
1221
1221
+
return;
1222
1222
+
}
1223
1223
+
1224
1224
+
const requestId = postsSearchRequestRef.current + 1;
1225
1225
+
postsSearchRequestRef.current = requestId;
1226
1226
+
setIsSearchingLocalPosts(true);
1227
1227
+
1228
1228
+
const timer = window.setTimeout(async () => {
1229
1229
+
try {
1230
1230
+
const response = await axios.get<LocalPostSearchResult[]>('/api/posts/search', {
1231
1231
+
params: { q: query, limit: 120 },
1232
1232
+
headers: authHeaders,
1233
1233
+
});
1234
1234
+
if (postsSearchRequestRef.current !== requestId) {
1235
1235
+
return;
1236
1236
+
}
1237
1237
+
setLocalPostSearchResults(Array.isArray(response.data) ? response.data : []);
1238
1238
+
} catch (error) {
1239
1239
+
if (postsSearchRequestRef.current !== requestId) {
1240
1240
+
return;
1241
1241
+
}
1242
1242
+
setLocalPostSearchResults([]);
1243
1243
+
handleAuthFailure(error, 'Failed to search local post history.');
1244
1244
+
} finally {
1245
1245
+
if (postsSearchRequestRef.current === requestId) {
1246
1246
+
setIsSearchingLocalPosts(false);
1247
1247
+
}
1248
1248
+
}
1249
1249
+
}, 220);
1250
1250
+
1251
1251
+
return () => {
1252
1252
+
window.clearTimeout(timer);
1253
1253
+
};
1254
1254
+
}, [authHeaders, handleAuthFailure, postsSearchQuery]);
1255
1255
+
970
1256
const isBackfillQueued = useCallback(
971
1257
(mappingId: string) => pendingBackfills.some((entry) => entry.id === mappingId),
972
1258
[pendingBackfills],
···
1193
1479
}));
1194
1480
};
1195
1481
1482
1482
+
const toggleCollapseAllGroups = () => {
1483
1483
+
const shouldCollapse = !allGroupsCollapsed;
1484
1484
+
setCollapsedGroupKeys((previous) => {
1485
1485
+
const next = { ...previous };
1486
1486
+
for (const groupKey of groupKeysForCollapse) {
1487
1487
+
next[groupKey] = shouldCollapse;
1488
1488
+
}
1489
1489
+
return next;
1490
1490
+
});
1491
1491
+
};
1492
1492
+
1196
1493
const handleCreateGroup = async (event: React.FormEvent<HTMLFormElement>) => {
1197
1494
event.preventDefault();
1198
1495
if (!authHeaders) {
···
1265
1562
}
1266
1563
};
1267
1564
1565
1565
+
const updateGroupDraft = (groupKey: string, field: 'name' | 'emoji', value: string) => {
1566
1566
+
setGroupDraftsByKey((previous) => ({
1567
1567
+
...previous,
1568
1568
+
[groupKey]: {
1569
1569
+
name: previous[groupKey]?.name ?? '',
1570
1570
+
emoji: previous[groupKey]?.emoji ?? '',
1571
1571
+
[field]: value,
1572
1572
+
},
1573
1573
+
}));
1574
1574
+
};
1575
1575
+
1576
1576
+
const handleRenameGroup = async (groupKey: string) => {
1577
1577
+
if (!authHeaders) {
1578
1578
+
return;
1579
1579
+
}
1580
1580
+
1581
1581
+
const draft = groupDraftsByKey[groupKey];
1582
1582
+
if (!draft || !draft.name.trim()) {
1583
1583
+
showNotice('error', 'Group name is required.');
1584
1584
+
return;
1585
1585
+
}
1586
1586
+
1587
1587
+
setIsGroupActionBusy(true);
1588
1588
+
try {
1589
1589
+
await axios.put(
1590
1590
+
`/api/groups/${encodeURIComponent(groupKey)}`,
1591
1591
+
{
1592
1592
+
name: draft.name.trim(),
1593
1593
+
emoji: draft.emoji.trim(),
1594
1594
+
},
1595
1595
+
{ headers: authHeaders },
1596
1596
+
);
1597
1597
+
showNotice('success', 'Group updated.');
1598
1598
+
await fetchData();
1599
1599
+
} catch (error) {
1600
1600
+
handleAuthFailure(error, 'Failed to update group.');
1601
1601
+
} finally {
1602
1602
+
setIsGroupActionBusy(false);
1603
1603
+
}
1604
1604
+
};
1605
1605
+
1606
1606
+
const handleDeleteGroup = async (groupKey: string) => {
1607
1607
+
if (!authHeaders) {
1608
1608
+
return;
1609
1609
+
}
1610
1610
+
1611
1611
+
const group = groupOptionsByKey.get(groupKey);
1612
1612
+
if (!group) {
1613
1613
+
showNotice('error', 'Group not found.');
1614
1614
+
return;
1615
1615
+
}
1616
1616
+
1617
1617
+
const confirmed = window.confirm(
1618
1618
+
`Delete "${group.name}"? Mappings in this folder will move to ${DEFAULT_GROUP_NAME}.`,
1619
1619
+
);
1620
1620
+
if (!confirmed) {
1621
1621
+
return;
1622
1622
+
}
1623
1623
+
1624
1624
+
setIsGroupActionBusy(true);
1625
1625
+
try {
1626
1626
+
const response = await axios.delete<{ reassignedCount?: number }>(`/api/groups/${encodeURIComponent(groupKey)}`, {
1627
1627
+
headers: authHeaders,
1628
1628
+
});
1629
1629
+
const reassignedCount = response.data?.reassignedCount || 0;
1630
1630
+
showNotice('success', `Group deleted. ${reassignedCount} account${reassignedCount === 1 ? '' : 's'} moved.`);
1631
1631
+
await fetchData();
1632
1632
+
} catch (error) {
1633
1633
+
handleAuthFailure(error, 'Failed to delete group.');
1634
1634
+
} finally {
1635
1635
+
setIsGroupActionBusy(false);
1636
1636
+
}
1637
1637
+
};
1638
1638
+
1268
1639
const resetAddAccountDraft = () => {
1269
1640
setNewMapping(defaultMappingForm());
1270
1641
setNewTwitterUsers([]);
···
1791
2162
})}
1792
2163
</CardContent>
1793
2164
</Card>
2165
2165
+
1794
2166
</section>
1795
2167
) : null}
1796
2168
···
1849
2221
</div>
1850
2222
</form>
1851
2223
1852
1852
-
{groupedMappings.length === 0 ? (
2224
2224
+
<div className="grid gap-2 md:grid-cols-[1fr_auto]">
2225
2225
+
<div className="space-y-1">
2226
2226
+
<Label htmlFor="accounts-search">Search accounts</Label>
2227
2227
+
<Input
2228
2228
+
id="accounts-search"
2229
2229
+
value={accountsSearchQuery}
2230
2230
+
onChange={(event) => setAccountsSearchQuery(event.target.value)}
2231
2231
+
placeholder="Find by @username, owner, Bluesky handle, or folder"
2232
2232
+
/>
2233
2233
+
{normalizedAccountsQuery ? (
2234
2234
+
<p className="text-xs text-muted-foreground">
2235
2235
+
{accountMatchesCount} result{accountMatchesCount === 1 ? '' : 's'} ranked by relevance
2236
2236
+
</p>
2237
2237
+
) : null}
2238
2238
+
</div>
2239
2239
+
<div className="flex flex-wrap items-end justify-end gap-2">
2240
2240
+
{accountsViewMode === 'grouped' ? (
2241
2241
+
<Button
2242
2242
+
size="sm"
2243
2243
+
variant="outline"
2244
2244
+
onClick={toggleCollapseAllGroups}
2245
2245
+
disabled={groupKeysForCollapse.length === 0}
2246
2246
+
>
2247
2247
+
{allGroupsCollapsed ? 'Expand all' : 'Collapse all'}
2248
2248
+
</Button>
2249
2249
+
) : null}
2250
2250
+
<Button
2251
2251
+
size="sm"
2252
2252
+
variant="outline"
2253
2253
+
onClick={() => setAccountsViewMode((previous) => (previous === 'grouped' ? 'global' : 'grouped'))}
2254
2254
+
>
2255
2255
+
{accountsViewMode === 'grouped' ? 'View all' : 'Grouped view'}
2256
2256
+
</Button>
2257
2257
+
</div>
2258
2258
+
</div>
2259
2259
+
2260
2260
+
{filteredGroupedMappings.length === 0 ? (
1853
2261
<div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
1854
1854
-
No mappings yet.
2262
2262
+
{normalizedAccountsQuery ? 'No accounts matched this search.' : 'No mappings yet.'}
1855
2263
{isAdmin ? (
1856
2264
<div className="mt-3">
1857
2265
<Button size="sm" variant="outline" onClick={openAddAccountSheet}>
···
1863
2271
</div>
1864
2272
) : (
1865
2273
<div className="space-y-3">
1866
1866
-
{groupedMappings.map((group, groupIndex) => {
1867
1867
-
const collapsed = collapsedGroupKeys[group.key] === true;
2274
2274
+
{filteredGroupedMappings.map((group, groupIndex) => {
2275
2275
+
const canCollapseGroup = accountsViewMode === 'grouped';
2276
2276
+
const collapsed = canCollapseGroup ? collapsedGroupKeys[group.key] === true : false;
1868
2277
1869
2278
return (
1870
2279
<div
···
1873
2282
style={{ animationDelay: `${Math.min(groupIndex * 45, 220)}ms` }}
1874
2283
>
1875
2284
<button
1876
1876
-
className="group flex w-full items-center justify-between bg-muted/40 px-3 py-2 text-left transition-[background-color,padding] duration-200 hover:bg-muted/70"
1877
1877
-
onClick={() => toggleGroupCollapsed(group.key)}
2285
2285
+
className={cn(
2286
2286
+
'group flex w-full items-center justify-between bg-muted/40 px-3 py-2 text-left transition-[background-color,padding] duration-200',
2287
2287
+
canCollapseGroup ? 'hover:bg-muted/70' : '',
2288
2288
+
)}
2289
2289
+
onClick={() => {
2290
2290
+
if (canCollapseGroup) {
2291
2291
+
toggleGroupCollapsed(group.key);
2292
2292
+
}
2293
2293
+
}}
1878
2294
type="button"
1879
2295
>
1880
2296
<div className="flex items-center gap-2">
···
1883
2299
<span className="font-medium">{group.name}</span>
1884
2300
<Badge variant="outline">{group.mappings.length}</Badge>
1885
2301
</div>
1886
1886
-
<ChevronDown
1887
1887
-
className={cn(
1888
1888
-
'h-4 w-4 transition-transform duration-200 motion-reduce:transition-none',
1889
1889
-
collapsed ? '-rotate-90' : 'rotate-0',
1890
1890
-
)}
1891
1891
-
/>
2302
2302
+
{canCollapseGroup ? (
2303
2303
+
<ChevronDown
2304
2304
+
className={cn(
2305
2305
+
'h-4 w-4 transition-transform duration-200 motion-reduce:transition-none',
2306
2306
+
collapsed ? '-rotate-90' : 'rotate-0',
2307
2307
+
)}
2308
2308
+
/>
2309
2309
+
) : null}
1892
2310
</button>
1893
2311
1894
2312
<div
···
2052
2470
)}
2053
2471
</CardContent>
2054
2472
</Card>
2473
2473
+
2474
2474
+
<Card className="animate-slide-up">
2475
2475
+
<CardHeader className="pb-3">
2476
2476
+
<CardTitle>Group Manager</CardTitle>
2477
2477
+
<CardDescription>Edit folder names/emojis or delete a group.</CardDescription>
2478
2478
+
</CardHeader>
2479
2479
+
<CardContent className="pt-0">
2480
2480
+
{reusableGroupOptions.length === 0 ? (
2481
2481
+
<div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
2482
2482
+
No custom folders yet.
2483
2483
+
</div>
2484
2484
+
) : (
2485
2485
+
<div className="space-y-2">
2486
2486
+
{reusableGroupOptions.map((group) => {
2487
2487
+
const draft = groupDraftsByKey[group.key] || { name: group.name, emoji: group.emoji };
2488
2488
+
return (
2489
2489
+
<div
2490
2490
+
key={`group-manager-${group.key}`}
2491
2491
+
className="grid gap-2 rounded-lg border border-border/70 bg-muted/20 p-3 md:grid-cols-[90px_minmax(0,1fr)_auto_auto]"
2492
2492
+
>
2493
2493
+
<div className="space-y-1">
2494
2494
+
<Label htmlFor={`group-manager-emoji-${group.key}`}>Emoji</Label>
2495
2495
+
<Input
2496
2496
+
id={`group-manager-emoji-${group.key}`}
2497
2497
+
value={draft.emoji}
2498
2498
+
onChange={(event) => updateGroupDraft(group.key, 'emoji', event.target.value)}
2499
2499
+
maxLength={8}
2500
2500
+
/>
2501
2501
+
</div>
2502
2502
+
<div className="space-y-1">
2503
2503
+
<Label htmlFor={`group-manager-name-${group.key}`}>Name</Label>
2504
2504
+
<Input
2505
2505
+
id={`group-manager-name-${group.key}`}
2506
2506
+
value={draft.name}
2507
2507
+
onChange={(event) => updateGroupDraft(group.key, 'name', event.target.value)}
2508
2508
+
/>
2509
2509
+
</div>
2510
2510
+
<Button
2511
2511
+
variant="outline"
2512
2512
+
size="sm"
2513
2513
+
className="self-end"
2514
2514
+
disabled={isGroupActionBusy || !draft.name.trim()}
2515
2515
+
onClick={() => {
2516
2516
+
void handleRenameGroup(group.key);
2517
2517
+
}}
2518
2518
+
>
2519
2519
+
Save
2520
2520
+
</Button>
2521
2521
+
<Button
2522
2522
+
variant="ghost"
2523
2523
+
size="sm"
2524
2524
+
className="self-end text-red-600 hover:text-red-500 dark:text-red-300 dark:hover:text-red-200"
2525
2525
+
disabled={isGroupActionBusy}
2526
2526
+
onClick={() => {
2527
2527
+
void handleDeleteGroup(group.key);
2528
2528
+
}}
2529
2529
+
>
2530
2530
+
Delete
2531
2531
+
</Button>
2532
2532
+
</div>
2533
2533
+
);
2534
2534
+
})}
2535
2535
+
</div>
2536
2536
+
)}
2537
2537
+
<p className="mt-3 text-xs text-muted-foreground">
2538
2538
+
Deleting a folder keeps mappings intact and moves them to {DEFAULT_GROUP_NAME}.
2539
2539
+
</p>
2540
2540
+
</CardContent>
2541
2541
+
</Card>
2055
2542
</section>
2056
2543
) : null}
2057
2544
···
2062
2549
<div className="flex flex-wrap items-center justify-between gap-3">
2063
2550
<div className="space-y-1">
2064
2551
<CardTitle>Already Posted</CardTitle>
2065
2065
-
<CardDescription>Native-styled feed of successfully posted Bluesky entries.</CardDescription>
2552
2552
+
<CardDescription>
2553
2553
+
Native-styled feed plus local SQLite search across all crossposted history.
2554
2554
+
</CardDescription>
2066
2555
</div>
2067
2067
-
<div className="w-full max-w-xs">
2068
2068
-
<Label htmlFor="posts-group-filter">Filter group</Label>
2069
2069
-
<select
2070
2070
-
id="posts-group-filter"
2071
2071
-
className={selectClassName}
2072
2072
-
value={postsGroupFilter}
2073
2073
-
onChange={(event) => setPostsGroupFilter(event.target.value)}
2074
2074
-
>
2075
2075
-
<option value="all">All folders</option>
2076
2076
-
{groupOptions.map((group) => (
2077
2077
-
<option key={`posts-filter-${group.key}`} value={group.key}>
2078
2078
-
{group.emoji} {group.name}
2079
2079
-
</option>
2080
2080
-
))}
2081
2081
-
</select>
2556
2556
+
<div className="grid w-full gap-2 md:max-w-2xl md:grid-cols-[1fr_240px]">
2557
2557
+
<div className="space-y-1">
2558
2558
+
<Label htmlFor="posts-search">Search crossposted posts</Label>
2559
2559
+
<div className="relative">
2560
2560
+
<Input
2561
2561
+
id="posts-search"
2562
2562
+
value={postsSearchQuery}
2563
2563
+
onChange={(event) => setPostsSearchQuery(event.target.value)}
2564
2564
+
placeholder="Search by text, @username, tweet id, or Bluesky handle"
2565
2565
+
/>
2566
2566
+
{isSearchingLocalPosts ? (
2567
2567
+
<Loader2 className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />
2568
2568
+
) : null}
2569
2569
+
</div>
2570
2570
+
</div>
2571
2571
+
<div className="space-y-1">
2572
2572
+
<Label htmlFor="posts-group-filter">Filter group</Label>
2573
2573
+
<select
2574
2574
+
id="posts-group-filter"
2575
2575
+
className={selectClassName}
2576
2576
+
value={postsGroupFilter}
2577
2577
+
onChange={(event) => setPostsGroupFilter(event.target.value)}
2578
2578
+
>
2579
2579
+
<option value="all">All folders</option>
2580
2580
+
{groupOptions.map((group) => (
2581
2581
+
<option key={`posts-filter-${group.key}`} value={group.key}>
2582
2582
+
{group.emoji} {group.name}
2583
2583
+
</option>
2584
2584
+
))}
2585
2585
+
</select>
2586
2586
+
</div>
2082
2587
</div>
2083
2588
</div>
2084
2589
</CardHeader>
2085
2590
<CardContent className="pt-0">
2086
2086
-
{filteredPostedActivity.length === 0 ? (
2591
2591
+
{postsSearchQuery.trim() ? (
2592
2592
+
filteredLocalPostSearchResults.length === 0 ? (
2593
2593
+
<div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
2594
2594
+
{isSearchingLocalPosts ? 'Searching local history...' : 'No local crossposted posts matched.'}
2595
2595
+
</div>
2596
2596
+
) : (
2597
2597
+
<div className="space-y-2">
2598
2598
+
{filteredLocalPostSearchResults.map((post) => {
2599
2599
+
const mapping = resolveMappingForLocalPost(post);
2600
2600
+
const groupMeta = getMappingGroupMeta(mapping);
2601
2601
+
const sourceTweetUrl = post.twitterUrl || getTwitterPostUrl(post.twitterUsername, post.twitterId);
2602
2602
+
const postUrl =
2603
2603
+
post.postUrl ||
2604
2604
+
(post.bskyUri
2605
2605
+
? `https://bsky.app/profile/${post.bskyIdentifier}/post/${post.bskyUri
2606
2606
+
.split('/')
2607
2607
+
.filter(Boolean)
2608
2608
+
.pop() || ''}`
2609
2609
+
: undefined);
2610
2610
+
2611
2611
+
return (
2612
2612
+
<article
2613
2613
+
key={`${post.twitterId}-${post.bskyIdentifier}-${post.bskyCid || post.createdAt || 'result'}`}
2614
2614
+
className="rounded-xl border border-border/70 bg-background/80 p-4 shadow-sm"
2615
2615
+
>
2616
2616
+
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
2617
2617
+
<div className="min-w-0">
2618
2618
+
<p className="truncate text-sm font-semibold">
2619
2619
+
@{post.bskyIdentifier} <span className="text-muted-foreground">from @{post.twitterUsername}</span>
2620
2620
+
</p>
2621
2621
+
<p className="text-xs text-muted-foreground">
2622
2622
+
{post.createdAt ? new Date(post.createdAt).toLocaleString() : 'Unknown time'}
2623
2623
+
</p>
2624
2624
+
</div>
2625
2625
+
<div className="flex items-center gap-2">
2626
2626
+
<Badge variant="outline">
2627
2627
+
{groupMeta.emoji} {groupMeta.name}
2628
2628
+
</Badge>
2629
2629
+
<Badge variant="secondary">Relevance {Math.round(post.score)}</Badge>
2630
2630
+
</div>
2631
2631
+
</div>
2632
2632
+
<p className="mb-2 whitespace-pre-wrap break-words text-sm leading-relaxed">
2633
2633
+
{post.tweetText || 'No local tweet text stored for this record.'}
2634
2634
+
</p>
2635
2635
+
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
2636
2636
+
<span className="font-mono">Tweet ID: {post.twitterId}</span>
2637
2637
+
{sourceTweetUrl ? (
2638
2638
+
<a
2639
2639
+
className="inline-flex items-center text-foreground underline-offset-4 hover:underline"
2640
2640
+
href={sourceTweetUrl}
2641
2641
+
target="_blank"
2642
2642
+
rel="noreferrer"
2643
2643
+
>
2644
2644
+
Source
2645
2645
+
<ArrowUpRight className="ml-1 h-3 w-3" />
2646
2646
+
</a>
2647
2647
+
) : null}
2648
2648
+
{postUrl ? (
2649
2649
+
<a
2650
2650
+
className="inline-flex items-center text-foreground underline-offset-4 hover:underline"
2651
2651
+
href={postUrl}
2652
2652
+
target="_blank"
2653
2653
+
rel="noreferrer"
2654
2654
+
>
2655
2655
+
Bluesky
2656
2656
+
<ArrowUpRight className="ml-1 h-3 w-3" />
2657
2657
+
</a>
2658
2658
+
) : null}
2659
2659
+
</div>
2660
2660
+
</article>
2661
2661
+
);
2662
2662
+
})}
2663
2663
+
</div>
2664
2664
+
)
2665
2665
+
) : filteredPostedActivity.length === 0 ? (
2087
2666
<div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
2088
2667
No posted entries yet.
2089
2668
</div>