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.

feat: upgrade dashboard UX with grouped accounts, rich post links, and smooth motion

jack 4a6ef9d9 e28e6abb

+1693 -233
+27
src/cli.ts
··· 283 283 message: 'Bluesky service URL:', 284 284 default: 'https://bsky.social', 285 285 }, 286 + { 287 + type: 'input', 288 + name: 'groupName', 289 + message: 'Group/folder name (optional):', 290 + }, 291 + { 292 + type: 'input', 293 + name: 'groupEmoji', 294 + message: 'Group emoji icon (optional):', 295 + }, 286 296 ]); 287 297 288 298 const usernames = answers.twitterUsernames ··· 296 306 bskyIdentifier: answers.bskyIdentifier, 297 307 bskyPassword: answers.bskyPassword, 298 308 bskyServiceUrl: answers.bskyServiceUrl, 309 + groupName: answers.groupName?.trim() || undefined, 310 + groupEmoji: answers.groupEmoji?.trim() || undefined, 299 311 }); 300 312 console.log('Mapping added successfully.'); 301 313 }); ··· 338 350 message: 'Bluesky service URL:', 339 351 default: mapping.bskyServiceUrl || 'https://bsky.social', 340 352 }, 353 + { 354 + type: 'input', 355 + name: 'groupName', 356 + message: 'Group/folder name (optional):', 357 + default: mapping.groupName || '', 358 + }, 359 + { 360 + type: 'input', 361 + name: 'groupEmoji', 362 + message: 'Group emoji icon (optional):', 363 + default: mapping.groupEmoji || '', 364 + }, 341 365 ]); 342 366 343 367 const usernames = answers.twitterUsernames ··· 357 381 twitterUsernames: usernames, 358 382 bskyIdentifier: answers.bskyIdentifier, 359 383 bskyServiceUrl: answers.bskyServiceUrl, 384 + groupName: answers.groupName?.trim() || undefined, 385 + groupEmoji: answers.groupEmoji?.trim() || undefined, 360 386 }; 361 387 362 388 if (answers.bskyPassword && answers.bskyPassword.trim().length > 0) { ··· 384 410 owner: mapping.owner || 'System', 385 411 twitter: mapping.twitterUsernames.join(', '), 386 412 bsky: mapping.bskyIdentifier, 413 + group: `${mapping.groupEmoji || '📁'} ${mapping.groupName || 'Ungrouped'}`, 387 414 enabled: mapping.enabled, 388 415 })), 389 416 );
+2
src/config-manager.ts
··· 34 34 bskyServiceUrl?: string; 35 35 enabled: boolean; 36 36 owner?: string; 37 + groupName?: string; 38 + groupEmoji?: string; 37 39 } 38 40 39 41 export interface AppConfig {
+2 -2
src/db.ts
··· 224 224 }, 225 225 226 226 getRecentProcessedTweets(limit = 50): ProcessedTweet[] { 227 - const stmt = db.prepare('SELECT * FROM processed_tweets ORDER BY created_at DESC LIMIT ?'); 227 + const stmt = db.prepare('SELECT * FROM processed_tweets ORDER BY datetime(created_at) DESC, rowid DESC LIMIT ?'); 228 228 return stmt.all(limit) as ProcessedTweet[]; 229 229 }, 230 230 ··· 248 248 clearAll() { 249 249 db.prepare('DELETE FROM processed_tweets').run(); 250 250 }, 251 - }; 251 + };
+346 -2
src/server.ts
··· 1 1 import fs from 'node:fs'; 2 2 import path from 'node:path'; 3 3 import { fileURLToPath } from 'node:url'; 4 + import axios from 'axios'; 4 5 import bcrypt from 'bcryptjs'; 5 6 import cors from 'cors'; 6 7 import express from 'express'; ··· 8 9 import { deleteAllPosts } from './bsky.js'; 9 10 import { getConfig, saveConfig } from './config-manager.js'; 10 11 import { dbService } from './db.js'; 12 + import type { ProcessedTweet } from './db.js'; 11 13 12 14 const __filename = fileURLToPath(import.meta.url); 13 15 const __dirname = path.dirname(__filename); ··· 18 20 const WEB_DIST_DIR = path.join(__dirname, '..', 'web', 'dist'); 19 21 const LEGACY_PUBLIC_DIR = path.join(__dirname, '..', 'public'); 20 22 const staticAssetsDir = fs.existsSync(path.join(WEB_DIST_DIR, 'index.html')) ? WEB_DIST_DIR : LEGACY_PUBLIC_DIR; 23 + const BSKY_APPVIEW_URL = process.env.BSKY_APPVIEW_URL || 'https://public.api.bsky.app'; 24 + const POST_VIEW_CACHE_TTL_MS = 60_000; 25 + const PROFILE_CACHE_TTL_MS = 5 * 60_000; 26 + 27 + interface CacheEntry<T> { 28 + value: T; 29 + expiresAt: number; 30 + } 31 + 32 + interface BskyProfileView { 33 + did?: string; 34 + handle?: string; 35 + displayName?: string; 36 + avatar?: string; 37 + } 38 + 39 + interface EnrichedPostMedia { 40 + type: 'image' | 'video' | 'external'; 41 + url?: string; 42 + thumb?: string; 43 + alt?: string; 44 + width?: number; 45 + height?: number; 46 + title?: string; 47 + description?: string; 48 + } 49 + 50 + interface EnrichedPost { 51 + bskyUri: string; 52 + bskyCid?: string; 53 + bskyIdentifier: string; 54 + twitterId: string; 55 + twitterUsername: string; 56 + twitterUrl?: string; 57 + postUrl?: string; 58 + createdAt?: string; 59 + text: string; 60 + facets: unknown[]; 61 + author: { 62 + did?: string; 63 + handle: string; 64 + displayName?: string; 65 + avatar?: string; 66 + }; 67 + stats: { 68 + likes: number; 69 + reposts: number; 70 + replies: number; 71 + quotes: number; 72 + engagement: number; 73 + }; 74 + media: EnrichedPostMedia[]; 75 + } 76 + 77 + const postViewCache = new Map<string, CacheEntry<any>>(); 78 + const profileCache = new Map<string, CacheEntry<BskyProfileView>>(); 79 + 80 + function chunkArray<T>(items: T[], size: number): T[][] { 81 + if (size <= 0) return [items]; 82 + const chunks: T[][] = []; 83 + for (let i = 0; i < items.length; i += size) { 84 + chunks.push(items.slice(i, i + size)); 85 + } 86 + return chunks; 87 + } 88 + 89 + function nowMs() { 90 + return Date.now(); 91 + } 92 + 93 + function buildPostUrl(identifier: string, uri?: string): string | undefined { 94 + if (!uri) return undefined; 95 + const rkey = uri.split('/').filter(Boolean).pop(); 96 + if (!rkey) return undefined; 97 + return `https://bsky.app/profile/${identifier}/post/${rkey}`; 98 + } 99 + 100 + function buildTwitterPostUrl(username: string, twitterId: string): string | undefined { 101 + if (!username || !twitterId) return undefined; 102 + return `https://x.com/${normalizeActor(username)}/status/${twitterId}`; 103 + } 104 + 105 + function normalizeActor(actor: string): string { 106 + return actor.trim().replace(/^@/, '').toLowerCase(); 107 + } 108 + 109 + function extractMediaFromEmbed(embed: any): EnrichedPostMedia[] { 110 + if (!embed || typeof embed !== 'object') { 111 + return []; 112 + } 113 + 114 + const type = embed.$type; 115 + if (type === 'app.bsky.embed.images#view') { 116 + const images = Array.isArray(embed.images) ? embed.images : []; 117 + return images.map((image: any) => ({ 118 + type: 'image' as const, 119 + url: typeof image.fullsize === 'string' ? image.fullsize : undefined, 120 + thumb: typeof image.thumb === 'string' ? image.thumb : undefined, 121 + alt: typeof image.alt === 'string' ? image.alt : undefined, 122 + width: typeof image.aspectRatio?.width === 'number' ? image.aspectRatio.width : undefined, 123 + height: typeof image.aspectRatio?.height === 'number' ? image.aspectRatio.height : undefined, 124 + })); 125 + } 126 + 127 + if (type === 'app.bsky.embed.video#view') { 128 + return [ 129 + { 130 + type: 'video', 131 + url: typeof embed.playlist === 'string' ? embed.playlist : undefined, 132 + thumb: typeof embed.thumbnail === 'string' ? embed.thumbnail : undefined, 133 + alt: typeof embed.alt === 'string' ? embed.alt : undefined, 134 + width: typeof embed.aspectRatio?.width === 'number' ? embed.aspectRatio.width : undefined, 135 + height: typeof embed.aspectRatio?.height === 'number' ? embed.aspectRatio.height : undefined, 136 + }, 137 + ]; 138 + } 139 + 140 + if (type === 'app.bsky.embed.external#view') { 141 + const external = embed.external || {}; 142 + return [ 143 + { 144 + type: 'external', 145 + url: typeof external.uri === 'string' ? external.uri : undefined, 146 + thumb: typeof external.thumb === 'string' ? external.thumb : undefined, 147 + title: typeof external.title === 'string' ? external.title : undefined, 148 + description: typeof external.description === 'string' ? external.description : undefined, 149 + }, 150 + ]; 151 + } 152 + 153 + if (type === 'app.bsky.embed.recordWithMedia#view') { 154 + return extractMediaFromEmbed(embed.media); 155 + } 156 + 157 + return []; 158 + } 159 + 160 + async function fetchPostViewsByUri(uris: string[]): Promise<Map<string, any>> { 161 + const result = new Map<string, any>(); 162 + const uniqueUris = [...new Set(uris.filter((uri) => typeof uri === 'string' && uri.length > 0))]; 163 + const pendingUris: string[] = []; 164 + 165 + for (const uri of uniqueUris) { 166 + const cached = postViewCache.get(uri); 167 + if (cached && cached.expiresAt > nowMs()) { 168 + result.set(uri, cached.value); 169 + continue; 170 + } 171 + pendingUris.push(uri); 172 + } 173 + 174 + for (const chunk of chunkArray(pendingUris, 25)) { 175 + if (chunk.length === 0) continue; 176 + const params = new URLSearchParams(); 177 + for (const uri of chunk) params.append('uris', uri); 178 + 179 + try { 180 + const response = await axios.get(`${BSKY_APPVIEW_URL}/xrpc/app.bsky.feed.getPosts?${params.toString()}`, { 181 + timeout: 12_000, 182 + }); 183 + const posts = Array.isArray(response.data?.posts) ? response.data.posts : []; 184 + for (const post of posts) { 185 + const uri = typeof post?.uri === 'string' ? post.uri : undefined; 186 + if (!uri) continue; 187 + postViewCache.set(uri, { 188 + value: post, 189 + expiresAt: nowMs() + POST_VIEW_CACHE_TTL_MS, 190 + }); 191 + result.set(uri, post); 192 + } 193 + } catch (error) { 194 + console.warn('Failed to fetch post views from Bluesky appview:', error); 195 + } 196 + } 197 + 198 + return result; 199 + } 200 + 201 + async function fetchProfilesByActor(actors: string[]): Promise<Record<string, BskyProfileView>> { 202 + const uniqueActors = [...new Set(actors.map(normalizeActor).filter((actor) => actor.length > 0))]; 203 + const result: Record<string, BskyProfileView> = {}; 204 + const pendingActors: string[] = []; 205 + 206 + for (const actor of uniqueActors) { 207 + const cached = profileCache.get(actor); 208 + if (cached && cached.expiresAt > nowMs()) { 209 + result[actor] = cached.value; 210 + continue; 211 + } 212 + pendingActors.push(actor); 213 + } 214 + 215 + for (const chunk of chunkArray(pendingActors, 25)) { 216 + if (chunk.length === 0) continue; 217 + const params = new URLSearchParams(); 218 + for (const actor of chunk) params.append('actors', actor); 219 + 220 + try { 221 + const response = await axios.get(`${BSKY_APPVIEW_URL}/xrpc/app.bsky.actor.getProfiles?${params.toString()}`, { 222 + timeout: 12_000, 223 + }); 224 + const profiles = Array.isArray(response.data?.profiles) ? response.data.profiles : []; 225 + for (const profile of profiles) { 226 + const view: BskyProfileView = { 227 + did: typeof profile?.did === 'string' ? profile.did : undefined, 228 + handle: typeof profile?.handle === 'string' ? profile.handle : undefined, 229 + displayName: typeof profile?.displayName === 'string' ? profile.displayName : undefined, 230 + avatar: typeof profile?.avatar === 'string' ? profile.avatar : undefined, 231 + }; 232 + 233 + const keys = [ 234 + typeof view.handle === 'string' ? normalizeActor(view.handle) : '', 235 + typeof view.did === 'string' ? normalizeActor(view.did) : '', 236 + ].filter((key) => key.length > 0); 237 + 238 + for (const key of keys) { 239 + profileCache.set(key, { value: view, expiresAt: nowMs() + PROFILE_CACHE_TTL_MS }); 240 + result[key] = view; 241 + } 242 + } 243 + } catch (error) { 244 + console.warn('Failed to fetch profiles from Bluesky appview:', error); 245 + } 246 + } 247 + 248 + for (const actor of uniqueActors) { 249 + const cached = profileCache.get(actor); 250 + if (cached && cached.expiresAt > nowMs()) { 251 + result[actor] = cached.value; 252 + } 253 + } 254 + 255 + return result; 256 + } 257 + 258 + function buildEnrichedPost(activity: ProcessedTweet, postView: any): EnrichedPost { 259 + const record = postView?.record || {}; 260 + const author = postView?.author || {}; 261 + const likes = Number(postView?.likeCount) || 0; 262 + const reposts = Number(postView?.repostCount) || 0; 263 + const replies = Number(postView?.replyCount) || 0; 264 + const quotes = Number(postView?.quoteCount) || 0; 265 + 266 + const identifier = 267 + (typeof activity.bsky_identifier === 'string' && activity.bsky_identifier.length > 0 268 + ? activity.bsky_identifier 269 + : typeof author.handle === 'string' 270 + ? author.handle 271 + : 'unknown') || 'unknown'; 272 + 273 + return { 274 + bskyUri: activity.bsky_uri || '', 275 + bskyCid: typeof postView?.cid === 'string' ? postView.cid : activity.bsky_cid, 276 + bskyIdentifier: identifier, 277 + twitterId: activity.twitter_id, 278 + twitterUsername: activity.twitter_username, 279 + twitterUrl: buildTwitterPostUrl(activity.twitter_username, activity.twitter_id), 280 + postUrl: buildPostUrl(identifier, activity.bsky_uri), 281 + createdAt: 282 + (typeof record.createdAt === 'string' ? record.createdAt : undefined) || 283 + activity.created_at || 284 + (typeof postView?.indexedAt === 'string' ? postView.indexedAt : undefined), 285 + text: 286 + (typeof record.text === 'string' ? record.text : undefined) || 287 + activity.tweet_text || 288 + `Tweet ID: ${activity.twitter_id}`, 289 + facets: Array.isArray(record.facets) ? record.facets : [], 290 + author: { 291 + did: typeof author.did === 'string' ? author.did : undefined, 292 + handle: 293 + typeof author.handle === 'string' && author.handle.length > 0 ? author.handle : activity.bsky_identifier, 294 + displayName: typeof author.displayName === 'string' ? author.displayName : undefined, 295 + avatar: typeof author.avatar === 'string' ? author.avatar : undefined, 296 + }, 297 + stats: { 298 + likes, 299 + reposts, 300 + replies, 301 + quotes, 302 + engagement: likes + reposts + replies + quotes, 303 + }, 304 + media: extractMediaFromEmbed(postView?.embed), 305 + }; 306 + } 21 307 22 308 // In-memory state for triggers and scheduling 23 309 let lastCheckTime = Date.now(); ··· 121 407 }); 122 408 123 409 app.post('/api/mappings', authenticateToken, (req, res) => { 124 - const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner } = req.body; 410 + const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner, groupName, groupEmoji } = req.body; 125 411 const config = getConfig(); 126 412 127 413 // Handle both array and comma-separated string ··· 134 420 .map((u) => u.trim()) 135 421 .filter((u) => u.length > 0); 136 422 } 423 + 424 + const normalizedGroupName = typeof groupName === 'string' ? groupName.trim() : ''; 425 + const normalizedGroupEmoji = typeof groupEmoji === 'string' ? groupEmoji.trim() : ''; 137 426 138 427 const newMapping = { 139 428 id: Math.random().toString(36).substring(7), ··· 143 432 bskyServiceUrl: bskyServiceUrl || 'https://bsky.social', 144 433 enabled: true, 145 434 owner, 435 + groupName: normalizedGroupName || undefined, 436 + groupEmoji: normalizedGroupEmoji || undefined, 146 437 }; 147 438 148 439 config.mappings.push(newMapping); ··· 152 443 153 444 app.put('/api/mappings/:id', authenticateToken, (req, res) => { 154 445 const { id } = req.params; 155 - const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner } = req.body; 446 + const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner, groupName, groupEmoji } = req.body; 156 447 const config = getConfig(); 157 448 158 449 const index = config.mappings.findIndex((m) => m.id === id); ··· 175 466 } 176 467 } 177 468 469 + let nextGroupName = existingMapping.groupName; 470 + if (groupName !== undefined) { 471 + const normalizedGroupName = typeof groupName === 'string' ? groupName.trim() : ''; 472 + nextGroupName = normalizedGroupName || undefined; 473 + } 474 + 475 + let nextGroupEmoji = existingMapping.groupEmoji; 476 + if (groupEmoji !== undefined) { 477 + const normalizedGroupEmoji = typeof groupEmoji === 'string' ? groupEmoji.trim() : ''; 478 + nextGroupEmoji = normalizedGroupEmoji || undefined; 479 + } 480 + 178 481 const updatedMapping = { 179 482 ...existingMapping, 180 483 twitterUsernames: usernames, ··· 183 486 bskyPassword: bskyPassword || existingMapping.bskyPassword, 184 487 bskyServiceUrl: bskyServiceUrl || existingMapping.bskyServiceUrl, 185 488 owner: owner || existingMapping.owner, 489 + groupName: nextGroupName, 490 + groupEmoji: nextGroupEmoji, 186 491 }; 187 492 188 493 config.mappings[index] = updatedMapping; ··· 409 714 const limit = req.query.limit ? Number(req.query.limit) : 50; 410 715 const tweets = dbService.getRecentProcessedTweets(limit); 411 716 res.json(tweets); 717 + }); 718 + 719 + app.post('/api/bsky/profiles', authenticateToken, async (req, res) => { 720 + const actors = Array.isArray(req.body?.actors) 721 + ? req.body.actors.filter((actor: unknown) => typeof actor === 'string') 722 + : []; 723 + 724 + if (actors.length === 0) { 725 + res.json({}); 726 + return; 727 + } 728 + 729 + const limitedActors = actors.slice(0, 200); 730 + const profiles = await fetchProfilesByActor(limitedActors); 731 + res.json(profiles); 732 + }); 733 + 734 + app.get('/api/posts/enriched', authenticateToken, async (req, res) => { 735 + const requestedLimit = req.query.limit ? Number(req.query.limit) : 24; 736 + const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 80)) : 24; 737 + 738 + const recent = dbService.getRecentProcessedTweets(limit * 4); 739 + const migratedWithUri = recent.filter((row) => row.status === 'migrated' && row.bsky_uri); 740 + 741 + const deduped: ProcessedTweet[] = []; 742 + const seenUris = new Set<string>(); 743 + for (const row of migratedWithUri) { 744 + const uri = row.bsky_uri; 745 + if (!uri || seenUris.has(uri)) continue; 746 + seenUris.add(uri); 747 + deduped.push(row); 748 + if (deduped.length >= limit) break; 749 + } 750 + 751 + const uris = deduped.map((row) => row.bsky_uri).filter((uri): uri is string => typeof uri === 'string'); 752 + const postViewsByUri = await fetchPostViewsByUri(uris); 753 + const enriched = deduped.map((row) => buildEnrichedPost(row, row.bsky_uri ? postViewsByUri.get(row.bsky_uri) : null)); 754 + 755 + res.json(enriched); 412 756 }); 413 757 414 758 // Export for use by index.ts
+48 -34
update.sh
··· 1 1 #!/bin/bash 2 2 3 - set -e 3 + set -euo pipefail 4 + 5 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 6 + cd "$SCRIPT_DIR" 4 7 5 8 echo "🔄 Tweets-2-Bsky Updater" 6 9 echo "=========================" 7 10 8 - CONFIG_FILE="config.json" 11 + CONFIG_FILE="$SCRIPT_DIR/config.json" 9 12 CONFIG_BACKUP="" 10 13 11 14 if [ -f "$CONFIG_FILE" ]; then 12 - CONFIG_BACKUP=$(mktemp) 13 - cp "$CONFIG_FILE" "$CONFIG_BACKUP" 14 - echo "🛡️ Backed up config.json to protect local settings." 15 + CONFIG_BACKUP="$(mktemp "${TMPDIR:-/tmp}/tweets2bsky-config.XXXXXX")" 16 + cp "$CONFIG_FILE" "$CONFIG_BACKUP" 17 + echo "🛡️ Backed up config.json to protect local settings." 15 18 fi 16 19 17 20 restore_config() { 18 - if [ -n "$CONFIG_BACKUP" ] && [ -f "$CONFIG_BACKUP" ]; then 19 - cp "$CONFIG_BACKUP" "$CONFIG_FILE" 20 - rm -f "$CONFIG_BACKUP" 21 - echo "🔐 Restored config.json." 22 - fi 21 + if [ -n "$CONFIG_BACKUP" ] && [ -f "$CONFIG_BACKUP" ]; then 22 + cp "$CONFIG_BACKUP" "$CONFIG_FILE" 23 + rm -f "$CONFIG_BACKUP" 24 + echo "🔐 Restored config.json." 25 + fi 23 26 } 24 27 25 28 trap restore_config EXIT 26 29 27 - # Check if git is available 28 - if ! command -v git &> /dev/null; then 29 - echo "❌ Git is not installed. Please install git to update." 30 - exit 1 30 + if ! command -v git >/dev/null 2>&1; then 31 + echo "❌ Git is not installed. Please install git to update." 32 + exit 1 33 + fi 34 + 35 + if ! command -v npm >/dev/null 2>&1; then 36 + echo "❌ npm is not installed. Please install Node.js/npm to update." 37 + exit 1 31 38 fi 32 39 33 40 echo "⬇️ Pulling latest changes..." 34 - # Attempt to pull with autostash to handle local changes gracefully 35 41 if ! git pull --autostash; then 36 - echo "⚠️ Standard pull failed. Attempting to stash local changes and retry..." 37 - git stash 38 - if ! git pull; then 39 - echo "❌ Git pull failed even after stashing. You might have complex local changes." 40 - echo " Please check 'git status' and resolve conflicts manually." 41 - exit 1 42 - fi 42 + echo "⚠️ Standard pull failed. Attempting to stash local changes and retry..." 43 + git stash push -u -m "tweets-2-bsky-update-autostash" 44 + if ! git pull; then 45 + echo "❌ Git pull failed even after stashing. Resolve conflicts manually and rerun ./update.sh." 46 + exit 1 47 + fi 43 48 fi 44 49 45 50 echo "📦 Installing dependencies..." 46 51 npm install 47 52 48 53 echo "🔧 Verifying native modules..." 49 - npm run rebuild:native 54 + if ! npm run rebuild:native; then 55 + echo "⚠️ rebuild:native failed (or missing). Falling back to direct better-sqlite3 rebuild..." 56 + if ! npm rebuild better-sqlite3; then 57 + npm rebuild better-sqlite3 --build-from-source 58 + fi 59 + fi 50 60 51 61 echo "🏗️ Building server + web dashboard..." 52 62 npm run build 53 63 54 64 echo "✅ Update complete!" 55 65 56 - # Determine PM2 process name (default to 'tweets-2-bsky' if not found) 57 - PROCESS_NAME="tweets-2-bsky" 58 - if pm2 describe twitter-mirror &> /dev/null; then 66 + if command -v pm2 >/dev/null 2>&1; then 67 + PROCESS_NAME="tweets-2-bsky" 68 + if pm2 describe twitter-mirror >/dev/null 2>&1; then 59 69 PROCESS_NAME="twitter-mirror" 60 - fi 70 + elif pm2 describe tweets-2-bsky >/dev/null 2>&1; then 71 + PROCESS_NAME="tweets-2-bsky" 72 + fi 61 73 62 - if command -v pm2 &> /dev/null; then 63 - echo "🔄 Hard restarting PM2 process '$PROCESS_NAME' to fix environment paths..." 64 - pm2 delete $PROCESS_NAME || true 65 - pm2 start dist/index.js --name $PROCESS_NAME 66 - pm2 save 67 - echo "✅ PM2 process restarted and saved." 74 + echo "🔄 Restarting PM2 process '$PROCESS_NAME'..." 75 + if ! pm2 restart "$PROCESS_NAME" --update-env >/dev/null 2>&1; then 76 + echo "ℹ️ PM2 process '$PROCESS_NAME' not found or restart failed. Recreating..." 77 + pm2 delete "$PROCESS_NAME" >/dev/null 2>&1 || true 78 + pm2 start dist/index.js --name "$PROCESS_NAME" 79 + fi 80 + pm2 save 81 + echo "✅ PM2 process restarted and saved." 68 82 else 69 - echo "⚠️ PM2 not found. Please restart your application manually." 83 + echo "⚠️ PM2 not found. Please restart your application manually." 70 84 fi
+1228 -187
web/src/App.tsx
··· 3 3 AlertTriangle, 4 4 ArrowUpRight, 5 5 Bot, 6 + ChevronDown, 6 7 Clock3, 7 8 Download, 9 + Folder, 10 + Heart, 8 11 History, 12 + LayoutDashboard, 9 13 Loader2, 10 14 LogOut, 15 + MessageCircle, 11 16 Moon, 17 + Newspaper, 12 18 Play, 13 19 Plus, 20 + Quote, 21 + Repeat2, 14 22 Save, 15 23 Settings2, 16 24 Sun, ··· 18 26 Trash2, 19 27 Upload, 20 28 UserRound, 29 + Users, 21 30 } from 'lucide-react'; 22 31 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 23 32 import { Badge } from './components/ui/badge'; ··· 29 38 30 39 type ThemeMode = 'system' | 'light' | 'dark'; 31 40 type AuthView = 'login' | 'register'; 41 + type DashboardTab = 'overview' | 'accounts' | 'posts' | 'activity' | 'settings'; 32 42 33 43 type AppState = 'idle' | 'checking' | 'backfilling' | 'pacing' | 'processing'; 34 44 ··· 40 50 bskyServiceUrl?: string; 41 51 enabled: boolean; 42 52 owner?: string; 53 + groupName?: string; 54 + groupEmoji?: string; 43 55 } 44 56 45 57 interface TwitterConfig { ··· 66 78 created_at?: string; 67 79 } 68 80 81 + interface BskyFacetFeatureLink { 82 + $type: 'app.bsky.richtext.facet#link'; 83 + uri: string; 84 + } 85 + 86 + interface BskyFacetFeatureMention { 87 + $type: 'app.bsky.richtext.facet#mention'; 88 + did: string; 89 + } 90 + 91 + interface BskyFacetFeatureTag { 92 + $type: 'app.bsky.richtext.facet#tag'; 93 + tag: string; 94 + } 95 + 96 + type BskyFacetFeature = BskyFacetFeatureLink | BskyFacetFeatureMention | BskyFacetFeatureTag; 97 + 98 + interface BskyFacet { 99 + index?: { 100 + byteStart?: number; 101 + byteEnd?: number; 102 + }; 103 + features?: BskyFacetFeature[]; 104 + } 105 + 106 + interface EnrichedPostMedia { 107 + type: 'image' | 'video' | 'external'; 108 + url?: string; 109 + thumb?: string; 110 + alt?: string; 111 + width?: number; 112 + height?: number; 113 + title?: string; 114 + description?: string; 115 + } 116 + 117 + interface EnrichedPost { 118 + bskyUri: string; 119 + bskyCid?: string; 120 + bskyIdentifier: string; 121 + twitterId: string; 122 + twitterUsername: string; 123 + twitterUrl?: string; 124 + postUrl?: string; 125 + createdAt?: string; 126 + text: string; 127 + facets: BskyFacet[]; 128 + author: { 129 + did?: string; 130 + handle: string; 131 + displayName?: string; 132 + avatar?: string; 133 + }; 134 + stats: { 135 + likes: number; 136 + reposts: number; 137 + replies: number; 138 + quotes: number; 139 + engagement: number; 140 + }; 141 + media: EnrichedPostMedia[]; 142 + } 143 + 144 + interface BskyProfileView { 145 + did?: string; 146 + handle?: string; 147 + displayName?: string; 148 + avatar?: string; 149 + } 150 + 69 151 interface PendingBackfill { 70 152 id: string; 71 153 limit?: number; ··· 107 189 108 190 interface MappingFormState { 109 191 owner: string; 110 - twitterUsernames: string; 111 192 bskyIdentifier: string; 112 193 bskyPassword: string; 113 194 bskyServiceUrl: string; 195 + groupName: string; 196 + groupEmoji: string; 114 197 } 115 198 116 199 const defaultMappingForm = (): MappingFormState => ({ 117 200 owner: '', 118 - twitterUsernames: '', 119 201 bskyIdentifier: '', 120 202 bskyPassword: '', 121 203 bskyServiceUrl: 'https://bsky.social', 204 + groupName: '', 205 + groupEmoji: '📁', 122 206 }); 123 207 208 + const DEFAULT_GROUP_NAME = 'Ungrouped'; 209 + const DEFAULT_GROUP_EMOJI = '📁'; 210 + 124 211 const selectClassName = 125 212 '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'; 126 213 ··· 165 252 return `https://bsky.app/profile/${activity.bsky_identifier}/post/${postId}`; 166 253 } 167 254 255 + function normalizeTwitterUsername(value: string): string { 256 + return value.trim().replace(/^@/, '').toLowerCase(); 257 + } 258 + 259 + function normalizeGroupName(value?: string): string { 260 + const trimmed = typeof value === 'string' ? value.trim() : ''; 261 + return trimmed || DEFAULT_GROUP_NAME; 262 + } 263 + 264 + function normalizeGroupEmoji(value?: string): string { 265 + const trimmed = typeof value === 'string' ? value.trim() : ''; 266 + return trimmed || DEFAULT_GROUP_EMOJI; 267 + } 268 + 269 + function getMappingGroupMeta(mapping?: Pick<AccountMapping, 'groupName' | 'groupEmoji'>) { 270 + const name = normalizeGroupName(mapping?.groupName); 271 + const emoji = normalizeGroupEmoji(mapping?.groupEmoji); 272 + const key = `${name.toLowerCase()}::${emoji}`; 273 + return { key, name, emoji }; 274 + } 275 + 276 + function getTwitterPostUrl(twitterUsername?: string, twitterId?: string): string | undefined { 277 + if (!twitterUsername || !twitterId) { 278 + return undefined; 279 + } 280 + return `https://x.com/${normalizeTwitterUsername(twitterUsername)}/status/${twitterId}`; 281 + } 282 + 283 + function addTwitterUsernames(current: string[], value: string): string[] { 284 + const candidates = value 285 + .split(/[\s,]+/) 286 + .map(normalizeTwitterUsername) 287 + .filter((username) => username.length > 0); 288 + if (candidates.length === 0) { 289 + return current; 290 + } 291 + 292 + const seen = new Set(current.map(normalizeTwitterUsername)); 293 + const next = [...current]; 294 + for (const candidate of candidates) { 295 + if (seen.has(candidate)) { 296 + continue; 297 + } 298 + seen.add(candidate); 299 + next.push(candidate); 300 + } 301 + 302 + return next; 303 + } 304 + 305 + const textEncoder = new TextEncoder(); 306 + const textDecoder = new TextDecoder(); 307 + const compactNumberFormatter = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }); 308 + 309 + type FacetSegment = 310 + | { type: 'text'; text: string } 311 + | { type: 'link'; text: string; href: string } 312 + | { type: 'mention'; text: string; href: string } 313 + | { type: 'tag'; text: string; href: string }; 314 + 315 + function sliceByBytes(bytes: Uint8Array, start: number, end: number): string { 316 + return textDecoder.decode(bytes.slice(start, end)); 317 + } 318 + 319 + function buildFacetSegments(text: string, facets: BskyFacet[]): FacetSegment[] { 320 + const bytes = textEncoder.encode(text); 321 + const sortedFacets = [...facets].sort((a, b) => (a.index?.byteStart || 0) - (b.index?.byteStart || 0)); 322 + const segments: FacetSegment[] = []; 323 + let cursor = 0; 324 + 325 + for (const facet of sortedFacets) { 326 + const start = Number(facet.index?.byteStart); 327 + const end = Number(facet.index?.byteEnd); 328 + if (!Number.isFinite(start) || !Number.isFinite(end)) continue; 329 + if (start < cursor || end <= start || end > bytes.length) continue; 330 + 331 + if (start > cursor) { 332 + segments.push({ type: 'text', text: sliceByBytes(bytes, cursor, start) }); 333 + } 334 + 335 + const rawText = sliceByBytes(bytes, start, end); 336 + const feature = facet.features?.[0]; 337 + if (!feature) { 338 + segments.push({ type: 'text', text: rawText }); 339 + } else if (feature.$type === 'app.bsky.richtext.facet#link' && feature.uri) { 340 + segments.push({ type: 'link', text: rawText, href: feature.uri }); 341 + } else if (feature.$type === 'app.bsky.richtext.facet#mention' && feature.did) { 342 + segments.push({ type: 'mention', text: rawText, href: `https://bsky.app/profile/${feature.did}` }); 343 + } else if (feature.$type === 'app.bsky.richtext.facet#tag' && feature.tag) { 344 + segments.push({ type: 'tag', text: rawText, href: `https://bsky.app/hashtag/${encodeURIComponent(feature.tag)}` }); 345 + } else { 346 + segments.push({ type: 'text', text: rawText }); 347 + } 348 + 349 + cursor = end; 350 + } 351 + 352 + if (cursor < bytes.length) { 353 + segments.push({ type: 'text', text: sliceByBytes(bytes, cursor, bytes.length) }); 354 + } 355 + 356 + if (segments.length === 0) { 357 + return [{ type: 'text', text }]; 358 + } 359 + 360 + return segments; 361 + } 362 + 363 + function formatCompactNumber(value: number): string { 364 + return compactNumberFormatter.format(Math.max(0, value)); 365 + } 366 + 168 367 function App() { 169 368 const [token, setToken] = useState<string | null>(() => localStorage.getItem('token')); 170 369 const [authView, setAuthView] = useState<AuthView>('login'); ··· 178 377 const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light'); 179 378 180 379 const [mappings, setMappings] = useState<AccountMapping[]>([]); 380 + const [enrichedPosts, setEnrichedPosts] = useState<EnrichedPost[]>([]); 381 + const [profilesByActor, setProfilesByActor] = useState<Record<string, BskyProfileView>>({}); 181 382 const [twitterConfig, setTwitterConfig] = useState<TwitterConfig>({ authToken: '', ct0: '' }); 182 383 const [aiConfig, setAiConfig] = useState<AIConfig>({ provider: 'gemini', apiKey: '', model: '', baseUrl: '' }); 183 384 const [recentActivity, setRecentActivity] = useState<ActivityLog[]>([]); 184 385 const [status, setStatus] = useState<StatusResponse | null>(null); 185 386 const [countdown, setCountdown] = useState('--'); 387 + const [activeTab, setActiveTab] = useState<DashboardTab>(() => { 388 + const saved = localStorage.getItem('dashboard-tab'); 389 + if ( 390 + saved === 'overview' || 391 + saved === 'accounts' || 392 + saved === 'posts' || 393 + saved === 'activity' || 394 + saved === 'settings' 395 + ) { 396 + return saved; 397 + } 398 + return 'overview'; 399 + }); 186 400 187 401 const [me, setMe] = useState<AuthUser | null>(null); 188 402 const [editingMapping, setEditingMapping] = useState<AccountMapping | null>(null); 189 403 const [newMapping, setNewMapping] = useState<MappingFormState>(defaultMappingForm); 404 + const [newTwitterUsers, setNewTwitterUsers] = useState<string[]>([]); 405 + const [newTwitterInput, setNewTwitterInput] = useState(''); 190 406 const [editForm, setEditForm] = useState<MappingFormState>(defaultMappingForm); 407 + const [editTwitterUsers, setEditTwitterUsers] = useState<string[]>([]); 408 + const [editTwitterInput, setEditTwitterInput] = useState(''); 409 + const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Record<string, boolean>>(() => { 410 + const raw = localStorage.getItem('accounts-collapsed-groups'); 411 + if (!raw) return {}; 412 + try { 413 + const parsed = JSON.parse(raw) as Record<string, boolean>; 414 + return parsed && typeof parsed === 'object' ? parsed : {}; 415 + } catch { 416 + return {}; 417 + } 418 + }); 419 + const [postsGroupFilter, setPostsGroupFilter] = useState('all'); 420 + const [activityGroupFilter, setActivityGroupFilter] = useState('all'); 191 421 const [notice, setNotice] = useState<Notice | null>(null); 192 422 193 423 const [isBusy, setIsBusy] = useState(false); ··· 214 444 setToken(null); 215 445 setMe(null); 216 446 setMappings([]); 447 + setEnrichedPosts([]); 448 + setProfilesByActor({}); 217 449 setStatus(null); 218 450 setRecentActivity([]); 219 451 setEditingMapping(null); 452 + setNewTwitterUsers([]); 453 + setEditTwitterUsers([]); 220 454 setAuthView('login'); 221 455 }, []); 222 456 ··· 257 491 } 258 492 }, [authHeaders, handleAuthFailure]); 259 493 494 + const fetchEnrichedPosts = useCallback(async () => { 495 + if (!authHeaders) { 496 + return; 497 + } 498 + 499 + try { 500 + const response = await axios.get<EnrichedPost[]>('/api/posts/enriched?limit=36', { headers: authHeaders }); 501 + setEnrichedPosts(response.data); 502 + } catch (error) { 503 + handleAuthFailure(error, 'Failed to fetch Bluesky posts.'); 504 + } 505 + }, [authHeaders, handleAuthFailure]); 506 + 507 + const fetchProfiles = useCallback( 508 + async (actors: string[]) => { 509 + if (!authHeaders) { 510 + return; 511 + } 512 + 513 + const normalizedActors = [...new Set(actors.map(normalizeTwitterUsername).filter((actor) => actor.length > 0))]; 514 + if (normalizedActors.length === 0) { 515 + setProfilesByActor({}); 516 + return; 517 + } 518 + 519 + try { 520 + const response = await axios.post<Record<string, BskyProfileView>>( 521 + '/api/bsky/profiles', 522 + { actors: normalizedActors }, 523 + { headers: authHeaders }, 524 + ); 525 + setProfilesByActor(response.data || {}); 526 + } catch (error) { 527 + handleAuthFailure(error, 'Failed to resolve Bluesky profiles.'); 528 + } 529 + }, 530 + [authHeaders, handleAuthFailure], 531 + ); 532 + 260 533 const fetchData = useCallback(async () => { 261 534 if (!authHeaders) { 262 535 return; ··· 269 542 ]); 270 543 271 544 const profile = meResponse.data; 545 + const mappingData = mappingsResponse.data; 272 546 setMe(profile); 273 - setMappings(mappingsResponse.data); 547 + setMappings(mappingData); 274 548 275 549 if (profile.isAdmin) { 276 550 const [twitterResponse, aiResponse] = await Promise.all([ ··· 293 567 }); 294 568 } 295 569 296 - await Promise.all([fetchStatus(), fetchRecentActivity()]); 570 + await Promise.all([fetchStatus(), fetchRecentActivity(), fetchEnrichedPosts()]); 571 + await fetchProfiles(mappingData.map((mapping) => mapping.bskyIdentifier)); 297 572 } catch (error) { 298 573 handleAuthFailure(error, 'Failed to load dashboard data.'); 299 574 } 300 - }, [authHeaders, fetchRecentActivity, fetchStatus, handleAuthFailure]); 575 + }, [authHeaders, fetchEnrichedPosts, fetchProfiles, fetchRecentActivity, fetchStatus, handleAuthFailure]); 301 576 302 577 useEffect(() => { 303 578 localStorage.setItem('theme-mode', themeMode); 304 579 }, [themeMode]); 305 580 306 581 useEffect(() => { 582 + localStorage.setItem('dashboard-tab', activeTab); 583 + }, [activeTab]); 584 + 585 + useEffect(() => { 586 + localStorage.setItem('accounts-collapsed-groups', JSON.stringify(collapsedGroupKeys)); 587 + }, [collapsedGroupKeys]); 588 + 589 + useEffect(() => { 590 + if (!isAdmin && activeTab === 'settings') { 591 + setActiveTab('overview'); 592 + } 593 + }, [activeTab, isAdmin]); 594 + 595 + useEffect(() => { 307 596 const media = window.matchMedia('(prefers-color-scheme: dark)'); 308 597 309 598 const applyTheme = () => { ··· 342 631 void fetchRecentActivity(); 343 632 }, 7000); 344 633 634 + const postsInterval = window.setInterval(() => { 635 + void fetchEnrichedPosts(); 636 + }, 12000); 637 + 345 638 return () => { 346 639 window.clearInterval(statusInterval); 347 640 window.clearInterval(activityInterval); 641 + window.clearInterval(postsInterval); 348 642 }; 349 - }, [token, fetchRecentActivity, fetchStatus]); 643 + }, [token, fetchEnrichedPosts, fetchRecentActivity, fetchStatus]); 350 644 351 645 useEffect(() => { 352 646 if (!status?.nextCheckTime) { ··· 384 678 385 679 const pendingBackfills = status?.pendingBackfills ?? []; 386 680 const currentStatus = status?.currentStatus; 681 + const latestActivity = recentActivity[0]; 682 + const dashboardTabs = useMemo( 683 + () => 684 + [ 685 + { id: 'overview' as DashboardTab, label: 'Overview', icon: LayoutDashboard }, 686 + { id: 'accounts' as DashboardTab, label: 'Accounts', icon: Users }, 687 + { id: 'posts' as DashboardTab, label: 'Posts', icon: Newspaper }, 688 + { id: 'activity' as DashboardTab, label: 'Activity', icon: History }, 689 + { id: 'settings' as DashboardTab, label: 'Settings', icon: Settings2, adminOnly: true }, 690 + ].filter((tab) => (tab.adminOnly ? isAdmin : true)), 691 + [isAdmin], 692 + ); 387 693 const postedActivity = useMemo( 694 + () => enrichedPosts.slice(0, 12), 695 + [enrichedPosts], 696 + ); 697 + const engagementByAccount = useMemo(() => { 698 + const map = new Map<string, { identifier: string; score: number; posts: number }>(); 699 + for (const post of enrichedPosts) { 700 + const key = normalizeTwitterUsername(post.bskyIdentifier); 701 + const existing = map.get(key) || { 702 + identifier: post.bskyIdentifier, 703 + score: 0, 704 + posts: 0, 705 + }; 706 + existing.score += post.stats.engagement || 0; 707 + existing.posts += 1; 708 + map.set(key, existing); 709 + } 710 + return [...map.values()].sort((a, b) => b.score - a.score); 711 + }, [enrichedPosts]); 712 + const topAccount = engagementByAccount[0]; 713 + const getProfileForActor = useCallback( 714 + (actor: string) => profilesByActor[normalizeTwitterUsername(actor)], 715 + [profilesByActor], 716 + ); 717 + const topAccountProfile = topAccount ? getProfileForActor(topAccount.identifier) : undefined; 718 + const mappingsByBskyIdentifier = useMemo(() => { 719 + const map = new Map<string, AccountMapping>(); 720 + for (const mapping of mappings) { 721 + map.set(normalizeTwitterUsername(mapping.bskyIdentifier), mapping); 722 + } 723 + return map; 724 + }, [mappings]); 725 + const mappingsByTwitterUsername = useMemo(() => { 726 + const map = new Map<string, AccountMapping>(); 727 + for (const mapping of mappings) { 728 + for (const username of mapping.twitterUsernames) { 729 + map.set(normalizeTwitterUsername(username), mapping); 730 + } 731 + } 732 + return map; 733 + }, [mappings]); 734 + const groupOptions = useMemo(() => { 735 + const options = new Map<string, { key: string; name: string; emoji: string }>(); 736 + for (const mapping of mappings) { 737 + const group = getMappingGroupMeta(mapping); 738 + if (!options.has(group.key)) { 739 + options.set(group.key, group); 740 + } 741 + } 742 + return [...options.values()].sort((a, b) => { 743 + const aUngrouped = a.name === DEFAULT_GROUP_NAME; 744 + const bUngrouped = b.name === DEFAULT_GROUP_NAME; 745 + if (aUngrouped && !bUngrouped) return 1; 746 + if (!aUngrouped && bUngrouped) return -1; 747 + return a.name.localeCompare(b.name); 748 + }); 749 + }, [mappings]); 750 + const groupedMappings = useMemo(() => { 751 + const groups = new Map<string, { key: string; name: string; emoji: string; mappings: AccountMapping[] }>(); 752 + for (const mapping of mappings) { 753 + const group = getMappingGroupMeta(mapping); 754 + const existing = groups.get(group.key); 755 + if (!existing) { 756 + groups.set(group.key, { ...group, mappings: [mapping] }); 757 + continue; 758 + } 759 + existing.mappings.push(mapping); 760 + } 761 + 762 + return [...groups.values()] 763 + .sort((a, b) => { 764 + const aUngrouped = a.name === DEFAULT_GROUP_NAME; 765 + const bUngrouped = b.name === DEFAULT_GROUP_NAME; 766 + if (aUngrouped && !bUngrouped) return 1; 767 + if (!aUngrouped && bUngrouped) return -1; 768 + return a.name.localeCompare(b.name); 769 + }) 770 + .map((group) => ({ 771 + ...group, 772 + mappings: [...group.mappings].sort((a, b) => 773 + `${(a.owner || '').toLowerCase()}-${a.bskyIdentifier.toLowerCase()}`.localeCompare( 774 + `${(b.owner || '').toLowerCase()}-${b.bskyIdentifier.toLowerCase()}`, 775 + ), 776 + ), 777 + })); 778 + }, [mappings]); 779 + const resolveMappingForPost = useCallback( 780 + (post: EnrichedPost) => 781 + mappingsByBskyIdentifier.get(normalizeTwitterUsername(post.bskyIdentifier)) || 782 + mappingsByTwitterUsername.get(normalizeTwitterUsername(post.twitterUsername)), 783 + [mappingsByBskyIdentifier, mappingsByTwitterUsername], 784 + ); 785 + const resolveMappingForActivity = useCallback( 786 + (activity: ActivityLog) => 787 + mappingsByBskyIdentifier.get(normalizeTwitterUsername(activity.bsky_identifier)) || 788 + mappingsByTwitterUsername.get(normalizeTwitterUsername(activity.twitter_username)), 789 + [mappingsByBskyIdentifier, mappingsByTwitterUsername], 790 + ); 791 + const filteredPostedActivity = useMemo( 388 792 () => 389 - recentActivity 390 - .filter((activity) => activity.status === 'migrated' && Boolean(getBskyPostUrl(activity))) 391 - .slice(0, 12), 392 - [recentActivity], 793 + postedActivity.filter((post) => { 794 + if (postsGroupFilter === 'all') return true; 795 + const mapping = resolveMappingForPost(post); 796 + return getMappingGroupMeta(mapping).key === postsGroupFilter; 797 + }), 798 + [postedActivity, postsGroupFilter, resolveMappingForPost], 799 + ); 800 + const filteredRecentActivity = useMemo( 801 + () => 802 + recentActivity.filter((activity) => { 803 + if (activityGroupFilter === 'all') return true; 804 + const mapping = resolveMappingForActivity(activity); 805 + return getMappingGroupMeta(mapping).key === activityGroupFilter; 806 + }), 807 + [activityGroupFilter, recentActivity, resolveMappingForActivity], 393 808 ); 394 809 810 + useEffect(() => { 811 + if (postsGroupFilter !== 'all' && !groupOptions.some((group) => group.key === postsGroupFilter)) { 812 + setPostsGroupFilter('all'); 813 + } 814 + if (activityGroupFilter !== 'all' && !groupOptions.some((group) => group.key === activityGroupFilter)) { 815 + setActivityGroupFilter('all'); 816 + } 817 + }, [activityGroupFilter, groupOptions, postsGroupFilter]); 818 + 395 819 const isBackfillQueued = useCallback( 396 820 (mappingId: string) => pendingBackfills.some((entry) => entry.id === mappingId), 397 821 [pendingBackfills], ··· 589 1013 } 590 1014 }; 591 1015 1016 + const addNewTwitterUsername = () => { 1017 + setNewTwitterUsers((previous) => addTwitterUsernames(previous, newTwitterInput)); 1018 + setNewTwitterInput(''); 1019 + }; 1020 + 1021 + const removeNewTwitterUsername = (username: string) => { 1022 + setNewTwitterUsers((previous) => 1023 + previous.filter((existing) => normalizeTwitterUsername(existing) !== normalizeTwitterUsername(username)), 1024 + ); 1025 + }; 1026 + 1027 + const addEditTwitterUsername = () => { 1028 + setEditTwitterUsers((previous) => addTwitterUsernames(previous, editTwitterInput)); 1029 + setEditTwitterInput(''); 1030 + }; 1031 + 1032 + const removeEditTwitterUsername = (username: string) => { 1033 + setEditTwitterUsers((previous) => 1034 + previous.filter((existing) => normalizeTwitterUsername(existing) !== normalizeTwitterUsername(username)), 1035 + ); 1036 + }; 1037 + 1038 + const toggleGroupCollapsed = (groupKey: string) => { 1039 + setCollapsedGroupKeys((previous) => ({ 1040 + ...previous, 1041 + [groupKey]: !previous[groupKey], 1042 + })); 1043 + }; 1044 + 592 1045 const handleAddMapping = async (event: React.FormEvent<HTMLFormElement>) => { 593 1046 event.preventDefault(); 594 1047 if (!authHeaders) { 595 1048 return; 596 1049 } 597 1050 1051 + if (newTwitterUsers.length === 0) { 1052 + showNotice('error', 'Add at least one Twitter username.'); 1053 + return; 1054 + } 1055 + 598 1056 setIsBusy(true); 599 1057 600 1058 try { ··· 602 1060 '/api/mappings', 603 1061 { 604 1062 owner: newMapping.owner.trim(), 605 - twitterUsernames: newMapping.twitterUsernames, 1063 + twitterUsernames: newTwitterUsers, 606 1064 bskyIdentifier: newMapping.bskyIdentifier.trim(), 607 1065 bskyPassword: newMapping.bskyPassword, 608 1066 bskyServiceUrl: newMapping.bskyServiceUrl.trim(), 1067 + groupName: newMapping.groupName.trim(), 1068 + groupEmoji: newMapping.groupEmoji.trim(), 609 1069 }, 610 1070 { headers: authHeaders }, 611 1071 ); 612 1072 613 1073 setNewMapping(defaultMappingForm()); 1074 + setNewTwitterUsers([]); 1075 + setNewTwitterInput(''); 614 1076 showNotice('success', 'Account mapping added.'); 615 1077 await fetchData(); 616 1078 } catch (error) { ··· 624 1086 setEditingMapping(mapping); 625 1087 setEditForm({ 626 1088 owner: mapping.owner || '', 627 - twitterUsernames: mapping.twitterUsernames.join(', '), 628 1089 bskyIdentifier: mapping.bskyIdentifier, 629 1090 bskyPassword: '', 630 1091 bskyServiceUrl: mapping.bskyServiceUrl || 'https://bsky.social', 1092 + groupName: mapping.groupName || '', 1093 + groupEmoji: mapping.groupEmoji || '📁', 631 1094 }); 1095 + setEditTwitterUsers(mapping.twitterUsernames); 1096 + setEditTwitterInput(''); 632 1097 }; 633 1098 634 1099 const handleUpdateMapping = async (event: React.FormEvent<HTMLFormElement>) => { 635 1100 event.preventDefault(); 636 1101 if (!authHeaders || !editingMapping) { 1102 + return; 1103 + } 1104 + 1105 + if (editTwitterUsers.length === 0) { 1106 + showNotice('error', 'At least one Twitter username is required.'); 637 1107 return; 638 1108 } 639 1109 ··· 644 1114 `/api/mappings/${editingMapping.id}`, 645 1115 { 646 1116 owner: editForm.owner.trim(), 647 - twitterUsernames: editForm.twitterUsernames, 1117 + twitterUsernames: editTwitterUsers, 648 1118 bskyIdentifier: editForm.bskyIdentifier.trim(), 649 1119 bskyPassword: editForm.bskyPassword, 650 1120 bskyServiceUrl: editForm.bskyServiceUrl.trim(), 1121 + groupName: editForm.groupName.trim(), 1122 + groupEmoji: editForm.groupEmoji.trim(), 651 1123 }, 652 1124 { headers: authHeaders }, 653 1125 ); 654 1126 655 1127 setEditingMapping(null); 656 1128 setEditForm(defaultMappingForm()); 1129 + setEditTwitterUsers([]); 1130 + setEditTwitterInput(''); 657 1131 showNotice('success', 'Mapping updated.'); 658 1132 await fetchData(); 659 1133 } catch (error) { ··· 870 1344 871 1345 {notice ? ( 872 1346 <div 873 - className={cn( 874 - 'mb-5 animate-fade-in rounded-md border px-4 py-2 text-sm', 875 - notice.tone === 'success' && 876 - 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/30 dark:text-emerald-300', 1347 + className={cn( 1348 + 'mb-5 animate-pop-in rounded-md border px-4 py-2 text-sm', 1349 + notice.tone === 'success' && 1350 + 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/30 dark:text-emerald-300', 877 1351 notice.tone === 'error' && 878 1352 'border-red-500/40 bg-red-500/10 text-red-700 dark:border-red-500/30 dark:text-red-300', 879 1353 notice.tone === 'info' && ··· 913 1387 </Card> 914 1388 ) : null} 915 1389 916 - <div className="panel-grid"> 917 - <section className="space-y-6"> 1390 + <div className="mb-6 animate-fade-in overflow-x-auto pb-1"> 1391 + <div className="inline-flex min-w-full gap-2 rounded-xl border border-border/70 bg-card/90 p-2 sm:min-w-0"> 1392 + {dashboardTabs.map((tab) => { 1393 + const Icon = tab.icon; 1394 + const isActive = activeTab === tab.id; 1395 + return ( 1396 + <button 1397 + key={tab.id} 1398 + className={cn( 1399 + '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', 1400 + isActive 1401 + ? 'bg-foreground text-background shadow-sm' 1402 + : 'bg-background text-muted-foreground hover:bg-muted hover:text-foreground hover:shadow-sm', 1403 + )} 1404 + onClick={() => setActiveTab(tab.id)} 1405 + type="button" 1406 + > 1407 + <Icon className="h-4 w-4" /> 1408 + {tab.label} 1409 + </button> 1410 + ); 1411 + })} 1412 + </div> 1413 + </div> 1414 + 1415 + {activeTab === 'overview' ? ( 1416 + <section className="space-y-6 animate-fade-in"> 1417 + <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5"> 1418 + <Card className="animate-slide-up"> 1419 + <CardContent className="p-4"> 1420 + <p className="text-xs uppercase tracking-wide text-muted-foreground">Mapped Accounts</p> 1421 + <p className="mt-2 text-2xl font-semibold">{mappings.length}</p> 1422 + </CardContent> 1423 + </Card> 1424 + <Card className="animate-slide-up"> 1425 + <CardContent className="p-4"> 1426 + <p className="text-xs uppercase tracking-wide text-muted-foreground">Backfill Queue</p> 1427 + <p className="mt-2 text-2xl font-semibold">{pendingBackfills.length}</p> 1428 + </CardContent> 1429 + </Card> 1430 + <Card className="animate-slide-up"> 1431 + <CardContent className="p-4"> 1432 + <p className="text-xs uppercase tracking-wide text-muted-foreground">Current State</p> 1433 + <p className="mt-2 text-2xl font-semibold">{formatState(currentStatus?.state || 'idle')}</p> 1434 + </CardContent> 1435 + </Card> 1436 + <Card className="animate-slide-up"> 1437 + <CardContent className="p-4"> 1438 + <p className="text-xs uppercase tracking-wide text-muted-foreground">Latest Activity</p> 1439 + <p className="mt-2 text-sm font-medium text-foreground"> 1440 + {latestActivity?.created_at ? new Date(latestActivity.created_at).toLocaleString() : 'No activity yet'} 1441 + </p> 1442 + </CardContent> 1443 + </Card> 1444 + <Card className="animate-slide-up"> 1445 + <CardContent className="p-4"> 1446 + <p className="text-xs uppercase tracking-wide text-muted-foreground">Top Account (Engagement)</p> 1447 + {topAccount ? ( 1448 + <div className="mt-2 flex items-center gap-3"> 1449 + {topAccountProfile?.avatar ? ( 1450 + <img 1451 + className="h-9 w-9 rounded-full border border-border/70 object-cover" 1452 + src={topAccountProfile.avatar} 1453 + alt={topAccountProfile.handle || topAccount.identifier} 1454 + loading="lazy" 1455 + /> 1456 + ) : ( 1457 + <div className="flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-muted text-muted-foreground"> 1458 + <UserRound className="h-4 w-4" /> 1459 + </div> 1460 + )} 1461 + <div className="min-w-0"> 1462 + <p className="truncate text-sm font-semibold"> 1463 + @{topAccountProfile?.handle || topAccount.identifier} 1464 + </p> 1465 + <p className="truncate text-xs text-muted-foreground"> 1466 + {formatCompactNumber(topAccount.score)} interactions • {topAccount.posts} posts 1467 + </p> 1468 + </div> 1469 + </div> 1470 + ) : ( 1471 + <p className="mt-2 text-sm text-muted-foreground">No engagement data yet.</p> 1472 + )} 1473 + </CardContent> 1474 + </Card> 1475 + </div> 1476 + 1477 + <Card className="animate-slide-up"> 1478 + <CardHeader> 1479 + <CardTitle>Quick Navigation</CardTitle> 1480 + <CardDescription>Use tabs to focus one workflow at a time, especially on mobile.</CardDescription> 1481 + </CardHeader> 1482 + <CardContent className="flex flex-wrap gap-2 pt-0"> 1483 + {dashboardTabs 1484 + .filter((tab) => tab.id !== 'overview') 1485 + .map((tab) => { 1486 + const Icon = tab.icon; 1487 + return ( 1488 + <Button key={`overview-${tab.id}`} variant="outline" onClick={() => setActiveTab(tab.id)}> 1489 + <Icon className="mr-2 h-4 w-4" /> 1490 + Open {tab.label} 1491 + </Button> 1492 + ); 1493 + })} 1494 + </CardContent> 1495 + </Card> 1496 + </section> 1497 + ) : null} 1498 + 1499 + {activeTab === 'accounts' ? ( 1500 + <section className="space-y-6 animate-fade-in"> 918 1501 <Card className="animate-slide-up"> 919 1502 <CardHeader className="pb-3"> 920 1503 <div className="flex items-center justify-between"> 921 1504 <div className="space-y-1"> 922 1505 <CardTitle>Active Accounts</CardTitle> 923 - <CardDescription>Manage source-to-target mappings and run account actions.</CardDescription> 1506 + <CardDescription>Organize mappings into folders and collapse/expand groups.</CardDescription> 924 1507 </div> 925 1508 <Badge variant="outline">{mappings.length} configured</Badge> 926 1509 </div> ··· 931 1514 No mappings yet. Add one from the settings panel. 932 1515 </div> 933 1516 ) : ( 934 - <div className="overflow-x-auto"> 935 - <table className="min-w-full text-left text-sm"> 936 - <thead className="border-b border-border text-xs uppercase tracking-wide text-muted-foreground"> 937 - <tr> 938 - <th className="px-2 py-3">Owner</th> 939 - <th className="px-2 py-3">Twitter Sources</th> 940 - <th className="px-2 py-3">Bluesky Target</th> 941 - <th className="px-2 py-3">Status</th> 942 - <th className="px-2 py-3 text-right">Actions</th> 943 - </tr> 944 - </thead> 945 - <tbody> 946 - {mappings.map((mapping) => { 947 - const queued = isBackfillQueued(mapping.id); 948 - const active = isBackfillActive(mapping.id); 949 - const queuePosition = getBackfillEntry(mapping.id)?.position; 1517 + <div className="space-y-3"> 1518 + {groupedMappings.map((group, groupIndex) => { 1519 + const collapsed = collapsedGroupKeys[group.key] === true; 950 1520 951 - return ( 952 - <tr key={mapping.id} className="border-b border-border/60 last:border-0"> 953 - <td className="px-2 py-3 align-top"> 954 - <div className="flex items-center gap-2 font-medium"> 955 - <UserRound className="h-4 w-4 text-muted-foreground" /> 956 - {mapping.owner || 'System'} 957 - </div> 958 - </td> 959 - <td className="px-2 py-3 align-top"> 960 - <div className="flex flex-wrap gap-2"> 961 - {mapping.twitterUsernames.map((username) => ( 962 - <Badge key={username} variant="secondary"> 963 - @{username} 964 - </Badge> 965 - ))} 966 - </div> 967 - </td> 968 - <td className="px-2 py-3 align-top"> 969 - <span className="font-mono text-xs sm:text-sm">{mapping.bskyIdentifier}</span> 970 - </td> 971 - <td className="px-2 py-3 align-top"> 972 - {active ? ( 973 - <Badge variant="warning">Backfilling</Badge> 974 - ) : queued ? ( 975 - <Badge variant="warning">Queued {queuePosition ? `#${queuePosition}` : ''}</Badge> 976 - ) : ( 977 - <Badge variant="success">Active</Badge> 978 - )} 979 - </td> 980 - <td className="px-2 py-3 align-top"> 981 - <div className="flex flex-wrap justify-end gap-1"> 982 - {isAdmin ? ( 983 - <> 984 - <Button variant="outline" size="sm" onClick={() => startEditMapping(mapping)}> 985 - Edit 986 - </Button> 987 - <Button 988 - variant="outline" 989 - size="sm" 990 - onClick={() => { 991 - void requestBackfill(mapping.id, 'normal'); 992 - }} 993 - > 994 - Backfill 995 - </Button> 996 - <Button 997 - variant="subtle" 998 - size="sm" 999 - onClick={() => { 1000 - void requestBackfill(mapping.id, 'reset'); 1001 - }} 1002 - > 1003 - Reset + Backfill 1004 - </Button> 1005 - <Button 1006 - variant="destructive" 1007 - size="sm" 1008 - onClick={() => { 1009 - void handleDeleteAllPosts(mapping.id); 1010 - }} 1011 - > 1012 - Delete Posts 1013 - </Button> 1014 - </> 1015 - ) : null} 1016 - <Button 1017 - variant="ghost" 1018 - size="sm" 1019 - onClick={() => { 1020 - void handleDeleteMapping(mapping.id); 1021 - }} 1022 - > 1023 - <Trash2 className="mr-1 h-4 w-4" /> 1024 - Remove 1025 - </Button> 1026 - </div> 1027 - </td> 1028 - </tr> 1029 - ); 1030 - })} 1031 - </tbody> 1032 - </table> 1521 + return ( 1522 + <div 1523 + key={group.key} 1524 + className="overflow-hidden rounded-lg border border-border/70 bg-card/70 animate-slide-up [animation-fill-mode:both]" 1525 + style={{ animationDelay: `${Math.min(groupIndex * 45, 220)}ms` }} 1526 + > 1527 + <button 1528 + 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" 1529 + onClick={() => toggleGroupCollapsed(group.key)} 1530 + type="button" 1531 + > 1532 + <div className="flex items-center gap-2"> 1533 + <Folder className="h-4 w-4 text-muted-foreground" /> 1534 + <span className="text-base">{group.emoji}</span> 1535 + <span className="font-medium">{group.name}</span> 1536 + <Badge variant="outline">{group.mappings.length}</Badge> 1537 + </div> 1538 + <ChevronDown 1539 + className={cn( 1540 + 'h-4 w-4 transition-transform duration-200 motion-reduce:transition-none', 1541 + collapsed ? '-rotate-90' : 'rotate-0', 1542 + )} 1543 + /> 1544 + </button> 1545 + 1546 + <div 1547 + className={cn( 1548 + 'grid transition-[grid-template-rows,opacity] duration-300 ease-out motion-reduce:transition-none', 1549 + collapsed ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr] opacity-100', 1550 + )} 1551 + > 1552 + <div className="min-h-0 overflow-hidden"> 1553 + <div className="overflow-x-auto"> 1554 + <table className="min-w-full text-left text-sm"> 1555 + <thead className="border-b border-border text-xs uppercase tracking-wide text-muted-foreground"> 1556 + <tr> 1557 + <th className="px-2 py-3">Owner</th> 1558 + <th className="px-2 py-3">Twitter Sources</th> 1559 + <th className="px-2 py-3">Bluesky Target</th> 1560 + <th className="px-2 py-3">Status</th> 1561 + <th className="px-2 py-3 text-right">Actions</th> 1562 + </tr> 1563 + </thead> 1564 + <tbody> 1565 + {group.mappings.map((mapping) => { 1566 + const queued = isBackfillQueued(mapping.id); 1567 + const active = isBackfillActive(mapping.id); 1568 + const queuePosition = getBackfillEntry(mapping.id)?.position; 1569 + const profile = getProfileForActor(mapping.bskyIdentifier); 1570 + const profileHandle = profile?.handle || mapping.bskyIdentifier; 1571 + const profileName = profile?.displayName || profileHandle; 1572 + 1573 + return ( 1574 + <tr key={mapping.id} className="interactive-row border-b border-border/60 last:border-0"> 1575 + <td className="px-2 py-3 align-top"> 1576 + <div className="flex items-center gap-2 font-medium"> 1577 + <UserRound className="h-4 w-4 text-muted-foreground" /> 1578 + {mapping.owner || 'System'} 1579 + </div> 1580 + </td> 1581 + <td className="px-2 py-3 align-top"> 1582 + <div className="flex flex-wrap gap-2"> 1583 + {mapping.twitterUsernames.map((username) => ( 1584 + <Badge key={username} variant="secondary"> 1585 + @{username} 1586 + </Badge> 1587 + ))} 1588 + </div> 1589 + </td> 1590 + <td className="px-2 py-3 align-top"> 1591 + <div className="flex items-center gap-2"> 1592 + {profile?.avatar ? ( 1593 + <img 1594 + className="h-8 w-8 rounded-full border border-border/70 object-cover" 1595 + src={profile.avatar} 1596 + alt={profileName} 1597 + loading="lazy" 1598 + /> 1599 + ) : ( 1600 + <div className="flex h-8 w-8 items-center justify-center rounded-full border border-border/70 bg-muted text-muted-foreground"> 1601 + <UserRound className="h-4 w-4" /> 1602 + </div> 1603 + )} 1604 + <div className="min-w-0"> 1605 + <p className="truncate text-sm font-medium">{profileName}</p> 1606 + <p className="truncate font-mono text-xs text-muted-foreground">{profileHandle}</p> 1607 + </div> 1608 + </div> 1609 + </td> 1610 + <td className="px-2 py-3 align-top"> 1611 + {active ? ( 1612 + <Badge variant="warning">Backfilling</Badge> 1613 + ) : queued ? ( 1614 + <Badge variant="warning">Queued {queuePosition ? `#${queuePosition}` : ''}</Badge> 1615 + ) : ( 1616 + <Badge variant="success">Active</Badge> 1617 + )} 1618 + </td> 1619 + <td className="px-2 py-3 align-top"> 1620 + <div className="flex flex-wrap justify-end gap-1"> 1621 + {isAdmin ? ( 1622 + <> 1623 + <Button variant="outline" size="sm" onClick={() => startEditMapping(mapping)}> 1624 + Edit 1625 + </Button> 1626 + <Button 1627 + variant="outline" 1628 + size="sm" 1629 + onClick={() => { 1630 + void requestBackfill(mapping.id, 'normal'); 1631 + }} 1632 + > 1633 + Backfill 1634 + </Button> 1635 + <Button 1636 + variant="subtle" 1637 + size="sm" 1638 + onClick={() => { 1639 + void requestBackfill(mapping.id, 'reset'); 1640 + }} 1641 + > 1642 + Reset + Backfill 1643 + </Button> 1644 + <Button 1645 + variant="destructive" 1646 + size="sm" 1647 + onClick={() => { 1648 + void handleDeleteAllPosts(mapping.id); 1649 + }} 1650 + > 1651 + Delete Posts 1652 + </Button> 1653 + </> 1654 + ) : null} 1655 + <Button 1656 + variant="ghost" 1657 + size="sm" 1658 + onClick={() => { 1659 + void handleDeleteMapping(mapping.id); 1660 + }} 1661 + > 1662 + <Trash2 className="mr-1 h-4 w-4" /> 1663 + Remove 1664 + </Button> 1665 + </div> 1666 + </td> 1667 + </tr> 1668 + ); 1669 + })} 1670 + </tbody> 1671 + </table> 1672 + </div> 1673 + </div> 1674 + </div> 1675 + </div> 1676 + ); 1677 + })} 1033 1678 </div> 1034 1679 )} 1035 1680 </CardContent> 1036 1681 </Card> 1682 + </section> 1683 + ) : null} 1037 1684 1685 + {activeTab === 'posts' ? ( 1686 + <section className="space-y-6 animate-fade-in"> 1038 1687 <Card className="animate-slide-up"> 1039 1688 <CardHeader className="pb-3"> 1040 - <CardTitle>Already Posted</CardTitle> 1041 - <CardDescription>Native-styled feed of successfully posted Bluesky entries.</CardDescription> 1689 + <div className="flex flex-wrap items-center justify-between gap-3"> 1690 + <div className="space-y-1"> 1691 + <CardTitle>Already Posted</CardTitle> 1692 + <CardDescription>Native-styled feed of successfully posted Bluesky entries.</CardDescription> 1693 + </div> 1694 + <div className="w-full max-w-xs"> 1695 + <Label htmlFor="posts-group-filter">Filter group</Label> 1696 + <select 1697 + id="posts-group-filter" 1698 + className={selectClassName} 1699 + value={postsGroupFilter} 1700 + onChange={(event) => setPostsGroupFilter(event.target.value)} 1701 + > 1702 + <option value="all">All folders</option> 1703 + {groupOptions.map((group) => ( 1704 + <option key={`posts-filter-${group.key}`} value={group.key}> 1705 + {group.emoji} {group.name} 1706 + </option> 1707 + ))} 1708 + </select> 1709 + </div> 1710 + </div> 1042 1711 </CardHeader> 1043 1712 <CardContent className="pt-0"> 1044 - {postedActivity.length === 0 ? ( 1713 + {filteredPostedActivity.length === 0 ? ( 1045 1714 <div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground"> 1046 1715 No posted entries yet. 1047 1716 </div> 1048 1717 ) : ( 1049 1718 <div className="grid gap-3 md:grid-cols-2"> 1050 - {postedActivity.map((activity, index) => { 1051 - const postUrl = getBskyPostUrl(activity); 1719 + {filteredPostedActivity.map((post, index) => { 1720 + const postUrl = 1721 + post.postUrl || 1722 + (post.bskyUri 1723 + ? `https://bsky.app/profile/${post.bskyIdentifier}/post/${post.bskyUri 1724 + .split('/') 1725 + .filter(Boolean) 1726 + .pop() || ''}` 1727 + : undefined); 1728 + const sourceTweetUrl = post.twitterUrl || getTwitterPostUrl(post.twitterUsername, post.twitterId); 1729 + const segments = buildFacetSegments(post.text, post.facets || []); 1730 + const mapping = resolveMappingForPost(post); 1731 + const groupMeta = getMappingGroupMeta(mapping); 1732 + const statItems: Array<{ 1733 + key: 'likes' | 'reposts' | 'replies' | 'quotes'; 1734 + value: number; 1735 + icon: typeof Heart; 1736 + }> = [ 1737 + { key: 'likes', value: post.stats.likes, icon: Heart }, 1738 + { key: 'reposts', value: post.stats.reposts, icon: Repeat2 }, 1739 + { key: 'replies', value: post.stats.replies, icon: MessageCircle }, 1740 + { key: 'quotes', value: post.stats.quotes, icon: Quote }, 1741 + ].filter((item) => item.value > 0); 1742 + const authorAvatar = post.author.avatar || getProfileForActor(post.author.handle)?.avatar; 1743 + const authorHandle = post.author.handle || post.bskyIdentifier; 1744 + const authorName = post.author.displayName || authorHandle; 1745 + 1052 1746 return ( 1053 1747 <article 1054 - key={`${activity.twitter_id}-${activity.created_at || index}-posted`} 1055 - className="rounded-xl border border-border/70 bg-background/80 p-4 shadow-sm" 1748 + key={post.bskyUri || `${post.bskyCid || 'post'}-${post.createdAt || index}`} 1749 + 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]" 1750 + style={{ animationDelay: `${Math.min(index * 45, 260)}ms` }} 1056 1751 > 1057 1752 <div className="mb-3 flex items-start justify-between gap-3"> 1058 - <div> 1059 - <p className="text-sm font-semibold">@{activity.bsky_identifier}</p> 1060 - <p className="text-xs text-muted-foreground">from @{activity.twitter_username}</p> 1753 + <div className="flex items-center gap-2"> 1754 + {authorAvatar ? ( 1755 + <img 1756 + className="h-9 w-9 rounded-full border border-border/70 object-cover" 1757 + src={authorAvatar} 1758 + alt={authorName} 1759 + loading="lazy" 1760 + /> 1761 + ) : ( 1762 + <div className="flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-muted text-muted-foreground"> 1763 + <UserRound className="h-4 w-4" /> 1764 + </div> 1765 + )} 1766 + <div> 1767 + <p className="text-sm font-semibold">{authorName}</p> 1768 + <p className="text-xs text-muted-foreground"> 1769 + @{authorHandle} • from @{post.twitterUsername} 1770 + </p> 1771 + </div> 1061 1772 </div> 1062 - <Badge variant="success">Posted</Badge> 1773 + <div className="flex items-center gap-2"> 1774 + <Badge variant="outline"> 1775 + {groupMeta.emoji} {groupMeta.name} 1776 + </Badge> 1777 + <Badge variant="success">Posted</Badge> 1778 + </div> 1063 1779 </div> 1780 + 1064 1781 <p className="mb-3 whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground"> 1065 - {activity.tweet_text || `(No cached text) Tweet ID ${activity.twitter_id}`} 1782 + {segments.map((segment, segmentIndex) => { 1783 + if (segment.type === 'text') { 1784 + return <span key={`${post.bskyUri}-segment-${segmentIndex}`}>{segment.text}</span>; 1785 + } 1786 + 1787 + const linkTone = 1788 + segment.type === 'mention' 1789 + ? 'text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200' 1790 + : segment.type === 'tag' 1791 + ? 'text-indigo-600 hover:text-indigo-500 dark:text-indigo-300 dark:hover:text-indigo-200' 1792 + : 'text-sky-600 hover:text-sky-500 dark:text-sky-300 dark:hover:text-sky-200'; 1793 + 1794 + return ( 1795 + <a 1796 + key={`${post.bskyUri}-segment-${segmentIndex}`} 1797 + className={cn('underline decoration-transparent transition hover:decoration-current', linkTone)} 1798 + href={segment.href} 1799 + target="_blank" 1800 + rel="noreferrer" 1801 + > 1802 + {segment.text} 1803 + </a> 1804 + ); 1805 + })} 1066 1806 </p> 1807 + 1808 + {post.media.length > 0 ? ( 1809 + <div className="mb-3 space-y-2"> 1810 + {post.media.map((media, mediaIndex) => { 1811 + if (media.type === 'image') { 1812 + const imageSrc = media.url || media.thumb; 1813 + if (!imageSrc) return null; 1814 + return ( 1815 + <a 1816 + key={`${post.bskyUri}-media-${mediaIndex}`} 1817 + className="group block overflow-hidden rounded-lg border border-border/70 bg-muted" 1818 + href={imageSrc} 1819 + target="_blank" 1820 + rel="noreferrer" 1821 + > 1822 + <img 1823 + className="h-56 w-full object-cover transition-transform duration-300 ease-out motion-reduce:transition-none motion-safe:group-hover:scale-[1.02]" 1824 + src={imageSrc} 1825 + alt={media.alt || 'Bluesky media'} 1826 + loading="lazy" 1827 + /> 1828 + </a> 1829 + ); 1830 + } 1831 + 1832 + if (media.type === 'video') { 1833 + const videoHref = media.url || media.thumb; 1834 + return ( 1835 + <div 1836 + key={`${post.bskyUri}-media-${mediaIndex}`} 1837 + className="group overflow-hidden rounded-lg border border-border/70 bg-muted" 1838 + > 1839 + {media.thumb ? ( 1840 + <img 1841 + className="h-56 w-full object-cover transition-transform duration-300 ease-out motion-reduce:transition-none motion-safe:group-hover:scale-[1.02]" 1842 + src={media.thumb} 1843 + alt={media.alt || 'Video thumbnail'} 1844 + loading="lazy" 1845 + /> 1846 + ) : ( 1847 + <div className="flex h-44 items-center justify-center text-sm text-muted-foreground"> 1848 + Video attachment 1849 + </div> 1850 + )} 1851 + {videoHref ? ( 1852 + <div className="border-t border-border/70 p-2 text-right"> 1853 + <a 1854 + className="inline-flex items-center text-xs text-foreground underline-offset-4 hover:underline" 1855 + href={videoHref} 1856 + target="_blank" 1857 + rel="noreferrer" 1858 + > 1859 + Open video 1860 + <ArrowUpRight className="ml-1 h-3 w-3" /> 1861 + </a> 1862 + </div> 1863 + ) : null} 1864 + </div> 1865 + ); 1866 + } 1867 + 1868 + if (media.type === 'external') { 1869 + if (!media.url) return null; 1870 + return ( 1871 + <a 1872 + key={`${post.bskyUri}-media-${mediaIndex}`} 1873 + className="group block overflow-hidden rounded-lg border border-border/70 bg-background transition-colors hover:bg-muted/60" 1874 + href={media.url} 1875 + target="_blank" 1876 + rel="noreferrer" 1877 + > 1878 + {media.thumb ? ( 1879 + <img 1880 + className="h-40 w-full object-cover transition-transform duration-300 ease-out motion-reduce:transition-none motion-safe:group-hover:scale-[1.02]" 1881 + src={media.thumb} 1882 + alt={media.title || media.url} 1883 + loading="lazy" 1884 + /> 1885 + ) : null} 1886 + <div className="space-y-1 p-3"> 1887 + <p className="truncate text-sm font-medium"> 1888 + {media.title || media.url} 1889 + </p> 1890 + {media.description ? ( 1891 + <p className="max-h-10 overflow-hidden text-xs text-muted-foreground"> 1892 + {media.description} 1893 + </p> 1894 + ) : null} 1895 + </div> 1896 + </a> 1897 + ); 1898 + } 1899 + 1900 + return null; 1901 + })} 1902 + </div> 1903 + ) : null} 1904 + 1905 + {statItems.length > 0 ? ( 1906 + <div className="mb-3 flex flex-wrap gap-2"> 1907 + {statItems.map((stat) => { 1908 + const Icon = stat.icon; 1909 + return ( 1910 + <span 1911 + key={`${post.bskyUri}-stat-${stat.key}`} 1912 + className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-muted px-2 py-1 text-xs text-muted-foreground" 1913 + > 1914 + <Icon className="h-3.5 w-3.5" /> 1915 + {formatCompactNumber(stat.value)} 1916 + </span> 1917 + ); 1918 + })} 1919 + </div> 1920 + ) : null} 1921 + 1067 1922 <div className="flex items-center justify-between gap-3 text-xs text-muted-foreground"> 1068 - <span>{activity.created_at ? new Date(activity.created_at).toLocaleString() : 'Unknown time'}</span> 1069 - {postUrl ? ( 1070 - <a 1071 - className="inline-flex items-center text-foreground underline-offset-4 hover:underline" 1072 - href={postUrl} 1073 - target="_blank" 1074 - rel="noreferrer" 1075 - > 1076 - Open 1077 - <ArrowUpRight className="ml-1 h-3 w-3" /> 1078 - </a> 1079 - ) : ( 1080 - <span>Missing URI</span> 1081 - )} 1923 + <span>{post.createdAt ? new Date(post.createdAt).toLocaleString() : 'Unknown time'}</span> 1924 + <div className="flex items-center gap-3"> 1925 + {sourceTweetUrl ? ( 1926 + <a 1927 + className="inline-flex items-center text-foreground underline-offset-4 hover:underline" 1928 + href={sourceTweetUrl} 1929 + target="_blank" 1930 + rel="noreferrer" 1931 + > 1932 + Source 1933 + <ArrowUpRight className="ml-1 h-3 w-3" /> 1934 + </a> 1935 + ) : null} 1936 + {postUrl ? ( 1937 + <a 1938 + className="inline-flex items-center text-foreground underline-offset-4 hover:underline" 1939 + href={postUrl} 1940 + target="_blank" 1941 + rel="noreferrer" 1942 + > 1943 + Bluesky 1944 + <ArrowUpRight className="ml-1 h-3 w-3" /> 1945 + </a> 1946 + ) : ( 1947 + <span>Missing URI</span> 1948 + )} 1949 + </div> 1082 1950 </div> 1083 1951 </article> 1084 1952 ); ··· 1087 1955 )} 1088 1956 </CardContent> 1089 1957 </Card> 1958 + </section> 1959 + ) : null} 1090 1960 1961 + {activeTab === 'activity' ? ( 1962 + <section className="space-y-6 animate-fade-in"> 1091 1963 <Card className="animate-slide-up"> 1092 1964 <CardHeader className="pb-3"> 1093 - <CardTitle className="flex items-center gap-2"> 1094 - <History className="h-4 w-4" /> 1095 - Recent Activity 1096 - </CardTitle> 1097 - <CardDescription>Latest migration outcomes from the processing database.</CardDescription> 1965 + <div className="flex flex-wrap items-center justify-between gap-3"> 1966 + <div className="space-y-1"> 1967 + <CardTitle className="flex items-center gap-2"> 1968 + <History className="h-4 w-4" /> 1969 + Recent Activity 1970 + </CardTitle> 1971 + <CardDescription>Latest migration outcomes from the processing database.</CardDescription> 1972 + </div> 1973 + <div className="w-full max-w-xs"> 1974 + <Label htmlFor="activity-group-filter">Filter group</Label> 1975 + <select 1976 + id="activity-group-filter" 1977 + className={selectClassName} 1978 + value={activityGroupFilter} 1979 + onChange={(event) => setActivityGroupFilter(event.target.value)} 1980 + > 1981 + <option value="all">All folders</option> 1982 + {groupOptions.map((group) => ( 1983 + <option key={`activity-filter-${group.key}`} value={group.key}> 1984 + {group.emoji} {group.name} 1985 + </option> 1986 + ))} 1987 + </select> 1988 + </div> 1989 + </div> 1098 1990 </CardHeader> 1099 1991 <CardContent className="pt-0"> 1100 1992 <div className="overflow-x-auto"> ··· 1103 1995 <tr> 1104 1996 <th className="px-2 py-3">Time</th> 1105 1997 <th className="px-2 py-3">Twitter User</th> 1998 + <th className="px-2 py-3">Group</th> 1106 1999 <th className="px-2 py-3">Status</th> 1107 2000 <th className="px-2 py-3">Details</th> 1108 2001 <th className="px-2 py-3 text-right">Link</th> 1109 2002 </tr> 1110 2003 </thead> 1111 2004 <tbody> 1112 - {recentActivity.map((activity, index) => { 2005 + {filteredRecentActivity.map((activity, index) => { 1113 2006 const href = getBskyPostUrl(activity); 2007 + const sourceTweetUrl = getTwitterPostUrl(activity.twitter_username, activity.twitter_id); 2008 + const mapping = resolveMappingForActivity(activity); 2009 + const groupMeta = getMappingGroupMeta(mapping); 1114 2010 1115 2011 return ( 1116 2012 <tr 1117 2013 key={`${activity.twitter_id}-${activity.created_at || index}`} 1118 - className="border-b border-border/60 last:border-0" 2014 + className="interactive-row border-b border-border/60 last:border-0" 1119 2015 > 1120 2016 <td className="px-2 py-3 align-top text-xs text-muted-foreground"> 1121 - {activity.created_at ? new Date(activity.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '--'} 2017 + {activity.created_at 2018 + ? new Date(activity.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) 2019 + : '--'} 1122 2020 </td> 1123 2021 <td className="px-2 py-3 align-top font-medium">@{activity.twitter_username}</td> 1124 2022 <td className="px-2 py-3 align-top"> 2023 + <Badge variant="outline"> 2024 + {groupMeta.emoji} {groupMeta.name} 2025 + </Badge> 2026 + </td> 2027 + <td className="px-2 py-3 align-top"> 1125 2028 {activity.status === 'migrated' ? ( 1126 2029 <Badge variant="success">Migrated</Badge> 1127 2030 ) : activity.status === 'skipped' ? ( ··· 1134 2037 <div className="max-w-[340px] truncate">{activity.tweet_text || `Tweet ID: ${activity.twitter_id}`}</div> 1135 2038 </td> 1136 2039 <td className="px-2 py-3 align-top text-right"> 1137 - {href ? ( 1138 - <a className="inline-flex items-center text-xs text-foreground underline-offset-4 hover:underline" href={href} target="_blank" rel="noreferrer"> 1139 - Open 1140 - <ArrowUpRight className="ml-1 h-3 w-3" /> 1141 - </a> 1142 - ) : ( 1143 - <span className="text-xs text-muted-foreground">--</span> 1144 - )} 2040 + <div className="flex flex-col items-end gap-1"> 2041 + {sourceTweetUrl ? ( 2042 + <a 2043 + className="inline-flex items-center text-xs text-foreground underline-offset-4 hover:underline" 2044 + href={sourceTweetUrl} 2045 + target="_blank" 2046 + rel="noreferrer" 2047 + > 2048 + Source 2049 + <ArrowUpRight className="ml-1 h-3 w-3" /> 2050 + </a> 2051 + ) : null} 2052 + {href ? ( 2053 + <a 2054 + className="inline-flex items-center text-xs text-foreground underline-offset-4 hover:underline" 2055 + href={href} 2056 + target="_blank" 2057 + rel="noreferrer" 2058 + > 2059 + Bluesky 2060 + <ArrowUpRight className="ml-1 h-3 w-3" /> 2061 + </a> 2062 + ) : ( 2063 + <span className="text-xs text-muted-foreground">--</span> 2064 + )} 2065 + </div> 1145 2066 </td> 1146 2067 </tr> 1147 2068 ); 1148 2069 })} 1149 - {recentActivity.length === 0 ? ( 2070 + {filteredRecentActivity.length === 0 ? ( 1150 2071 <tr> 1151 - <td className="px-2 py-6 text-center text-sm text-muted-foreground" colSpan={5}> 1152 - No activity yet. 2072 + <td className="px-2 py-6 text-center text-sm text-muted-foreground" colSpan={6}> 2073 + No activity for this filter. 1153 2074 </td> 1154 2075 </tr> 1155 2076 ) : null} ··· 1159 2080 </CardContent> 1160 2081 </Card> 1161 2082 </section> 2083 + ) : null} 1162 2084 1163 - {isAdmin ? ( 1164 - <aside className="space-y-6"> 2085 + {activeTab === 'settings' ? ( 2086 + isAdmin ? ( 2087 + <section className="space-y-6 animate-fade-in"> 1165 2088 <Card className="animate-slide-up"> 1166 2089 <CardHeader> 1167 2090 <CardTitle className="flex items-center gap-2"> ··· 1233 2156 <form className="space-y-3 border-t border-border pt-6" onSubmit={handleSaveAiConfig}> 1234 2157 <div className="flex items-center justify-between"> 1235 2158 <h3 className="text-sm font-semibold">AI Settings</h3> 1236 - <Badge variant={aiConfig.apiKey ? 'success' : 'outline'}>{aiConfig.apiKey ? 'Configured' : 'Optional'}</Badge> 2159 + <Badge variant={aiConfig.apiKey ? 'success' : 'outline'}> 2160 + {aiConfig.apiKey ? 'Configured' : 'Optional'} 2161 + </Badge> 1237 2162 </div> 1238 2163 <div className="space-y-2"> 1239 2164 <Label htmlFor="provider">Provider</Label> ··· 1308 2233 required 1309 2234 /> 1310 2235 </div> 2236 + <div className="grid gap-3 sm:grid-cols-[1fr_auto]"> 2237 + <div className="space-y-2"> 2238 + <Label htmlFor="groupName">Folder / Group Name</Label> 2239 + <Input 2240 + id="groupName" 2241 + value={newMapping.groupName} 2242 + onChange={(event) => { 2243 + setNewMapping((prev) => ({ ...prev, groupName: event.target.value })); 2244 + }} 2245 + placeholder="Gaming, News, Sports..." 2246 + /> 2247 + </div> 2248 + <div className="space-y-2"> 2249 + <Label htmlFor="groupEmoji">Emoji</Label> 2250 + <Input 2251 + id="groupEmoji" 2252 + value={newMapping.groupEmoji} 2253 + onChange={(event) => { 2254 + setNewMapping((prev) => ({ ...prev, groupEmoji: event.target.value })); 2255 + }} 2256 + placeholder="📁" 2257 + maxLength={8} 2258 + /> 2259 + </div> 2260 + </div> 1311 2261 <div className="space-y-2"> 1312 - <Label htmlFor="twitterUsernames">Twitter Usernames (comma separated)</Label> 1313 - <Input 1314 - id="twitterUsernames" 1315 - value={newMapping.twitterUsernames} 1316 - onChange={(event) => { 1317 - setNewMapping((prev) => ({ ...prev, twitterUsernames: event.target.value })); 1318 - }} 1319 - required 1320 - /> 2262 + <Label htmlFor="twitterUsernames">Twitter Usernames</Label> 2263 + <div className="flex gap-2"> 2264 + <Input 2265 + id="twitterUsernames" 2266 + value={newTwitterInput} 2267 + onChange={(event) => { 2268 + setNewTwitterInput(event.target.value); 2269 + }} 2270 + onKeyDown={(event) => { 2271 + if (event.key === 'Enter' || event.key === ',') { 2272 + event.preventDefault(); 2273 + addNewTwitterUsername(); 2274 + } 2275 + }} 2276 + placeholder="@accountname (press Enter to add)" 2277 + /> 2278 + <Button 2279 + variant="outline" 2280 + size="sm" 2281 + type="button" 2282 + disabled={normalizeTwitterUsername(newTwitterInput).length === 0} 2283 + onClick={addNewTwitterUsername} 2284 + > 2285 + Add 2286 + </Button> 2287 + </div> 2288 + <p className="text-xs text-muted-foreground">Press Enter or comma to add multiple handles quickly.</p> 2289 + <div className="flex min-h-7 flex-wrap gap-2"> 2290 + {newTwitterUsers.map((username) => ( 2291 + <Badge key={`new-${username}`} variant="secondary" className="gap-1 pr-1"> 2292 + @{username} 2293 + <button 2294 + type="button" 2295 + className="rounded-full px-1 text-muted-foreground transition hover:bg-background hover:text-foreground" 2296 + onClick={() => removeNewTwitterUsername(username)} 2297 + aria-label={`Remove @${username}`} 2298 + > 2299 + × 2300 + </button> 2301 + </Badge> 2302 + ))} 2303 + </div> 1321 2304 </div> 1322 2305 <div className="space-y-2"> 1323 2306 <Label htmlFor="bskyIdentifier">Bluesky Identifier</Label> ··· 1392 2375 Import configuration 1393 2376 </Button> 1394 2377 <p className="text-xs text-muted-foreground"> 1395 - Imports preserve dashboard users and passwords while replacing mappings, provider keys, and scheduler settings. 2378 + Imports preserve dashboard users and passwords while replacing mappings, provider keys, and scheduler 2379 + settings. 1396 2380 </p> 1397 2381 </CardContent> 1398 2382 </Card> 1399 - </aside> 1400 - ) : null} 1401 - </div> 2383 + </section> 2384 + ) : null 2385 + ) : null} 1402 2386 1403 2387 {editingMapping ? ( 1404 2388 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"> ··· 1420 2404 required 1421 2405 /> 1422 2406 </div> 2407 + <div className="grid gap-3 sm:grid-cols-[1fr_auto]"> 2408 + <div className="space-y-2"> 2409 + <Label htmlFor="edit-groupName">Folder / Group Name</Label> 2410 + <Input 2411 + id="edit-groupName" 2412 + value={editForm.groupName} 2413 + onChange={(event) => { 2414 + setEditForm((prev) => ({ ...prev, groupName: event.target.value })); 2415 + }} 2416 + placeholder="Gaming, News, Sports..." 2417 + /> 2418 + </div> 2419 + <div className="space-y-2"> 2420 + <Label htmlFor="edit-groupEmoji">Emoji</Label> 2421 + <Input 2422 + id="edit-groupEmoji" 2423 + value={editForm.groupEmoji} 2424 + onChange={(event) => { 2425 + setEditForm((prev) => ({ ...prev, groupEmoji: event.target.value })); 2426 + }} 2427 + placeholder="📁" 2428 + maxLength={8} 2429 + /> 2430 + </div> 2431 + </div> 1423 2432 <div className="space-y-2"> 1424 2433 <Label htmlFor="edit-twitterUsernames">Twitter Usernames</Label> 1425 - <Input 1426 - id="edit-twitterUsernames" 1427 - value={editForm.twitterUsernames} 1428 - onChange={(event) => { 1429 - setEditForm((prev) => ({ ...prev, twitterUsernames: event.target.value })); 1430 - }} 1431 - required 1432 - /> 2434 + <div className="flex gap-2"> 2435 + <Input 2436 + id="edit-twitterUsernames" 2437 + value={editTwitterInput} 2438 + onChange={(event) => { 2439 + setEditTwitterInput(event.target.value); 2440 + }} 2441 + onKeyDown={(event) => { 2442 + if (event.key === 'Enter' || event.key === ',') { 2443 + event.preventDefault(); 2444 + addEditTwitterUsername(); 2445 + } 2446 + }} 2447 + placeholder="@accountname" 2448 + /> 2449 + <Button 2450 + variant="outline" 2451 + size="sm" 2452 + type="button" 2453 + disabled={normalizeTwitterUsername(editTwitterInput).length === 0} 2454 + onClick={addEditTwitterUsername} 2455 + > 2456 + Add 2457 + </Button> 2458 + </div> 2459 + <div className="flex min-h-7 flex-wrap gap-2"> 2460 + {editTwitterUsers.map((username) => ( 2461 + <Badge key={`edit-${username}`} variant="secondary" className="gap-1 pr-1"> 2462 + @{username} 2463 + <button 2464 + type="button" 2465 + className="rounded-full px-1 text-muted-foreground transition hover:bg-background hover:text-foreground" 2466 + onClick={() => removeEditTwitterUsername(username)} 2467 + aria-label={`Remove @${username}`} 2468 + > 2469 + × 2470 + </button> 2471 + </Badge> 2472 + ))} 2473 + </div> 1433 2474 </div> 1434 2475 <div className="space-y-2"> 1435 2476 <Label htmlFor="edit-bskyIdentifier">Bluesky Identifier</Label>
+3 -1
web/src/components/ui/badge.tsx
··· 2 2 import { cva, type VariantProps } from 'class-variance-authority'; 3 3 import { cn } from '../../lib/utils'; 4 4 5 - const badgeVariants = cva('inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold', { 5 + const badgeVariants = cva( 6 + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors duration-200 motion-reduce:transition-none', 7 + { 6 8 variants: { 7 9 variant: { 8 10 default: 'border-transparent bg-foreground text-background',
+5 -5
web/src/components/ui/button.tsx
··· 3 3 import { cn } from '../../lib/utils'; 4 4 5 5 const buttonVariants = cva( 6 - 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]', 6 + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-[transform,box-shadow,background-color,color,border-color,opacity] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 motion-reduce:transform-none motion-reduce:transition-none motion-safe:hover:-translate-y-[1px] motion-safe:active:translate-y-0 motion-safe:active:scale-[0.98]', 7 7 { 8 8 variants: { 9 9 variant: { 10 - default: 'bg-foreground text-background shadow hover:opacity-90', 11 - outline: 'border border-border bg-background text-foreground hover:bg-muted', 10 + default: 'bg-foreground text-background shadow-sm hover:opacity-90 hover:shadow', 11 + outline: 'border border-border bg-background text-foreground shadow-sm hover:bg-muted hover:shadow', 12 12 ghost: 'text-muted-foreground hover:bg-muted hover:text-foreground', 13 - destructive: 'bg-red-600 text-white hover:bg-red-700', 14 - subtle: 'bg-muted text-foreground hover:bg-muted/80', 13 + destructive: 'bg-red-600 text-white shadow-sm hover:bg-red-700 hover:shadow', 14 + subtle: 'bg-muted text-foreground shadow-sm hover:bg-muted/80 hover:shadow', 15 15 }, 16 16 size: { 17 17 default: 'h-10 px-4 py-2',
+4 -1
web/src/components/ui/card.tsx
··· 4 4 const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => ( 5 5 <div 6 6 ref={ref} 7 - className={cn('rounded-xl border border-border/70 bg-card/95 text-card-foreground shadow-sm backdrop-blur', className)} 7 + className={cn( 8 + 'rounded-xl border border-border/70 bg-card/95 text-card-foreground shadow-sm backdrop-blur 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', 9 + className, 10 + )} 8 11 {...props} 9 12 /> 10 13 ));
+1 -1
web/src/components/ui/input.tsx
··· 6 6 <input 7 7 type={type} 8 8 className={cn( 9 - 'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 9 + 'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-[border-color,box-shadow,background-color] duration-150 ease-out file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none', 10 10 className, 11 11 )} 12 12 ref={ref}
+22
web/src/index.css
··· 47 47 @apply min-h-screen; 48 48 } 49 49 50 + html { 51 + scroll-behavior: smooth; 52 + } 53 + 50 54 body { 51 55 @apply bg-background text-foreground antialiased; 52 56 font-family: 'Space Grotesk', system-ui, sans-serif; ··· 63 67 radial-gradient(circle at 85% 0%, rgba(255, 255, 255, 0.06) 0, transparent 24%), 64 68 linear-gradient(145deg, rgba(11, 11, 11, 0.95), rgba(18, 18, 18, 0.86)); 65 69 } 70 + 71 + @media (prefers-reduced-motion: reduce) { 72 + html { 73 + scroll-behavior: auto; 74 + } 75 + 76 + *, 77 + *::before, 78 + *::after { 79 + animation-duration: 1ms !important; 80 + animation-iteration-count: 1 !important; 81 + transition-duration: 1ms !important; 82 + } 83 + } 66 84 } 67 85 68 86 @layer components { 69 87 .panel-grid { 70 88 @apply grid gap-6 lg:grid-cols-[2fr_1fr]; 89 + } 90 + 91 + .interactive-row { 92 + @apply transition-colors duration-200 motion-reduce:transition-none hover:bg-muted/45; 71 93 } 72 94 }
+5
web/tailwind.config.cjs
··· 42 42 from: { opacity: '0', transform: 'translateY(8px)' }, 43 43 to: { opacity: '1', transform: 'translateY(0)' }, 44 44 }, 45 + 'pop-in': { 46 + from: { opacity: '0', transform: 'scale(0.98)' }, 47 + to: { opacity: '1', transform: 'scale(1)' }, 48 + }, 45 49 }, 46 50 animation: { 47 51 'fade-in': 'fade-in 320ms ease-out', 48 52 'slide-up': 'slide-up 420ms ease-out', 53 + 'pop-in': 'pop-in 220ms ease-out', 49 54 }, 50 55 }, 51 56 },