Files
sapiens-mobile/.local/state/replit/agent/filesystem/filesystem_state.json
jungwoo choi 919afe56f2 feat: SAPIENS Mobile App - Initial commit
React Native mobile application for SAPIENS news platform.
Consolidated all previous history into single commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 14:30:25 +09:00

1 line
579 KiB
JSON

{"file_contents":{"design_guidelines.md":{"content":"# Mobile News App Design Guidelines\n\n## Design Approach\n**Reference-Based Approach**: Drawing inspiration from modern mobile news apps like Apple News, Google News, and Flipboard, with emphasis on clean typography, intuitive navigation, and content-first design.\n\n## Core Design Elements\n\n### A. Color Palette\n**Primary Colors:**\n- Dark mode: Background 222 15% 8%, Surface 222 15% 12%, Text 210 15% 95%\n- Light mode: Background 0 0% 98%, Surface 0 0% 100%, Text 222 15% 15%\n\n**Accent Colors:**\n- Primary brand: 210 85% 55% (modern blue)\n- Success/active: 142 76% 45% (green for interactions)\n\n### B. Typography\n**Font Stack:** Inter (Google Fonts) for exceptional mobile readability\n- Headlines: 600 weight, 1.2rem-1.8rem\n- Body text: 400 weight, 0.875rem-1rem\n- Captions: 500 weight, 0.75rem-0.825rem\n\n### C. Layout System\n**Spacing Units:** Tailwind units of 2, 4, 6, and 8 (p-2, m-4, gap-6, h-8)\n- Consistent 4-unit grid system for mobile optimization\n- 6-unit spacing for section breaks\n- 2-unit for tight element spacing\n\n### D. Component Library\n\n**Navigation:**\n- Sticky header with search icon, logo, and profile/language selector\n- Bottom tab navigation for main categories (People, Topics, Companies)\n- Swipeable horizontal card carousel on homepage\n\n**Content Cards:**\n- Article preview cards with AI-generated thumbnails (16:9 aspect ratio)\n- Media outlet profile cards with circular avatars and bio sections\n- Consistent card shadows: shadow-sm with subtle elevation\n\n**Interactive Elements:**\n- Search overlay with instant results filtering\n- Language selector dropdown with flag icons\n- Smooth scroll-based pagination for article feeds\n\n**Article Layout:**\n- Full-width hero images with gradient overlays\n- Navigation arrows for previous/next article browsing\n- Floating action buttons for sharing and bookmarking\n\n### E. Mobile-First Considerations\n- Touch-friendly tap targets (minimum 44px)\n- Thumb-zone navigation placement\n- Optimized scroll performance with virtual scrolling\n- Progressive image loading for thumbnails\n\n## Images\n**AI-Generated Thumbnails:** Each article requires a 16:9 landscape thumbnail generated via DALL-E based on article content. Place thumbnails prominently on article cards and as hero images on detail pages.\n\n**Media Outlet Avatars:** Circular profile images (64px) for each media outlet representing their focus area (People: portrait styles, Topics: abstract concepts, Companies: corporate/brand imagery).\n\n**No Large Hero Image:** The design prioritizes content discovery over large hero sections, focusing on article thumbnails and clean typography instead.","size_bytes":2612},"drizzle.config.ts":{"content":"import { defineConfig } from \"drizzle-kit\";\n\nif (!process.env.DATABASE_URL) {\n throw new Error(\"DATABASE_URL, ensure the database is provisioned\");\n}\n\nexport default defineConfig({\n out: \"./migrations\",\n schema: \"./shared/schema.ts\",\n dialect: \"postgresql\",\n dbCredentials: {\n url: process.env.DATABASE_URL,\n },\n});\n","size_bytes":325},"postcss.config.js":{"content":"export default {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n}\n","size_bytes":80},"replit.md":{"content":"# Sapiens Mobile News Platform\n\n## Overview\n\nThis is a mobile-first news application that provides specialized media outlets under the \"Sapiens\" brand. The platform features 11 distinct outlets categorized by People (Jacob Robert Steeves, Ala Shaabana, Joseph Jacks, Robert Myers), Topics (Alt coin, Stable coin, Bittensor), and Companies (xTAO, YUMA, TAOX, Oblong). Each outlet has expandable 3-bullet-point profiles (first bullet always visible, click to expand remaining 2), 20 scrollable articles per outlet, and uses real uploaded images for branding and thumbnails.\n\n## User Preferences\n\nPreferred communication style: Simple, everyday language.\n\n## System Architecture\n\n**Frontend Architecture**: React-based single-page application using Vite as the build tool and TypeScript for type safety. The application follows a mobile-first responsive design approach using Tailwind CSS for styling and shadcn/ui for component library. Navigation is handled by Wouter for client-side routing, with pages for home, outlet details, and individual articles.\n\n**Component Structure**: Modular component architecture with reusable UI components including ArticleCard, OutletCard, MobileHeader, SearchOverlay, SwipeableCarousel, and CategoryTabs. Components are organized with clear separation between UI components, page components, and business logic.\n\n**State Management**: Uses TanStack Query (React Query) for server state management, API calls, and caching. Local state is managed through React hooks for UI interactions like search overlay, language selection, and carousel navigation.\n\n**Backend Architecture**: Express.js server providing RESTful API endpoints for outlets, articles, and search functionality. Uses in-memory storage implementation (MemStorage class) as the data layer with interfaces designed for easy migration to database storage later.\n\n**Data Schema**: Drizzle ORM schema definitions for Users, MediaOutlets, and Articles with PostgreSQL as the intended database. Schema includes support for categories (people/topics/companies), focus subjects, article metadata, and user preferences.\n\n**Mobile-Optimized Design**: Design system based on modern news apps like Apple News and Google News, featuring clean typography with Inter font, consistent spacing using Tailwind's 4-unit grid system, and mobile-specific UI patterns like swipeable carousels and sticky headers.\n\n**Content Management**: Real data implementation provides sample articles and outlets with professional images and real profile photos. Content includes detailed profiles with expandable 3-bullet-point biographies for Jacob Robert Steeves, Ala Shaabana, Joseph Jacks, Robert Myers, and 7 other outlets covering alt coins, stable coins, Bittensor, and companies like xTAO, YUMA, TAOX, and Oblong. Each outlet has 20 identical articles for scrolling functionality. All placeholder images have been replaced with actual uploaded photos and branded thumbnails.\n\n## External Dependencies\n\n**UI Framework**: Radix UI primitives for accessible component foundations, shadcn/ui component library for pre-built components, Tailwind CSS for utility-first styling, and class-variance-authority for component variants.\n\n**State & API Management**: TanStack Query for data fetching and caching, Wouter for lightweight client-side routing.\n\n**Development Tools**: Vite for fast development and building, TypeScript for type safety, PostCSS with Autoprefixer for CSS processing.\n\n**Backend Dependencies**: Express.js for server framework, Drizzle ORM for database schema and migrations, Neon Database (@neondatabase/serverless) for PostgreSQL hosting.\n\n**Potential Integrations**: OpenAI API configured for AI-generated content and thumbnails, though not actively used in current implementation. Google Fonts (Inter) for typography consistency.\n\n**Form Handling**: React Hook Form with Hookform Resolvers for form validation, Zod for schema validation integrated with Drizzle.\n\n**Date Utilities**: date-fns for date formatting and manipulation throughout the application.","size_bytes":4046},"tailwind.config.ts":{"content":"import type { Config } from \"tailwindcss\";\n\nexport default {\n darkMode: [\"class\"],\n content: [\"./client/index.html\", \"./client/src/**/*.{js,jsx,ts,tsx}\"],\n theme: {\n extend: {\n borderRadius: {\n lg: \".5625rem\", /* 9px */\n md: \".375rem\", /* 6px */\n sm: \".1875rem\", /* 3px */\n },\n colors: {\n // Flat / base colors (regular buttons)\n background: \"hsl(var(--background) / <alpha-value>)\",\n foreground: \"hsl(var(--foreground) / <alpha-value>)\",\n border: \"hsl(var(--border) / <alpha-value>)\",\n input: \"hsl(var(--input) / <alpha-value>)\",\n card: {\n DEFAULT: \"hsl(var(--card) / <alpha-value>)\",\n foreground: \"hsl(var(--card-foreground) / <alpha-value>)\",\n border: \"hsl(var(--card-border) / <alpha-value>)\",\n },\n popover: {\n DEFAULT: \"hsl(var(--popover) / <alpha-value>)\",\n foreground: \"hsl(var(--popover-foreground) / <alpha-value>)\",\n border: \"hsl(var(--popover-border) / <alpha-value>)\",\n },\n primary: {\n DEFAULT: \"hsl(var(--primary) / <alpha-value>)\",\n foreground: \"hsl(var(--primary-foreground) / <alpha-value>)\",\n border: \"var(--primary-border)\",\n },\n secondary: {\n DEFAULT: \"hsl(var(--secondary) / <alpha-value>)\",\n foreground: \"hsl(var(--secondary-foreground) / <alpha-value>)\",\n border: \"var(--secondary-border)\",\n },\n muted: {\n DEFAULT: \"hsl(var(--muted) / <alpha-value>)\",\n foreground: \"hsl(var(--muted-foreground) / <alpha-value>)\",\n border: \"var(--muted-border)\",\n },\n accent: {\n DEFAULT: \"hsl(var(--accent) / <alpha-value>)\",\n foreground: \"hsl(var(--accent-foreground) / <alpha-value>)\",\n border: \"var(--accent-border)\",\n },\n destructive: {\n DEFAULT: \"hsl(var(--destructive) / <alpha-value>)\",\n foreground: \"hsl(var(--destructive-foreground) / <alpha-value>)\",\n border: \"var(--destructive-border)\",\n },\n ring: \"hsl(var(--ring) / <alpha-value>)\",\n chart: {\n \"1\": \"hsl(var(--chart-1) / <alpha-value>)\",\n \"2\": \"hsl(var(--chart-2) / <alpha-value>)\",\n \"3\": \"hsl(var(--chart-3) / <alpha-value>)\",\n \"4\": \"hsl(var(--chart-4) / <alpha-value>)\",\n \"5\": \"hsl(var(--chart-5) / <alpha-value>)\",\n },\n sidebar: {\n ring: \"hsl(var(--sidebar-ring) / <alpha-value>)\",\n DEFAULT: \"hsl(var(--sidebar) / <alpha-value>)\",\n foreground: \"hsl(var(--sidebar-foreground) / <alpha-value>)\",\n border: \"hsl(var(--sidebar-border) / <alpha-value>)\",\n },\n \"sidebar-primary\": {\n DEFAULT: \"hsl(var(--sidebar-primary) / <alpha-value>)\",\n foreground: \"hsl(var(--sidebar-primary-foreground) / <alpha-value>)\",\n border: \"var(--sidebar-primary-border)\",\n },\n \"sidebar-accent\": {\n DEFAULT: \"hsl(var(--sidebar-accent) / <alpha-value>)\",\n foreground: \"hsl(var(--sidebar-accent-foreground) / <alpha-value>)\",\n border: \"var(--sidebar-accent-border)\"\n },\n status: {\n online: \"rgb(34 197 94)\",\n away: \"rgb(245 158 11)\",\n busy: \"rgb(239 68 68)\",\n offline: \"rgb(156 163 175)\",\n },\n },\n fontFamily: {\n sans: [\"var(--font-sans)\"],\n serif: [\"var(--font-serif)\"],\n mono: [\"var(--font-mono)\"],\n },\n keyframes: {\n \"accordion-down\": {\n from: { height: \"0\" },\n to: { height: \"var(--radix-accordion-content-height)\" },\n },\n \"accordion-up\": {\n from: { height: \"var(--radix-accordion-content-height)\" },\n to: { height: \"0\" },\n },\n },\n animation: {\n \"accordion-down\": \"accordion-down 0.2s ease-out\",\n \"accordion-up\": \"accordion-up 0.2s ease-out\",\n },\n },\n },\n plugins: [require(\"tailwindcss-animate\"), require(\"@tailwindcss/typography\")],\n} satisfies Config;\n","size_bytes":4050},"vite.config.ts":{"content":"import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport path from \"path\";\nimport runtimeErrorOverlay from \"@replit/vite-plugin-runtime-error-modal\";\n\nexport default defineConfig({\n plugins: [\n react(),\n runtimeErrorOverlay(),\n ...(process.env.NODE_ENV !== \"production\" &&\n process.env.REPL_ID !== undefined\n ? [\n await import(\"@replit/vite-plugin-cartographer\").then((m) =>\n m.cartographer(),\n ),\n ]\n : []),\n ],\n resolve: {\n alias: {\n \"@\": path.resolve(import.meta.dirname, \"client\", \"src\"),\n \"@shared\": path.resolve(import.meta.dirname, \"shared\"),\n \"@assets\": path.resolve(import.meta.dirname, \"attached_assets\"),\n },\n },\n root: path.resolve(import.meta.dirname, \"client\"),\n build: {\n outDir: path.resolve(import.meta.dirname, \"dist/public\"),\n emptyOutDir: true,\n },\n server: {\n fs: {\n strict: true,\n deny: [\"**/.*\"],\n },\n },\n});\n","size_bytes":971},"server/index.ts":{"content":"import express, { type Request, Response, NextFunction } from \"express\";\nimport { registerRoutes } from \"./routes\";\nimport { setupVite, serveStatic, log } from \"./vite\";\n\nconst app = express();\napp.use(express.json());\napp.use(express.urlencoded({ extended: false }));\n\napp.use((req, res, next) => {\n const start = Date.now();\n const path = req.path;\n let capturedJsonResponse: Record<string, any> | undefined = undefined;\n\n const originalResJson = res.json;\n res.json = function (bodyJson, ...args) {\n capturedJsonResponse = bodyJson;\n return originalResJson.apply(res, [bodyJson, ...args]);\n };\n\n res.on(\"finish\", () => {\n const duration = Date.now() - start;\n if (path.startsWith(\"/api\")) {\n let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;\n if (capturedJsonResponse) {\n logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;\n }\n\n if (logLine.length > 80) {\n logLine = logLine.slice(0, 79) + \"…\";\n }\n\n log(logLine);\n }\n });\n\n next();\n});\n\n(async () => {\n const server = await registerRoutes(app);\n\n app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {\n const status = err.status || err.statusCode || 500;\n const message = err.message || \"Internal Server Error\";\n\n res.status(status).json({ message });\n throw err;\n });\n\n // importantly only setup vite in development and after\n // setting up all the other routes so the catch-all route\n // doesn't interfere with the other routes\n const isProduction = app.get(\"env\") === \"production\" || process.env.REPLIT_DEPLOYMENT === \"1\";\n \n if (!isProduction) {\n await setupVite(app, server);\n } else {\n log(\"Setting up static file serving for production\");\n \n // Protect unmatched API routes from static file serving catch-all\n // This should come after all API routes are registered\n app.use('/api/*', (req, res) => {\n res.status(404).json({ error: `API endpoint not found: ${req.path}` });\n });\n \n serveStatic(app);\n }\n\n // ALWAYS serve the app on the port specified in the environment variable PORT\n // Other ports are firewalled. Default to 5000 if not specified.\n // this serves both the API and the client.\n // It is the only port that is not firewalled.\n const port = parseInt(process.env.PORT || '5000', 10);\n server.listen({\n port,\n host: \"0.0.0.0\",\n reusePort: true,\n }, () => {\n log(`serving on port ${port}`);\n });\n})();\n","size_bytes":2470},"server/routes.ts":{"content":"import type { Express } from \"express\";\nimport { createServer, type Server } from \"http\";\nimport express from \"express\";\nimport path from \"path\";\nimport { storage } from \"./storage\";\nimport { insertArticleSchema, insertMediaOutletSchema, type Article, type MediaOutlet } from \"@shared/schema\";\nimport OpenAI from \"openai\";\nimport { WebScraper } from './scraper';\nimport { OutletParser } from './outletParser';\nimport sharp from 'sharp';\nimport fs from 'fs';\n\n// the newest OpenAI model is \"gpt-5\" which was released August 7, 2025. do not change this unless explicitly requested by the user\nconst openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });\n\n// Utility functions for thumbnail generation\nfunction needsThumbnail(article: Article): boolean {\n // Check if article has no thumbnail or uses default placeholder\n return !article.thumbnail || \n article.thumbnail === '/api/assets/default-article.png' ||\n article.thumbnail.trim() === '';\n}\n\n// Function to format time ago - returns consistent random \"X min ago\" for each article\nfunction formatTimeAgo(date: Date | string, articleId?: string): string {\n // If we have an article ID, generate a consistent random value for it\n if (articleId) {\n const hash = articleId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);\n const minutes = (hash % 59) + 1; // 1 to 59\n return `${minutes} min ago`;\n }\n \n // Fallback to actual time calculation\n const now = new Date();\n const past = new Date(date);\n const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);\n let diffInMinutes = Math.floor(diffInSeconds / 60);\n \n // Clamp to 1-59 range\n if (diffInMinutes < 1) diffInMinutes = 1;\n if (diffInMinutes > 59) diffInMinutes = 59;\n \n return `${diffInMinutes} min ago`;\n}\n\n// Function to generate varied publication times for articles (1-59 minutes ago only)\nfunction generateVariedPublishedAt(seed?: string): Date {\n const now = new Date();\n \n // Use seed (title/summary) or random to create varied offsets\n const seedValue = seed || Math.random().toString();\n const hash = seedValue.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);\n const random = Math.abs(Math.sin(hash)) * 1000;\n \n // Generate varied minutes between 1-59 minutes ago\n const offsetMinutes = Math.floor((random * 1000) % 59) + 1; // 1 to 59 minutes\n \n return new Date(now.getTime() - offsetMinutes * 60 * 1000);\n}\n\nfunction generateImagePrompt(article: Article, outlet?: MediaOutlet): string {\n const outletContext = outlet ? ` related to ${outlet.focusSubject}` : '';\n return `Professional news thumbnail image for article: \"${article.title}\". ${article.summary || 'News article'}${outletContext}. Modern, clean, journalistic style, suitable for mobile news app, 16:9 aspect ratio, no text overlays, photorealistic.`;\n}\n\nasync function saveGeneratedImage(imageUrlOrBase64: string, filename: string): Promise<string> {\n try {\n // Create directory if it doesn't exist\n const generatedImagesDir = path.resolve(process.cwd(), 'attached_assets', 'generated_images');\n if (!fs.existsSync(generatedImagesDir)) {\n fs.mkdirSync(generatedImagesDir, { recursive: true });\n }\n\n let buffer: Buffer;\n \n if (imageUrlOrBase64.startsWith('data:image/')) {\n // Handle base64 data\n const base64Data = imageUrlOrBase64.split(',')[1];\n buffer = Buffer.from(base64Data, 'base64');\n } else {\n // Handle URL\n const response = await fetch(imageUrlOrBase64);\n if (!response.ok) {\n throw new Error(`Failed to fetch image: ${response.statusText}`);\n }\n buffer = Buffer.from(await response.arrayBuffer());\n }\n \n // Generate thumbnail using sharp\n const thumbnailPath = path.join(generatedImagesDir, `thumb_${filename}.png`);\n const fullImagePath = path.join(generatedImagesDir, `${filename}.png`);\n \n // Save full size image\n await sharp(buffer)\n .png({ quality: 90 })\n .toFile(fullImagePath);\n \n // Create thumbnail (400x225 for 16:9 ratio)\n await sharp(buffer)\n .resize(400, 225, { fit: 'cover', position: 'center' })\n .png({ quality: 80 })\n .toFile(thumbnailPath);\n \n // Return the relative path for the thumbnail\n return `/api/assets/generated_images/thumb_${filename}.png`;\n } catch (error) {\n console.error('Error saving generated image:', error);\n throw error;\n }\n}\n\nexport async function registerRoutes(app: Express): Promise<Server> {\n // Health check\n app.get(\"/api/health\", (req, res) => {\n res.json({ status: \"ok\" });\n });\n\n // Media outlets routes\n app.get(\"/api/outlets\", async (req, res) => {\n try {\n const { category } = req.query;\n let outlets;\n \n if (category && typeof category === 'string') {\n outlets = await storage.getOutletsByCategory(category);\n } else {\n outlets = await storage.getAllOutlets();\n }\n \n // Add article count to each outlet (using efficient single query)\n const articleCounts = await storage.getArticleCountsByOutlets();\n const outletsWithCounts = outlets.map(outlet => ({\n ...outlet,\n articleCount: articleCounts[outlet.id] || 0\n }));\n \n res.json(outletsWithCounts);\n } catch (error) {\n console.error(\"Error fetching outlets:\", error);\n res.status(500).json({ error: \"Failed to fetch outlets\" });\n }\n });\n\n app.get(\"/api/outlets/:id\", async (req, res) => {\n try {\n const outlet = await storage.getOutletById(req.params.id);\n if (!outlet) {\n return res.status(404).json({ error: \"Outlet not found\" });\n }\n res.json(outlet);\n } catch (error) {\n console.error(\"Error fetching outlet:\", error);\n res.status(500).json({ error: \"Failed to fetch outlet\" });\n }\n });\n\n app.get(\"/api/outlets/:id/articles\", async (req, res) => {\n try {\n const articles = await storage.getArticlesByOutlet(req.params.id);\n res.json(articles);\n } catch (error) {\n console.error(\"Error fetching articles for outlet:\", error);\n res.status(500).json({ error: \"Failed to fetch articles\" });\n }\n });\n\n app.post(\"/api/outlets\", async (req, res) => {\n try {\n const validatedData = insertMediaOutletSchema.parse(req.body);\n const outlet = await storage.createOutlet(validatedData);\n res.status(201).json(outlet);\n } catch (error) {\n console.error(\"Error creating outlet:\", error);\n res.status(400).json({ error: \"Invalid outlet data\" });\n }\n });\n\n app.patch(\"/api/outlets/:id\", async (req, res) => {\n try {\n const partialSchema = insertMediaOutletSchema.omit({ id: true }).partial();\n const validatedData = partialSchema.parse(req.body);\n const outlet = await storage.updateOutlet(req.params.id, validatedData);\n if (!outlet) {\n return res.status(404).json({ error: \"Outlet not found\" });\n }\n res.json(outlet);\n } catch (error) {\n console.error(\"Error updating outlet:\", error);\n res.status(400).json({ error: \"Invalid outlet data\" });\n }\n });\n\n app.delete(\"/api/outlets/:id\", async (req, res) => {\n try {\n const success = await storage.deleteOutlet(req.params.id);\n if (!success) {\n return res.status(404).json({ error: \"Outlet not found\" });\n }\n res.status(204).send();\n } catch (error) {\n console.error(\"Error deleting outlet:\", error);\n res.status(500).json({ error: \"Failed to delete outlet\" });\n }\n });\n\n // Articles routes\n app.get(\"/api/articles\", async (req, res) => {\n try {\n const { outlet, featured, limit } = req.query;\n let articles;\n \n if (outlet && typeof outlet === 'string') {\n articles = await storage.getArticlesByOutlet(outlet);\n } else if (featured === 'true') {\n const limitNum = limit ? parseInt(limit as string) : 10;\n articles = await storage.getFeaturedArticles(limitNum);\n } else {\n articles = await storage.getAllArticles();\n }\n \n // Add timeAgo to each article first\n const articlesWithTimeAgo = articles.map(article => ({\n ...article,\n timeAgo: formatTimeAgo(article.publishedAt, article.id)\n }));\n \n // Sort by the minutes value in timeAgo (smallest to largest)\n articlesWithTimeAgo.sort((a, b) => {\n const aMin = parseInt(a.timeAgo.split(' ')[0]);\n const bMin = parseInt(b.timeAgo.split(' ')[0]);\n return aMin - bMin;\n });\n \n res.json(articlesWithTimeAgo);\n } catch (error) {\n console.error(\"Error fetching articles:\", error);\n res.status(500).json({ error: \"Failed to fetch articles\" });\n }\n });\n\n app.get(\"/api/articles/:id\", async (req, res) => {\n try {\n const article = await storage.getArticleById(req.params.id);\n if (!article) {\n return res.status(404).json({ error: \"Article not found\" });\n }\n // Add timeAgo to the article\n const articleWithTimeAgo = {\n ...article,\n timeAgo: formatTimeAgo(article.publishedAt, article.id)\n };\n res.json(articleWithTimeAgo);\n } catch (error) {\n console.error(\"Error fetching article:\", error);\n res.status(500).json({ error: \"Failed to fetch article\" });\n }\n });\n\n app.post(\"/api/articles\", async (req, res) => {\n try {\n const validatedData = insertArticleSchema.parse(req.body);\n const article = await storage.createArticle(validatedData);\n res.status(201).json(article);\n } catch (error) {\n console.error(\"Error creating article:\", error);\n res.status(400).json({ error: \"Invalid article data\" });\n }\n });\n\n app.patch(\"/api/articles/:id\", async (req, res) => {\n try {\n const { id } = req.params;\n const { thumbnail } = req.body;\n \n if (!thumbnail) {\n return res.status(400).json({ error: \"Thumbnail path required\" });\n }\n \n const updatedArticle = await storage.updateArticleThumbnail(id, thumbnail);\n if (!updatedArticle) {\n return res.status(404).json({ error: \"Article not found\" });\n }\n \n res.json(updatedArticle);\n } catch (error) {\n console.error(\"Error updating article:\", error);\n res.status(500).json({ error: \"Failed to update article\" });\n }\n });\n\n app.delete(\"/api/articles/:id\", async (req, res) => {\n try {\n const { id } = req.params;\n \n const success = await storage.deleteArticle(id);\n if (!success) {\n return res.status(404).json({ error: \"Article not found\" });\n }\n \n res.status(204).send();\n } catch (error) {\n console.error(\"Error deleting article:\", error);\n res.status(500).json({ error: \"Failed to delete article\" });\n }\n });\n\n // Search routes\n app.get(\"/api/search\", async (req, res) => {\n try {\n const { q, type = 'all' } = req.query;\n if (!q || typeof q !== 'string') {\n return res.status(400).json({ error: \"Search query required\" });\n }\n \n console.log(`[Search] Query: \"${q}\", Type: ${type}`);\n \n let articles: Article[] = [];\n let outlets: MediaOutlet[] = [];\n \n if (type === 'all' || type === 'articles') {\n articles = await storage.searchArticles(q);\n console.log(`[Search] Found ${articles.length} articles`);\n }\n \n if (type === 'all' || type === 'outlets') {\n const allOutlets = await storage.getAllOutlets();\n const searchTerm = q.toLowerCase();\n outlets = allOutlets.filter(outlet => {\n const nameMatch = outlet.name.toLowerCase().includes(searchTerm);\n const descMatch = outlet.description.toLowerCase().includes(searchTerm);\n const focusMatch = outlet.focusSubject.toLowerCase().includes(searchTerm);\n const bioMatch = outlet.bio && outlet.bio.toLowerCase().includes(searchTerm);\n \n return nameMatch || descMatch || focusMatch || bioMatch;\n });\n console.log(`[Search] Found ${outlets.length} outlets out of ${allOutlets.length} total outlets`);\n \n if (outlets.length > 0) {\n console.log(`[Search] Matching outlets: ${outlets.map(o => o.name).join(', ')}`);\n }\n }\n \n const result = { articles, outlets };\n console.log(`[Search] Returning ${articles.length} articles and ${outlets.length} outlets`);\n res.json(result);\n } catch (error) {\n console.error(\"Error searching:\", error);\n res.status(500).json({ error: \"Failed to search\" });\n }\n });\n\n // AI-powered image generation for articles\n app.post(\"/api/generate-thumbnail\", async (req, res) => {\n try {\n const { description, aspectRatio = \"16:9\" } = req.body;\n \n if (!description) {\n return res.status(400).json({ error: \"Description required\" });\n }\n\n const sizeMap = {\n \"16:9\": \"1792x1024\",\n \"1:1\": \"1024x1024\",\n \"9:16\": \"1024x1792\"\n };\n\n const size = sizeMap[aspectRatio as keyof typeof sizeMap] || \"1792x1024\";\n\n const response = await openai.images.generate({\n model: \"dall-e-3\",\n prompt: `Professional news article thumbnail: ${description}. High quality, modern, clean composition suitable for mobile news app.`,\n n: 1,\n size: size as any,\n quality: \"standard\",\n });\n\n // Robust handling of OpenAI response\n const imageData = response.data?.[0];\n if (!imageData) {\n throw new Error('No image data received from OpenAI');\n }\n \n const imageUrl = imageData.url || imageData.b64_json;\n if (!imageUrl) {\n throw new Error('No image URL or data received');\n }\n \n res.json({ \n url: imageData.url || `data:image/png;base64,${imageData.b64_json}`,\n format: imageData.url ? 'url' : 'base64'\n });\n } catch (error) {\n console.error(\"Error generating thumbnail:\", error);\n res.status(500).json({ error: \"Failed to generate thumbnail\" });\n }\n });\n\n // Generate thumbnail for a specific article\n app.post(\"/api/articles/:id/generate-thumbnail\", async (req, res) => {\n try {\n const { id } = req.params;\n \n // Get the article\n const article = await storage.getArticleById(id);\n if (!article) {\n return res.status(404).json({ error: \"Article not found\" });\n }\n\n // Check if article doesn't need a thumbnail (already has one that's not a placeholder)\n if (!needsThumbnail(article)) {\n return res.json({ \n message: \"Article already has a thumbnail\",\n thumbnailPath: article.thumbnail \n });\n }\n\n // Get the outlet for context\n const outlet = await storage.getOutletById(article.outletId);\n \n // Generate image prompt\n const prompt = await generateImagePrompt(article, outlet || undefined);\n \n // Generate image with OpenAI\n const response = await openai.images.generate({\n model: \"dall-e-3\",\n prompt,\n n: 1,\n size: \"1792x1024\",\n quality: \"standard\",\n });\n\n const imageData = response.data?.[0];\n if (!imageData) {\n throw new Error('No image data received from OpenAI');\n }\n \n const imageUrl = imageData.url || (imageData.b64_json ? `data:image/png;base64,${imageData.b64_json}` : null);\n if (!imageUrl) {\n throw new Error('No image URL or data received from OpenAI');\n }\n\n // Create filename\n const timestamp = Date.now();\n const randomId = Math.random().toString(36).substring(2, 8);\n const filename = `${article.title.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 50)}_${timestamp}_${randomId}`;\n\n // Save image and get thumbnail path\n const thumbnailPath = await saveGeneratedImage(imageUrl, filename);\n \n // Update article with new thumbnail\n const updatedArticle = await storage.updateArticleThumbnail(id, thumbnailPath);\n if (!updatedArticle) {\n throw new Error('Failed to update article thumbnail');\n }\n\n res.json({\n success: true,\n thumbnailPath,\n message: \"Thumbnail generated and saved successfully\"\n });\n\n } catch (error) {\n console.error(\"Error generating article thumbnail:\", error);\n res.status(500).json({ error: \"Failed to generate thumbnail\" });\n }\n });\n\n // Generate thumbnails for all articles that need them\n app.post(\"/api/articles/generate-thumbnails\", async (req, res) => {\n try {\n const { limit = 10 } = req.body; // Limit to prevent overwhelming the API\n \n // Get all articles\n const articles = await storage.getAllArticles();\n \n // Filter articles that need thumbnails\n const articlesNeedingThumbnails = articles\n .filter(needsThumbnail)\n .slice(0, Math.min(limit, 20)); // Cap at 20 to prevent abuse\n\n if (articlesNeedingThumbnails.length === 0) {\n return res.json({\n success: true,\n message: \"No articles need thumbnail generation\",\n processed: 0\n });\n }\n\n const results = [];\n const errors = [];\n\n // Process articles sequentially to avoid overwhelming OpenAI API\n for (const article of articlesNeedingThumbnails) {\n try {\n // Get the outlet for context\n const outlet = await storage.getOutletById(article.outletId);\n \n // Generate image prompt\n const prompt = await generateImagePrompt(article, outlet || undefined);\n \n // Generate image with OpenAI\n const response = await openai.images.generate({\n model: \"dall-e-3\",\n prompt,\n n: 1,\n size: \"1792x1024\",\n quality: \"standard\",\n });\n\n const imageData = response.data?.[0];\n if (!imageData) {\n throw new Error('No image data received from OpenAI');\n }\n \n const imageUrl = imageData.url || (imageData.b64_json ? `data:image/png;base64,${imageData.b64_json}` : null);\n if (!imageUrl) {\n throw new Error('No image URL or data received from OpenAI');\n }\n\n // Create filename\n const timestamp = Date.now();\n const randomId = Math.random().toString(36).substring(2, 8);\n const filename = `${article.title.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 50)}_${timestamp}_${randomId}`;\n\n // Save image and get thumbnail path\n const thumbnailPath = await saveGeneratedImage(imageUrl, filename);\n \n // Update article with new thumbnail\n await storage.updateArticleThumbnail(article.id, thumbnailPath);\n\n results.push({\n articleId: article.id,\n title: article.title,\n thumbnailPath,\n success: true\n });\n\n // Small delay to avoid rate limiting\n await new Promise(resolve => setTimeout(resolve, 1000));\n\n } catch (error) {\n console.error(`Error generating thumbnail for article ${article.id}:`, error);\n errors.push({\n articleId: article.id,\n title: article.title,\n error: error instanceof Error ? error.message : 'Unknown error'\n });\n }\n }\n\n res.json({\n success: true,\n processed: results.length,\n failed: errors.length,\n results,\n errors,\n message: `Generated ${results.length} thumbnails, ${errors.length} failed`\n });\n\n } catch (error) {\n console.error(\"Error in batch thumbnail generation:\", error);\n res.status(500).json({ error: \"Failed to generate thumbnails\" });\n }\n });\n\n // AI-powered article enhancement\n app.post(\"/api/enhance-article\", async (req, res) => {\n try {\n const { title, summary, content } = req.body;\n \n if (!title || !content) {\n return res.status(400).json({ error: \"Title and content required\" });\n }\n\n const response = await openai.chat.completions.create({\n model: \"gpt-4o\", // Using gpt-4o as it's widely available\n messages: [\n {\n role: \"system\",\n content: \"You are a professional news editor. Enhance the given article by improving clarity, flow, and journalistic quality while maintaining the original facts and tone. Respond with JSON containing enhanced title, summary, and body.\"\n },\n {\n role: \"user\",\n content: `Please enhance this article:\nTitle: ${title}\nSummary: ${summary || \"\"}\nContent: ${content}\n\nProvide enhanced versions in JSON format: { \"title\": \"...\", \"summary\": \"...\", \"body\": \"...\" }`\n }\n ],\n response_format: { type: \"json_object\" },\n });\n\n // Robust parsing with validation\n const messageContent = response.choices?.[0]?.message?.content;\n if (!messageContent) {\n throw new Error('No content received from OpenAI');\n }\n \n let enhanced;\n try {\n enhanced = JSON.parse(messageContent);\n } catch (parseError) {\n console.error('Failed to parse OpenAI response:', messageContent);\n throw new Error('Invalid JSON response from AI');\n }\n \n // Validate required fields\n const result = {\n title: enhanced.title || title,\n summary: enhanced.summary || summary || '',\n body: enhanced.body || content\n };\n \n res.json(result);\n } catch (error) {\n console.error(\"Error enhancing article:\", error);\n res.status(500).json({ error: \"Failed to enhance article\" });\n }\n });\n\n // Generate AI-powered summary from article content\n app.post(\"/api/generate-summary\", async (req, res) => {\n try {\n const { content, title } = req.body;\n \n if (!content) {\n return res.status(400).json({ error: \"Content is required\" });\n }\n\n // Generate AI-powered summary with fallback to basic method\n let summary = '';\n \n try {\n const response = await openai.chat.completions.create({\n model: \"gpt-4o-mini\", // Using mini model for cost-effectiveness\n messages: [\n {\n role: \"system\",\n content: \"You are a professional news summarizer. Create a concise, engaging summary (maximum 2-3 sentences, 150 characters max) that captures the key points and newsworthiness of the article. Focus on who, what, when, where, why. Make it compelling but factual.\"\n },\n {\n role: \"user\",\n content: `Title: ${title || \"No title\"}\nArticle Content: ${content.substring(0, 2000)}`\n }\n ],\n max_tokens: 100,\n temperature: 0.3 // Lower temperature for more consistent, factual output\n });\n\n const aiSummary = response.choices?.[0]?.message?.content?.trim();\n if (aiSummary && aiSummary.length > 10) {\n summary = aiSummary;\n } else {\n throw new Error('AI summary too short or empty');\n }\n } catch (aiError) {\n console.warn(\"AI summary generation failed, falling back to basic method:\", aiError);\n \n // Fallback to basic summary generation (improved version)\n const sentences = content.replace(/^This article was originally published at .+?\\.\\n\\n/, '').split(/[.!?]+/);\n let basicSummary = '';\n \n for (const sentence of sentences) {\n const trimmed = sentence.trim();\n if (trimmed.length < 10) continue; // Skip very short sentences\n if (basicSummary.length + trimmed.length > 150) break;\n basicSummary += (basicSummary ? '. ' : '') + trimmed;\n }\n \n summary = basicSummary + (basicSummary ? '.' : 'Content not available.');\n }\n\n res.json({ summary: summary.substring(0, 150) }); // Ensure max 150 chars\n } catch (error) {\n console.error(\"Error generating summary:\", error);\n \n // Final fallback - return a generic summary\n const basicSummary = req.body.content.substring(0, 100).trim() + '...';\n res.json({ summary: basicSummary });\n }\n });\n\n // AI-powered Text-to-Speech for articles with professional anchor style\n app.post(\"/api/articles/:id/speech\", async (req, res) => {\n try {\n const { id } = req.params;\n const { voice = 'nova', speed = 1.0 } = req.body;\n \n // Get the article\n const article = await storage.getArticleById(id);\n if (!article) {\n return res.status(404).json({ error: \"Article not found\" });\n }\n \n // Prepare the text for speech synthesis with professional anchor style\n const speechText = `Here's today's news from ${article.outletName || 'our newsroom'}. ${article.title}. \n\n ${article.summary || ''}\n \n ${article.body || 'Content not available.'}\n \n This has been your news update. Thank you for listening.`;\n \n // Use OpenAI's text-to-speech with professional voice settings\n const response = await openai.audio.speech.create({\n model: \"tts-1-hd\", // High-quality model for professional sound\n voice: voice as any, // nova, alloy, echo, fable, onyx, shimmer\n input: speechText.substring(0, 4000), // Limit text length for API\n speed: Math.max(0.25, Math.min(4.0, speed)), // Ensure speed is within valid range\n response_format: \"mp3\"\n });\n \n // Stream the audio response\n res.setHeader('Content-Type', 'audio/mpeg');\n res.setHeader('Content-Disposition', `inline; filename=\"article_${id}_speech.mp3\"`);\n res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour\n \n // Convert response to buffer and send\n const buffer = Buffer.from(await response.arrayBuffer());\n res.send(buffer);\n \n } catch (error) {\n console.error(\"Error generating speech:\", error);\n res.status(500).json({ \n error: \"Failed to generate speech\",\n details: error instanceof Error ? error.message : 'Unknown error'\n });\n }\n });\n\n // Statistics route\n app.get(\"/api/stats\", async (req, res) => {\n try {\n const outlets = await storage.getAllOutlets();\n const articles = await storage.getAllArticles();\n \n const categoryStats = outlets.reduce((acc, outlet) => {\n acc[outlet.category] = (acc[outlet.category] || 0) + 1;\n return acc;\n }, {} as Record<string, number>);\n\n res.json({\n totalOutlets: outlets.length,\n totalArticles: articles.length,\n categoriesBreakdown: categoryStats,\n recentArticlesCount: articles.slice(0, 5).length\n });\n } catch (error) {\n console.error(\"Error fetching stats:\", error);\n res.status(500).json({ error: \"Failed to fetch statistics\" });\n }\n });\n\n // YouTube-style feed endpoint\n app.get(\"/api/feed\", async (req, res) => {\n try {\n const { cursor, limit, filter } = req.query;\n const params = {\n cursor: cursor as string,\n limit: limit ? parseInt(limit as string) : 10,\n filter: (filter as 'all' | 'people' | 'topics' | 'companies') || 'all'\n };\n \n const feed = await storage.listFeed(params);\n res.json(feed);\n } catch (error) {\n console.error(\"Error fetching feed:\", error);\n res.status(500).json({ error: \"Failed to fetch feed\" });\n }\n });\n\n // Increment article view count\n app.post(\"/api/articles/:id/view\", async (req, res) => {\n try {\n await storage.incrementView(req.params.id);\n res.json({ success: true });\n } catch (error) {\n console.error(\"Error incrementing view:\", error);\n res.status(500).json({ error: \"Failed to increment view\" });\n }\n });\n\n // Remove duplicate articles within outlets\n app.post(\"/api/articles/remove-duplicates\", async (req, res) => {\n try {\n console.log(\"Starting duplicate article removal...\");\n const result = await storage.removeDuplicateArticles();\n \n res.json({\n success: true,\n removedCount: result.removedCount,\n details: result.details,\n message: `Successfully removed ${result.removedCount} duplicate articles across ${result.details.length} outlets`\n });\n } catch (error) {\n console.error(\"Error removing duplicate articles:\", error);\n res.status(500).json({ \n error: \"Failed to remove duplicate articles\",\n details: error instanceof Error ? error.message : 'Unknown error'\n });\n }\n });\n\n // Comment API routes\n app.get(\"/api/articles/:articleId/comments\", async (req, res) => {\n try {\n const { articleId } = req.params;\n const { limit = 10, offset = 0 } = req.query;\n \n const result = await storage.getCommentsByArticle(articleId, {\n limit: parseInt(limit as string),\n offset: parseInt(offset as string)\n });\n \n res.json(result);\n } catch (error) {\n console.error(\"Error fetching comments:\", error);\n res.status(500).json({ error: \"Failed to fetch comments\" });\n }\n });\n\n app.get(\"/api/comments/:commentId/replies\", async (req, res) => {\n try {\n const { commentId } = req.params;\n const { limit = 5, offset = 0 } = req.query;\n \n const result = await storage.getCommentReplies(commentId, {\n limit: parseInt(limit as string),\n offset: parseInt(offset as string)\n });\n \n res.json(result);\n } catch (error) {\n console.error(\"Error fetching replies:\", error);\n res.status(500).json({ error: \"Failed to fetch replies\" });\n }\n });\n\n app.post(\"/api/articles/:articleId/comments\", async (req, res) => {\n try {\n const { articleId } = req.params;\n const { content, nickname, avatar, parentId } = req.body;\n \n if (!content || !nickname) {\n return res.status(400).json({ error: \"Content and nickname are required\" });\n }\n \n const comment = await storage.createComment({\n articleId,\n content,\n nickname,\n avatar,\n parentId: parentId || null\n });\n \n res.json(comment);\n } catch (error) {\n console.error(\"Error creating comment:\", error);\n res.status(500).json({ error: \"Failed to create comment\" });\n }\n });\n\n app.post(\"/api/comments/:commentId/reactions\", async (req, res) => {\n try {\n const { commentId } = req.params;\n const { reactionType, userIdentifier } = req.body;\n \n if (!reactionType || !userIdentifier) {\n return res.status(400).json({ error: \"Reaction type and user identifier are required\" });\n }\n \n if (!['like', 'dislike'].includes(reactionType)) {\n return res.status(400).json({ error: \"Invalid reaction type\" });\n }\n \n const result = await storage.toggleCommentReaction(commentId, userIdentifier, reactionType);\n res.json(result);\n } catch (error) {\n console.error(\"Error toggling reaction:\", error);\n res.status(500).json({ error: \"Failed to toggle reaction\" });\n }\n });\n\n app.get(\"/api/comments/:commentId/reactions/:userIdentifier\", async (req, res) => {\n try {\n const { commentId, userIdentifier } = req.params;\n \n const reaction = await storage.getUserCommentReaction(commentId, userIdentifier);\n res.json(reaction || null);\n } catch (error) {\n console.error(\"Error fetching user reaction:\", error);\n res.status(500).json({ error: \"Failed to fetch user reaction\" });\n }\n });\n\n // Web scraping endpoint for adding new articles from URLs\n app.post(\"/api/scrape\", async (req, res) => {\n try {\n const { url, outletId } = req.body;\n \n if (!url || !outletId) {\n return res.status(400).json({ error: \"URL and outletId are required\" });\n }\n\n // Fetch the web page content\n const response = await fetch(url);\n const html = await response.text();\n \n // Parse content using simple regex patterns (basic implementation)\n const titleMatch = html.match(/<title[^>]*>([^<]+)<\\/title>/i);\n const metaDescMatch = html.match(/<meta[^>]*name=[\"\\']description[\"\\'][^>]*content=[\"\\']([^\"']+)[\"\\'][^>]*>/i);\n \n // Extract title and clean it\n let title = titleMatch ? titleMatch[1].trim() : 'Scraped Article';\n title = title.replace(/\\s+/g, ' ').substring(0, 200); // Clean and limit length\n \n // Extract description/summary\n let summary = metaDescMatch ? metaDescMatch[1].trim() : 'Article scraped from ' + new URL(url).hostname;\n summary = summary.replace(/\\s+/g, ' ').substring(0, 500);\n \n // Create basic article body from summary\n let body = summary;\n if (body.length < 200) {\n body += `\\n\\nThis content discusses recent developments and market insights related to the subject matter. The article provides detailed analysis and current information about the industry trends and key developments.`;\n }\n \n // Generate a simple thumbnail placeholder (we'll improve this later)\n const defaultThumbnail = '/api/assets/default-article.png';\n \n // Create the article\n const articleData = {\n outletId,\n title,\n summary,\n body,\n thumbnail: defaultThumbnail,\n publishedAt: generateVariedPublishedAt(title + summary),\n tags: [] as string[],\n viewCount: 0\n };\n \n const article = await storage.createArticle(articleData);\n \n res.json({ \n success: true, \n article,\n message: `Successfully scraped and added article: ${title}`\n });\n \n } catch (error) {\n console.error(\"Error scraping article:\", error);\n res.status(500).json({ \n error: \"Failed to scrape article\", \n details: error instanceof Error ? error.message : 'Unknown error'\n });\n }\n });\n\n // Batch scraping endpoint for multiple URLs\n app.post(\"/api/scrape-batch\", async (req, res) => {\n try {\n const { urls, outletId } = req.body;\n \n if (!urls || !Array.isArray(urls) || !outletId) {\n return res.status(400).json({ error: \"URLs array and outletId are required\" });\n }\n\n const results = [];\n const errors = [];\n\n // Process URLs one by one to avoid overwhelming the server\n for (const url of urls.slice(0, 10)) { // Limit to 10 URLs per batch\n try {\n // Fetch and parse each URL\n const response = await fetch(url);\n const html = await response.text();\n \n const titleMatch = html.match(/<title[^>]*>([^<]+)<\\/title>/i);\n const metaDescMatch = html.match(/<meta[^>]*name=[\"\\']description[\"\\'][^>]*content=[\"\\']([^\"']+)[\"\\'][^>]*>/i);\n \n let title = titleMatch ? titleMatch[1].trim() : `Article from ${new URL(url).hostname}`;\n title = title.replace(/\\s+/g, ' ').substring(0, 200);\n \n let summary = metaDescMatch ? metaDescMatch[1].trim() : `Article scraped from ${new URL(url).hostname}`;\n summary = summary.replace(/\\s+/g, ' ').substring(0, 500);\n \n let body = summary;\n if (body.length < 200) {\n body += `\\n\\nThis content provides insights and analysis on current industry developments and trends.`;\n }\n \n const articleData = {\n outletId,\n title,\n summary,\n body,\n thumbnail: '/api/assets/default-article.png',\n publishedAt: generateVariedPublishedAt(title + summary),\n tags: [] as string[],\n viewCount: 0\n };\n \n const article = await storage.createArticle(articleData);\n results.push({ url, article, success: true });\n \n // Small delay to be respectful to target servers\n await new Promise(resolve => setTimeout(resolve, 500));\n \n } catch (error) {\n console.error(`Error scraping ${url}:`, error);\n errors.push({ \n url, \n error: error instanceof Error ? error.message : 'Unknown error' \n });\n }\n }\n \n res.json({\n success: true,\n results,\n errors,\n message: `Processed ${results.length} articles successfully, ${errors.length} errors`\n });\n \n } catch (error) {\n console.error(\"Error in batch scraping:\", error);\n res.status(500).json({ \n error: \"Failed to process batch scraping\", \n details: error instanceof Error ? error.message : 'Unknown error'\n });\n }\n });\n\n // Advanced web scraping routes\n app.post(\"/api/scrape/advanced\", async (req, res) => {\n try {\n const { urls, outletId, maxConcurrency = 3 } = req.body;\n \n if (!urls || !Array.isArray(urls) || urls.length === 0) {\n return res.status(400).json({ error: \"URLs array is required\" });\n }\n \n if (!outletId) {\n return res.status(400).json({ error: \"Outlet ID is required\" });\n }\n \n // Verify outlet exists\n const outlet = await storage.getOutletById(outletId);\n if (!outlet) {\n return res.status(404).json({ error: \"Outlet not found\" });\n }\n \n const scraper = new WebScraper();\n console.log(`Starting advanced scraping of ${urls.length} URLs for outlet ${outlet.name}`);\n \n const results = [];\n const errors = [];\n \n // Process URLs in batches with concurrency control\n const { successes: scrapedArticles, failures: scrapingFailures } = await scraper.scrapeMultipleArticles(urls, maxConcurrency);\n \n // Add scraping failures to errors\n for (const failure of scrapingFailures) {\n errors.push({\n url: failure.url,\n error: failure.error\n });\n }\n \n for (const scrapedArticle of scrapedArticles) {\n try {\n // Download and process images if available\n let thumbnailPath = '/api/assets/default-article.png';\n if (scrapedArticle.imageUrl) {\n try {\n const imageFilename = `scraped_${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`;\n const downloadedImage = await scraper.downloadImage(scrapedArticle.imageUrl, imageFilename);\n if (downloadedImage) {\n // Create thumbnail\n const thumbnailFilename = `thumb_${imageFilename}`;\n const thumbnailSuccess = await scraper.createThumbnail(\n join(process.cwd(), 'attached_assets', 'scraped', imageFilename),\n join(process.cwd(), 'attached_assets', 'scraped', thumbnailFilename),\n 300\n );\n if (thumbnailSuccess) {\n thumbnailPath = `/api/assets/scraped/${thumbnailFilename}`;\n }\n }\n } catch (imageError) {\n console.warn(`Failed to process image for ${scrapedArticle.url}:`, imageError);\n }\n }\n \n // Create article with scraped data\n const articleData = {\n outletId,\n title: scrapedArticle.title.substring(0, 200),\n summary: scrapedArticle.summary.substring(0, 500),\n body: scrapedArticle.body,\n thumbnail: thumbnailPath,\n publishedAt: scrapedArticle.publishedAt,\n tags: scrapedArticle.tags,\n viewCount: 0,\n sourceUrl: scrapedArticle.url,\n author: scrapedArticle.author || null,\n originalImageUrl: scrapedArticle.imageUrl || null,\n scrapedAt: new Date(),\n isScraped: 1\n };\n \n const article = await storage.createArticle(articleData);\n results.push({ \n url: scrapedArticle.url, \n article: {\n id: article.id,\n title: article.title,\n summary: article.summary\n }, \n success: true \n });\n \n } catch (error) {\n console.error(`Error saving scraped article from ${scrapedArticle.url}:`, error);\n errors.push({ \n url: scrapedArticle.url, \n error: error instanceof Error ? error.message : 'Failed to save article' \n });\n }\n }\n \n res.json({\n success: true,\n results,\n errors,\n message: `Successfully scraped and saved ${results.length} articles, ${errors.length} errors`,\n outlet: outlet.name,\n totalProcessed: urls.length\n });\n \n } catch (error) {\n console.error(\"Error in advanced scraping:\", error);\n res.status(500).json({ \n error: \"Failed to process advanced scraping\", \n details: error instanceof Error ? error.message : 'Unknown error'\n });\n }\n });\n\n // Batch scrape all outlets from attached file\n app.post(\"/api/scrape/batch-all\", async (req, res) => {\n try {\n const { maxPerOutlet = 5, maxConcurrency = 2 } = req.body;\n \n console.log(\"Starting batch scraping of all outlets from attached file...\");\n \n // Parse the attached outlet file\n const parsedOutlets = parseAttachedOutletFile();\n console.log(`Parsed ${parsedOutlets.total} outlets from file`);\n \n // Convert to outlet format\n const outletsData = OutletParser.convertToOutletFormat(parsedOutlets);\n \n const scraper = new WebScraper();\n const allResults = [];\n const allErrors = [];\n \n let processedOutlets = 0;\n \n for (const outletData of outletsData) {\n try {\n processedOutlets++;\n console.log(`Processing outlet ${processedOutlets}/${outletsData.length}: ${outletData.name}`);\n \n // Create or update outlet\n let outlet;\n try {\n outlet = await storage.getOutletById(outletData.id);\n if (!outlet) {\n outlet = await storage.createOutlet({\n name: outletData.name,\n description: outletData.description,\n category: outletData.category,\n focusSubject: outletData.focusSubject,\n avatar: outletData.avatar,\n profileImage: outletData.profileImage,\n bio: outletData.bio,\n fullBio: outletData.fullBio\n });\n }\n } catch (outletError) {\n console.error(`Error creating/updating outlet ${outletData.name}:`, outletError);\n allErrors.push({\n outlet: outletData.name,\n error: `Failed to create outlet: ${outletError instanceof Error ? outletError.message : 'Unknown error'}`\n });\n continue;\n }\n \n // Take limited number of URLs per outlet\n const urlsToScrape = outletData.urls.slice(0, maxPerOutlet);\n console.log(`Scraping ${urlsToScrape.length} URLs for ${outlet.name}`);\n \n // Scrape articles for this outlet\n const { successes: scrapedArticles, failures: scrapingFailures } = await scraper.scrapeMultipleArticles(urlsToScrape, maxConcurrency);\n \n // Add scraping failures to errors\n for (const failure of scrapingFailures) {\n allErrors.push({\n outlet: outlet.name,\n url: failure.url,\n error: failure.error\n });\n }\n \n let savedCount = 0;\n \n for (const scrapedArticle of scrapedArticles) {\n try {\n // Process thumbnail\n let thumbnailPath = '/api/assets/default-article.png';\n if (scrapedArticle.imageUrl) {\n try {\n const imageFilename = `scraped_${outletData.id}_${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`;\n const downloadedImage = await scraper.downloadImage(scrapedArticle.imageUrl, imageFilename);\n if (downloadedImage) {\n const thumbnailFilename = `thumb_${imageFilename}`;\n const thumbnailSuccess = await scraper.createThumbnail(\n join(process.cwd(), 'attached_assets', 'scraped', imageFilename),\n join(process.cwd(), 'attached_assets', 'scraped', thumbnailFilename),\n 300\n );\n if (thumbnailSuccess) {\n thumbnailPath = `/api/assets/scraped/${thumbnailFilename}`;\n }\n }\n } catch (imageError) {\n console.warn(`Failed to process image for ${scrapedArticle.url}:`, imageError);\n }\n }\n \n // Save article\n const articleData = {\n outletId: outlet.id,\n title: scrapedArticle.title.substring(0, 200),\n summary: scrapedArticle.summary.substring(0, 500),\n body: scrapedArticle.body,\n thumbnail: thumbnailPath,\n publishedAt: scrapedArticle.publishedAt,\n tags: scrapedArticle.tags,\n viewCount: 0,\n sourceUrl: scrapedArticle.url,\n author: scrapedArticle.author || null,\n originalImageUrl: scrapedArticle.imageUrl || null,\n scrapedAt: new Date(),\n isScraped: 1\n };\n \n const article = await storage.createArticle(articleData);\n savedCount++;\n \n allResults.push({\n outlet: outlet.name,\n url: scrapedArticle.url,\n articleTitle: article.title,\n success: true\n });\n \n } catch (error) {\n console.error(`Error saving article from ${scrapedArticle.url}:`, error);\n allErrors.push({\n outlet: outlet.name,\n url: scrapedArticle.url,\n error: error instanceof Error ? error.message : 'Failed to save article'\n });\n }\n }\n \n console.log(`Completed ${outlet.name}: ${savedCount} articles saved`);\n \n // Small delay between outlets to be respectful\n if (processedOutlets < outletsData.length) {\n await new Promise(resolve => setTimeout(resolve, 2000));\n }\n \n } catch (error) {\n console.error(`Error processing outlet ${outletData.name}:`, error);\n allErrors.push({\n outlet: outletData.name,\n error: error instanceof Error ? error.message : 'Failed to process outlet'\n });\n }\n }\n \n res.json({\n success: true,\n summary: {\n totalOutlets: outletsData.length,\n processedOutlets,\n totalArticlesSaved: allResults.length,\n totalErrors: allErrors.length\n },\n results: allResults,\n errors: allErrors,\n message: `Batch scraping completed! Processed ${processedOutlets} outlets, saved ${allResults.length} articles`\n });\n \n } catch (error) {\n console.error(\"Error in batch scraping:\", error);\n res.status(500).json({ \n error: \"Failed to process batch scraping\", \n details: error instanceof Error ? error.message : 'Unknown error'\n });\n }\n });\n\n // Get scraped articles\n app.get(\"/api/scraped-articles\", async (req, res) => {\n try {\n const { limit = 50 } = req.query;\n const limitNum = parseInt(limit as string);\n const articles = await storage.getScrapedArticles(limitNum);\n res.json(articles);\n } catch (error) {\n console.error(\"Error fetching scraped articles:\", error);\n res.status(500).json({ error: \"Failed to fetch scraped articles\" });\n }\n });\n\n // Parse outlet file and scrape all 195 links\n app.post(\"/api/scrape/outlet-file\", async (req, res) => {\n try {\n const { filePath } = req.body;\n \n if (!filePath) {\n return res.status(400).json({ error: \"File path is required\" });\n }\n \n // Parse the outlet file\n console.log('Parsing outlet file for scraping...');\n const outletFilePath = join(process.cwd(), filePath);\n const parsedOutlets = OutletParser.parseOutletFile(outletFilePath);\n \n console.log(`Parsed ${parsedOutlets.total} outlets with links to scrape`);\n \n const scraper = new WebScraper();\n const allResults = [];\n const allErrors = [];\n let processedOutlets = 0;\n \n // Process each category (people, topics, companies)\n for (const category of ['people', 'topics', 'companies'] as const) {\n const outlets = parsedOutlets[category];\n \n for (const outletData of outlets) {\n try {\n processedOutlets++;\n console.log(`Processing ${category} outlet: ${outletData.name} (${outletData.urls.length} URLs)`);\n \n // Find existing outlet or create new one\n let outlet = await storage.getOutletById(outletData.focusSubject);\n \n if (!outlet) {\n console.log(`Creating new outlet: ${outletData.name}`);\n // Create outlet if it doesn't exist\n outlet = await storage.createOutlet({\n name: outletData.name,\n description: `Specialized coverage focusing on ${outletData.name}`,\n category: outletData.category,\n focusSubject: outletData.name,\n bio: `Comprehensive coverage and analysis focusing on ${outletData.name}`,\n avatar: null,\n profileImage: null,\n fullBio: null\n });\n }\n \n // Scrape all URLs for this outlet with controlled concurrency\n const { successes: scrapedArticles, failures: scrapingFailures } = await scraper.scrapeMultipleArticles(outletData.urls, 2);\n \n // Add scraping failures to errors\n for (const failure of scrapingFailures) {\n allErrors.push({\n outlet: outlet.name,\n url: failure.url,\n error: failure.error\n });\n }\n \n for (const scrapedArticle of scrapedArticles) {\n try {\n // Download and process images if available\n let thumbnailPath = '/api/assets/default-article.png';\n if (scrapedArticle.imageUrl) {\n try {\n const imageFilename = `scraped_${outletData.focusSubject}_${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`;\n const downloadedImage = await scraper.downloadImage(scrapedArticle.imageUrl, imageFilename);\n if (downloadedImage) {\n // Create thumbnail\n const thumbnailFilename = `thumb_${imageFilename}`;\n const thumbnailSuccess = await scraper.createThumbnail(\n join(process.cwd(), 'attached_assets', 'scraped', imageFilename),\n join(process.cwd(), 'attached_assets', 'scraped', thumbnailFilename),\n 300\n );\n if (thumbnailSuccess) {\n thumbnailPath = `/api/assets/scraped/${thumbnailFilename}`;\n }\n }\n } catch (imageError) {\n console.warn(`Failed to process image for ${scrapedArticle.url}:`, imageError);\n }\n }\n \n // Create article with scraped data\n const articleData = {\n outletId: outlet.id,\n title: scrapedArticle.title.substring(0, 200),\n summary: scrapedArticle.summary.substring(0, 500),\n body: scrapedArticle.body,\n thumbnail: thumbnailPath,\n publishedAt: scrapedArticle.publishedAt,\n tags: scrapedArticle.tags,\n viewCount: 0,\n sourceUrl: scrapedArticle.url,\n author: scrapedArticle.author || null,\n originalImageUrl: scrapedArticle.imageUrl || null,\n scrapedAt: new Date(),\n isScraped: 1\n };\n \n const article = await storage.createArticle(articleData);\n allResults.push({ \n outlet: outlet.name,\n url: scrapedArticle.url, \n article: {\n id: article.id,\n title: article.title,\n summary: article.summary\n }, \n success: true \n });\n \n } catch (error) {\n console.error(`Error saving scraped article from ${scrapedArticle.url}:`, error);\n allErrors.push({ \n outlet: outlet.name,\n url: scrapedArticle.url, \n error: error instanceof Error ? error.message : 'Failed to save article' \n });\n }\n }\n \n // Add delay between outlets to be respectful\n await new Promise(resolve => setTimeout(resolve, 2000));\n \n } catch (error) {\n console.error(`Error processing outlet ${outletData.name}:`, error);\n allErrors.push({\n outlet: outletData.name,\n url: 'N/A',\n error: error instanceof Error ? error.message : 'Failed to process outlet'\n });\n }\n }\n }\n \n res.json({\n success: true,\n summary: {\n totalOutlets: parsedOutlets.total,\n processedOutlets,\n totalArticlesSaved: allResults.length,\n totalErrors: allErrors.length,\n breakdown: {\n people: parsedOutlets.people.length,\n topics: parsedOutlets.topics.length,\n companies: parsedOutlets.companies.length\n }\n },\n results: allResults,\n errors: allErrors,\n message: `Batch scraping completed! Processed ${processedOutlets} outlets, saved ${allResults.length} articles from ${parsedOutlets.total} total outlets`\n });\n \n } catch (error) {\n console.error(\"Error in outlet file scraping:\", error);\n res.status(500).json({ \n error: \"Failed to process outlet file scraping\", \n details: error instanceof Error ? error.message : 'Unknown error'\n });\n }\n });\n\n // Outlet consolidation endpoint\n app.post(\"/api/outlets/consolidate\", async (req, res) => {\n try {\n console.log(\"Starting outlet consolidation...\");\n\n // 사용자가 제공한 정확한 언론매체 리스트\n const correctOutlets = {\n people: [\n \"Ala Shaabana\", \"Alex Karp\", \"Arthur Hayes\", \"Donald Trump Jr.\", \"Eric Trump\",\n \"Jacob Robert Steeves\", \"Jared Kushner\", \"J.D. Vance\", \"Jensen Huang\", \"Jerome Powell\",\n \"Joseph Jacks\", \"Larry Ellison\", \"Lily Liu\", \"Marco Rubio\", \"Robert Myers\",\n \"Sam Altman\", \"Satya Nadella\", \"Scott Bessent\", \"Simon Kim\", \"Yat Siu\"\n ],\n topics: [\n \"AI\", \"Alt Coin\", \"Bollywood\", \"CantoPop\", \"CBDC (Central Bank Digital Currency)\",\n \"CFTC (Commodity Futures Trading Commission)\", \"Crypto\", \"Custody Regulation\", \"DAT (Digital Asset Treasury)\",\n \"Decentralized AI\", \"DeFi\", \"DEX (Decentralized Exchange)\", \"Fed (Federal Reserve)\",\n \"FOMC (Federal Open Market Committee)\", \"J-Star\", \"K-Star\", \"NFT (Non-Fungible Token)\",\n \"RWA (Real World Assets)\", \"SEC (Securities and Exchange Commission)\", \"Stable Coin\", \"SWF (Sovereign Wealth Fund)\"\n ],\n companies: [\n \"Ava Labs\", \"Bittensor\", \"BlackRock\", \"Boston Dynamics\", \"Chainlink\", \"Circle\",\n \"CME Group\", \"Epic Games\", \"Hashed\", \"Hyperliquid\", \"Oblong\", \"OpenSea\",\n \"Palantir\", \"PancakeSwap\", \"Polygon\", \"Saudi Aramco\", \"Solana Foundation\",\n \"TAOX\", \"TRON\", \"TSMC\", \"Uniswap\", \"World Liberty Financial\", \"xTAO\", \"YUMA\"\n ]\n };\n\n const allOutlets = await storage.getAllOutlets();\n console.log(`Found ${allOutlets.length} existing outlets`);\n\n // 중복된 언론매체들을 찾아내기 위한 매핑\n const duplicateGroups: { [key: string]: MediaOutlet[] } = {};\n const correctNames = [\n ...correctOutlets.people,\n ...correctOutlets.topics,\n ...correctOutlets.companies\n ];\n\n // 각 정확한 이름에 대해 유사한 이름들을 찾기\n for (const correctName of correctNames) {\n const similarOutlets = allOutlets.filter(outlet => {\n const outletName = outlet.name.toLowerCase().trim();\n const correctNameLower = correctName.toLowerCase().trim();\n \n // 정확히 일치하거나, 한글/영어 설명이 포함된 경우\n return outletName === correctNameLower ||\n outletName.includes(correctNameLower) ||\n correctNameLower.includes(outletName) ||\n outletName.includes(correctNameLower.split(' ')[0]) ||\n outletName.includes(correctNameLower.split('-')[0]);\n });\n\n if (similarOutlets.length > 1) {\n duplicateGroups[correctName] = similarOutlets;\n }\n }\n\n console.log(`Found ${Object.keys(duplicateGroups).length} duplicate groups`);\n\n const consolidationResults: any[] = [];\n let totalMergedOutlets = 0;\n let totalMovedArticles = 0;\n\n // 각 중복 그룹에 대해 통합 작업 수행\n for (const [correctName, duplicates] of Object.entries(duplicateGroups)) {\n if (duplicates.length <= 1) continue;\n\n console.log(`Processing duplicates for: ${correctName}`);\n \n // 정확한 이름과 가장 일치하는 언론매체를 찾기\n let mainOutlet = duplicates.find(outlet => \n outlet.name.toLowerCase().trim() === correctName.toLowerCase().trim()\n );\n\n // 정확한 일치가 없으면 첫 번째를 main으로 사용\n if (!mainOutlet) {\n mainOutlet = duplicates[0];\n // 이름을 정확한 이름으로 업데이트\n await storage.updateOutlet(mainOutlet.id, { name: correctName });\n console.log(`Updated outlet name from \"${mainOutlet.name}\" to \"${correctName}\"`);\n }\n\n // 나머지 중복 언론매체들을 main으로 통합\n const duplicatesToMerge = duplicates.filter(outlet => outlet.id !== mainOutlet!.id);\n let movedArticlesCount = 0;\n\n for (const duplicate of duplicatesToMerge) {\n console.log(`Merging \"${duplicate.name}\" into \"${mainOutlet.name}\"`);\n \n const mergeResult = await storage.mergeOutlets(duplicate.id, mainOutlet.id);\n if (mergeResult.success) {\n movedArticlesCount += mergeResult.movedArticles;\n totalMergedOutlets++;\n console.log(`Successfully merged ${duplicate.name}, moved ${mergeResult.movedArticles} articles`);\n } else {\n console.error(`Failed to merge ${duplicate.name}`);\n }\n }\n\n totalMovedArticles += movedArticlesCount;\n consolidationResults.push({\n correctName,\n mainOutletId: mainOutlet.id,\n mergedOutlets: duplicatesToMerge.map(d => d.name),\n movedArticles: movedArticlesCount\n });\n }\n\n console.log(\"Outlet consolidation completed\");\n\n res.json({\n success: true,\n summary: {\n totalDuplicateGroups: Object.keys(duplicateGroups).length,\n totalMergedOutlets,\n totalMovedArticles,\n initialOutletCount: allOutlets.length,\n finalOutletCount: allOutlets.length - totalMergedOutlets\n },\n consolidationResults,\n message: `Successfully consolidated ${totalMergedOutlets} duplicate outlets, moved ${totalMovedArticles} articles`\n });\n\n } catch (error) {\n console.error(\"Error in outlet consolidation:\", error);\n res.status(500).json({\n error: \"Failed to consolidate outlets\",\n details: error instanceof Error ? error.message : 'Unknown error'\n });\n }\n });\n\n // Bookmark routes\n app.post(\"/api/bookmarks/toggle\", async (req, res) => {\n try {\n const { articleId, userIdentifier } = req.body;\n if (!articleId || !userIdentifier) {\n return res.status(400).json({ error: \"articleId and userIdentifier are required\" });\n }\n const result = await storage.toggleBookmark(articleId, userIdentifier);\n res.json(result);\n } catch (error) {\n console.error(\"Error toggling bookmark:\", error);\n res.status(500).json({ error: \"Failed to toggle bookmark\" });\n }\n });\n\n app.get(\"/api/bookmarks/:articleId/:userIdentifier\", async (req, res) => {\n try {\n const { articleId, userIdentifier } = req.params;\n const isBookmarked = await storage.isBookmarked(articleId, userIdentifier);\n res.json({ isBookmarked });\n } catch (error) {\n console.error(\"Error checking bookmark:\", error);\n res.status(500).json({ error: \"Failed to check bookmark\" });\n }\n });\n\n app.get(\"/api/bookmarks/user/:userIdentifier\", async (req, res) => {\n try {\n const { userIdentifier } = req.params;\n const bookmarks = await storage.getUserBookmarks(userIdentifier);\n res.json({ bookmarks });\n } catch (error) {\n console.error(\"Error getting user bookmarks:\", error);\n res.status(500).json({ error: \"Failed to get bookmarks\" });\n }\n });\n\n // Article stats routes\n app.get(\"/api/articles/:id/comment-count\", async (req, res) => {\n try {\n const { id } = req.params;\n const count = await storage.getArticleCommentCount(id);\n res.json({ count });\n } catch (error) {\n console.error(\"Error getting comment count:\", error);\n res.status(500).json({ error: \"Failed to get comment count\" });\n }\n });\n\n // Prediction Market routes\n app.get(\"/api/prediction-markets/article/:articleId\", async (req, res) => {\n try {\n const { articleId } = req.params;\n const limit = parseInt(req.query.limit as string) || 3;\n const offset = parseInt(req.query.offset as string) || 0;\n \n const markets = await storage.getPredictionMarketsByArticle(articleId, { limit, offset });\n res.json(markets);\n } catch (error) {\n console.error(\"Error getting prediction markets:\", error);\n res.status(500).json({ error: \"Failed to get prediction markets\" });\n }\n });\n\n // Serve attached assets route\n app.use(\"/api/assets\", express.static(path.resolve(process.cwd(), \"attached_assets\")));\n\n const httpServer = createServer(app);\n return httpServer;\n}","size_bytes":62895},"server/storage.ts":{"content":"import { \n type User, \n type InsertUser, \n type MediaOutlet, \n type InsertMediaOutlet, \n type Article, \n type InsertArticle,\n type Comment,\n type InsertComment,\n type CommentReaction,\n type InsertCommentReaction,\n type Bookmark,\n type InsertBookmark,\n type PredictionMarket,\n type InsertPredictionMarket,\n users,\n mediaOutlets,\n articles,\n comments,\n commentReactions,\n bookmarks,\n predictionMarkets\n} from \"@shared/schema\";\nimport { randomUUID } from \"crypto\";\nimport { drizzle } from \"drizzle-orm/neon-http\";\nimport { neon } from \"@neondatabase/serverless\";\nimport { eq, desc, asc, and, or, sql, like, count } from \"drizzle-orm\";\n\n// Initialize database connection\nif (!process.env.DATABASE_URL) {\n throw new Error(\"DATABASE_URL environment variable is required\");\n}\n\nconst sqlClient = neon(process.env.DATABASE_URL);\nconst db = drizzle(sqlClient);\n\n// Storage interface for the news platform\nexport interface IStorage {\n // User methods\n getUser(id: string): Promise<User | undefined>;\n getUserByUsername(username: string): Promise<User | undefined>;\n createUser(user: InsertUser): Promise<User>;\n \n // Media outlet methods\n getAllOutlets(): Promise<MediaOutlet[]>;\n getOutletsByCategory(category: string): Promise<MediaOutlet[]>;\n getOutletById(id: string): Promise<MediaOutlet | undefined>;\n createOutlet(outlet: InsertMediaOutlet): Promise<MediaOutlet>;\n deleteOutlet(id: string): Promise<boolean>;\n updateOutlet(id: string, updates: Partial<InsertMediaOutlet>): Promise<MediaOutlet | undefined>;\n mergeOutlets(fromOutletId: string, toOutletId: string): Promise<{ success: boolean; movedArticles: number }>;\n \n // Article methods\n getAllArticles(): Promise<Article[]>;\n getArticlesByOutlet(outletId: string): Promise<Article[]>;\n getArticleById(id: string): Promise<Article | undefined>;\n getFeaturedArticles(limit?: number): Promise<Article[]>;\n searchArticles(query: string): Promise<Article[]>;\n createArticle(article: InsertArticle): Promise<Article>;\n deleteArticle(id: string): Promise<boolean>;\n \n // Scraped article methods\n createScrapedArticle(article: Omit<InsertArticle, 'id'> & { \n sourceUrl: string; \n author?: string; \n originalImageUrl?: string; \n isScraped?: number;\n }): Promise<Article>;\n getScrapedArticles(limit?: number): Promise<Article[]>;\n updateArticleWithScrapedData(id: string, data: {\n sourceUrl?: string;\n author?: string;\n originalImageUrl?: string;\n }): Promise<Article | undefined>;\n \n // Thumbnail generation methods\n updateArticleThumbnail(id: string, thumbnailPath: string): Promise<Article | undefined>;\n \n // YouTube-style feed methods (enriched with outlet data)\n listFeed(params: { cursor?: string; limit?: number; filter?: 'all' | 'people' | 'topics' | 'companies' }): Promise<{ items: (Article & { outletName: string; outletAvatar: string; category: string })[]; nextCursor?: string }>;\n incrementView(id: string): Promise<void>;\n \n // Comment system methods\n getCommentsByArticle(articleId: string, params: { limit?: number; offset?: number }): Promise<{ comments: Comment[]; total: number }>;\n getCommentReplies(parentId: string, params: { limit?: number; offset?: number }): Promise<{ comments: Comment[]; total: number }>;\n getCommentById(id: string): Promise<Comment | undefined>;\n createComment(comment: InsertComment): Promise<Comment>;\n updateComment(id: string, content: string): Promise<Comment | undefined>;\n deleteComment(id: string): Promise<boolean>;\n \n // Comment reaction methods (atomic operations)\n toggleCommentReaction(commentId: string, userIdentifier: string, reactionType: 'like' | 'dislike'): Promise<{ action: 'added' | 'removed' | 'changed'; reaction: CommentReaction | null }>;\n getUserCommentReaction(commentId: string, userIdentifier: string): Promise<CommentReaction | undefined>;\n \n // Bookmark methods\n toggleBookmark(articleId: string, userIdentifier: string): Promise<{ action: 'added' | 'removed'; bookmark: Bookmark | null }>;\n isBookmarked(articleId: string, userIdentifier: string): Promise<boolean>;\n getUserBookmarks(userIdentifier: string): Promise<Bookmark[]>;\n \n // Article stats methods\n getArticleCommentCount(articleId: string): Promise<number>;\n \n // Article deduplication methods\n removeDuplicateArticles(): Promise<{ removedCount: number; details: { outletId: string; duplicatesRemoved: number }[] }>;\n \n // Prediction Market methods\n getPredictionMarketsByArticle(articleId: string, params: { limit?: number; offset?: number }): Promise<PredictionMarket[]>;\n getPredictionMarketById(id: string): Promise<PredictionMarket | undefined>;\n createPredictionMarket(market: InsertPredictionMarket): Promise<PredictionMarket>;\n}\n\nexport class DbStorage implements IStorage {\n // In-memory Maps for hybrid storage approach\n private outlets = new Map<string, MediaOutlet>();\n private users = new Map<string, User>();\n private articles = new Map<string, Article>();\n private comments = new Map<string, Comment>();\n private commentReactions = new Map<string, CommentReaction>();\n private bookmarks = new Map<string, Bookmark>();\n\n constructor() {\n this.logDatabaseStats();\n this.initializeDefaultData();\n }\n\n private async logDatabaseStats() {\n try {\n const outletCount = await db.select({ count: count() }).from(mediaOutlets);\n const articleCount = await db.select({ count: count() }).from(articles);\n console.log(`[DbStorage] Connected to database with ${outletCount[0].count} outlets and ${articleCount[0].count} articles`);\n } catch (error) {\n console.error('[DbStorage] Failed to connect to database:', error);\n }\n }\n\n private async initializeDefaultData() {\n // Initialize with new outlet structure based on requirements\n const defaultOutlets = [\n // People Category (4 outlets)\n {\n id: 'jacob-robert-steeves',\n name: 'Jacob Robert Steeves',\n description: 'Co-founder of Bittensor and primary architect of decentralized AI',\n category: 'people',\n focusSubject: 'Jacob Robert Steeves',\n avatar: '/api/assets/jacob-steeves_1758526589973.png',\n profileImage: '/api/assets/steeves thumbnail_1758526589975.png',\n bio: 'Co-founder of Bittensor and a primary architect of its decentralized AI network.',\n fullBio: [\n 'Co-founder of Bittensor and primary architect of the decentralized AI network protocol.',\n 'Visionary leader advocating for open, permissionless intelligence markets and AI democratization.',\n 'Technical expert directing core research in AI consensus mechanisms and protocol development.'\n ]\n },\n {\n id: 'ala-shaabana',\n name: 'Ala Shaabana',\n description: 'Co-founder and key technical architect of Bittensor',\n category: 'people',\n focusSubject: 'Ala Shaabana',\n avatar: '/api/assets/Ala Shaabana_1758526589971.jpeg',\n profileImage: '/api/assets/ala thumbnail_1758526589972.png',\n bio: 'A co-founder of Bittensor and a key technical architect of its decentralized AI network.',\n fullBio: [\n 'Co-founder of Bittensor with a Ph.D. in Computer Science specializing in advanced AI and machine learning.',\n 'Primary architect of the Bittensor protocol\\'s complex incentive and consensus mechanisms.',\n 'Known for technical precision and focus on scalability in decentralized AI network design.'\n ]\n },\n {\n id: 'joseph-jacks',\n name: 'Joseph Jacks',\n description: 'Founder of Open Source Capital and Bittensor advocate',\n category: 'people',\n focusSubject: 'Joseph Jacks',\n avatar: '/api/assets/Joseph Jacks_1758526589973.jpeg',\n profileImage: '/api/assets/joseph thumbnail_1758526589974.png',\n bio: 'Founder and General Partner of OSS Capital, a venture capital firm with a strong focus on open-source software.',\n fullBio: [\n 'Founder and General Partner of OSS Capital, the first venture capital fund exclusively focused on commercial open-source software (COSS). Since 2018, has led over 40 funding rounds representing $200+ million in investments.',\n 'Entrepreneurial background includes founding Kismatic (first enterprise Kubernetes company, acquired by Apprenda), co-founding Aljabr cloud data management startup, and creating KubeCon conference now run by Linux Foundation.',\n 'Previously held strategic roles at Mesosphere (D2IQ), Enstratius (acquired by Dell), TIBCO Software, and Talend. Served as Entrepreneur in Residence at Quantum Corporation supporting the Rook project.',\n 'Visionary behind Open Core Summit, the global vendor-neutral conference for the COSS ecosystem, bringing together 1,000+ attendees from 29 countries, 40+ sponsors, and 100+ speakers annually.',\n 'Investment portfolio includes notable successes like Remix (acquired by Shopify), OpenBB (Bloomberg Terminal alternative), and dozens of open-source alternatives to major enterprise software platforms.',\n 'Limited partners include tech luminaries: WordPress co-creator Matt Mullenweg, Red Hat co-founder Bob Young, YouTube founders Chad Hurley and Steve Chen, GitHub co-founder Tom Preston-Werner, and Shopify founder Tobias Lütke.',\n 'Core investment philosophy: \"Open source is eating software faster than software is eating the world\" - believes open core approach will replace closed core SaaS companies entirely.',\n 'Serves on boards of multiple portfolio companies including Plane, OpenBB, DeSci Labs, and Liquid AI, actively guiding the next generation of open-source commercial enterprises.'\n ]\n },\n {\n id: 'robert-myers',\n name: 'Robert Myers',\n description: 'CEO of Manifold Labs and former Opentensor Foundation co-founder',\n category: 'people',\n focusSubject: 'Robert Myers',\n avatar: '/api/assets/Robert Myers_1758526589974.jpg',\n profileImage: '/api/assets/robert thumbnail_1758526589974.png',\n bio: 'Co-founder and former Marketing Director of Opentensor Foundation, currently CEO of Manifold Labs.',\n fullBio: [\n 'Co-founder and former Marketing Director of Opentensor Foundation behind Bittensor.',\n 'Current CEO of Manifold Labs, AI infrastructure company funded by OSS Capital.',\n 'Focus on building critical tools and infrastructure for decentralized AI networks.'\n ]\n },\n // Topics Category (3 outlets)\n {\n id: 'alt-coin',\n name: 'Alt Coin',\n description: 'Coverage of alternative cryptocurrencies and emerging tokens',\n category: 'topics',\n focusSubject: 'Alt Coin',\n avatar: '/api/assets/altcoin_1758526589973.jpeg',\n profileImage: '/api/assets/alt coin news_1758526589972.png',\n bio: 'A term for any cryptocurrency other than Bitcoin (BTC).',\n fullBio: [\n 'Any cryptocurrency other than Bitcoin, built to solve problems like scalability, privacy, or smart contracts.',\n 'Highly volatile and speculative market with potential for massive gains and significant losses.',\n 'Success depends on community strength, network utility, and technological innovation.'\n ]\n },\n {\n id: 'stable-coin',\n name: 'Stable Coin',\n description: 'Analysis of stablecoins and price-stable digital assets',\n category: 'topics',\n focusSubject: 'Stable Coin',\n avatar: '/api/assets/stable coin_1758526589975.jpeg',\n profileImage: '/api/assets/stable coin news_1758526589975.png',\n bio: 'A class of cryptocurrencies designed to minimize price volatility, often pegged to a fiat currency like the US Dollar.',\n fullBio: [\n 'Cryptocurrency designed to minimize price volatility, usually pegged to fiat currencies like USD.',\n 'Collateralized by fiat, other cryptocurrencies, or algorithmic mechanisms to maintain price stability.',\n 'Essential for cross-border transactions and DeFi by providing stable store of value.'\n ]\n },\n {\n id: 'bittensor',\n name: 'Bittensor',\n description: 'Comprehensive coverage of the Bittensor network and ecosystem',\n category: 'topics',\n focusSubject: 'Bittensor',\n avatar: '/api/assets/Bittensor_1758526589973.jpg',\n profileImage: '/api/assets/Bittensor_1758526589973.jpg',\n bio: 'A decentralized blockchain network focused on creating a global, open marketplace for artificial intelligence.',\n fullBio: [\n 'Decentralized blockchain network creating a global marketplace for artificial intelligence.',\n 'Uses \"Yuma Consensus\" mechanism and specialized \"Subnets\" for AI tasks like language processing.',\n 'Native TAO token incentivizes participation and democratizes AI away from centralized corporate control.'\n ]\n },\n {\n id: 'dat',\n name: 'DAT',\n description: 'Digital Asset Treasury strategies and corporate cryptocurrency adoption',\n category: 'topics',\n focusSubject: 'DAT',\n avatar: '/api/assets/dat_default.jpeg',\n profileImage: '/api/assets/dat_news_default.png',\n bio: 'Digital Asset Treasury strategies helping corporations integrate cryptocurrency into their treasury management.',\n fullBio: [\n 'Digital Asset Treasury strategies transforming corporate treasury management through cryptocurrency integration.',\n 'Comprehensive framework for institutional digital asset adoption including custody, compliance, and risk management.',\n 'Leading the transition from traditional corporate treasuries to multi-asset digital strategies.'\n ]\n },\n // Additional Topics Category outlets\n {\n id: 'blockchain',\n name: 'Blockchain',\n description: 'Comprehensive blockchain technology news and analysis',\n category: 'topics',\n focusSubject: 'Blockchain',\n avatar: '/api/assets/blockchain_1758526589973.jpeg',\n profileImage: '/api/assets/blockchain_news_1758526589972.png',\n bio: 'The foundational technology behind cryptocurrencies and decentralized applications.',\n fullBio: [\n 'Distributed ledger technology that maintains a continuously growing list of records.',\n 'Enables trustless transactions and smart contracts without central authority.',\n 'Revolutionary technology transforming finance, supply chain, and digital identity.'\n ]\n },\n {\n id: 'defi',\n name: 'DeFi',\n description: 'Decentralized Finance protocols and innovations',\n category: 'topics',\n focusSubject: 'DeFi',\n avatar: '/api/assets/defi_1758526589973.jpeg',\n profileImage: '/api/assets/defi_news_1758526589972.png',\n bio: 'Decentralized Financial services built on blockchain technology.',\n fullBio: [\n 'Financial services using smart contracts instead of traditional intermediaries.',\n 'Includes lending, borrowing, trading, and yield farming protocols.',\n 'Democratizes access to financial services globally without traditional barriers.'\n ]\n },\n {\n id: 'nft',\n name: 'NFT',\n description: 'Non-Fungible Tokens and digital collectibles market',\n category: 'topics',\n focusSubject: 'NFT',\n avatar: '/api/assets/nft_1758526589973.jpeg',\n profileImage: '/api/assets/nft_news_1758526589972.png',\n bio: 'Unique digital assets representing ownership of digital or physical items.',\n fullBio: [\n 'Non-fungible tokens that represent unique ownership of digital assets.',\n 'Revolutionizing art, gaming, music, and digital collectibles markets.',\n 'Enables creators to monetize digital content with verified scarcity.'\n ]\n },\n {\n id: 'dao',\n name: 'DAO',\n description: 'Decentralized Autonomous Organizations and governance',\n category: 'topics',\n focusSubject: 'DAO',\n avatar: '/api/assets/dao_1758526589973.jpeg',\n profileImage: '/api/assets/dao_news_1758526589972.png',\n bio: 'Organizations governed by smart contracts and community voting.',\n fullBio: [\n 'Organizations governed entirely by smart contracts and token holder voting.',\n 'Enables transparent, democratic decision-making without traditional management.',\n 'Revolutionary organizational structure for the decentralized economy.'\n ]\n },\n {\n id: 'metaverse',\n name: 'Metaverse',\n description: 'Virtual worlds and immersive digital experiences',\n category: 'topics',\n focusSubject: 'Metaverse',\n avatar: '/api/assets/metaverse_1758526589973.jpeg',\n profileImage: '/api/assets/metaverse_news_1758526589972.png',\n bio: 'Persistent virtual worlds where users interact in immersive digital environments.',\n fullBio: [\n 'Persistent virtual worlds enabling social interaction and economic activity.',\n 'Combines VR, AR, blockchain, and AI to create immersive digital experiences.',\n 'The future of social media, gaming, and digital commerce.'\n ]\n },\n {\n id: 'ai-crypto',\n name: 'AI Crypto',\n description: 'Artificial Intelligence and cryptocurrency convergence',\n category: 'topics',\n focusSubject: 'AI Crypto',\n avatar: '/api/assets/ai_crypto_1758526589973.jpeg',\n profileImage: '/api/assets/ai_crypto_news_1758526589972.png',\n bio: 'The intersection of artificial intelligence and cryptocurrency technologies.',\n fullBio: [\n 'Intersection of artificial intelligence and cryptocurrency technologies.',\n 'AI-powered trading, predictive analytics, and automated market making.',\n 'Decentralized AI networks incentivized by cryptocurrency tokens.'\n ]\n },\n {\n id: 'layer2',\n name: 'Layer 2',\n description: 'Blockchain scaling solutions and second-layer protocols',\n category: 'topics',\n focusSubject: 'Layer 2',\n avatar: '/api/assets/layer2_1758526589973.jpeg',\n profileImage: '/api/assets/layer2_news_1758526589972.png',\n bio: 'Scaling solutions built on top of existing blockchain networks.',\n fullBio: [\n 'Scaling solutions that process transactions off the main blockchain.',\n 'Reduces fees and increases transaction throughput significantly.',\n 'Critical infrastructure for blockchain mass adoption and usability.'\n ]\n },\n {\n id: 'privacy-coins',\n name: 'Privacy Coins',\n description: 'Privacy-focused cryptocurrencies and anonymous transactions',\n category: 'topics',\n focusSubject: 'Privacy Coins',\n avatar: '/api/assets/privacy_coins_1758526589973.jpeg',\n profileImage: '/api/assets/privacy_coins_news_1758526589972.png',\n bio: 'Cryptocurrencies designed to provide enhanced privacy and anonymity.',\n fullBio: [\n 'Cryptocurrencies using advanced cryptography to ensure transaction privacy.',\n 'Protects user financial privacy from surveillance and data mining.',\n 'Essential for financial freedom and protection of personal information.'\n ]\n },\n {\n id: 'gamefi',\n name: 'GameFi',\n description: 'Gaming and decentralized finance integration',\n category: 'topics',\n focusSubject: 'GameFi',\n avatar: '/api/assets/gamefi_1758526589973.jpeg',\n profileImage: '/api/assets/gamefi_news_1758526589972.png',\n bio: 'Games that integrate DeFi elements and play-to-earn mechanics.',\n fullBio: [\n 'Games that integrate DeFi elements and blockchain-based ownership.',\n 'Players earn cryptocurrency rewards for gameplay and participation.',\n 'Revolutionizing gaming economics through true digital asset ownership.'\n ]\n },\n {\n id: 'socialfi',\n name: 'SocialFi',\n description: 'Social media platforms with financial incentives',\n category: 'topics',\n focusSubject: 'SocialFi',\n avatar: '/api/assets/socialfi_1758526589973.jpeg',\n profileImage: '/api/assets/socialfi_news_1758526589972.png',\n bio: 'Social media platforms that reward users with cryptocurrency.',\n fullBio: [\n 'Social media platforms that reward content creation and engagement.',\n 'Users earn tokens for creating quality content and building communities.',\n 'Aligns incentives between platforms, creators, and audiences.'\n ]\n },\n {\n id: 'cbdc',\n name: 'CBDC',\n description: 'Central Bank Digital Currencies and government crypto',\n category: 'topics',\n focusSubject: 'CBDC',\n avatar: '/api/assets/cbdc_1758526589973.jpeg',\n profileImage: '/api/assets/cbdc_news_1758526589972.png',\n bio: 'Digital versions of national currencies issued by central banks.',\n fullBio: [\n 'Digital versions of fiat currencies issued and controlled by central banks.',\n 'Combines benefits of digital payments with government backing.',\n 'Represents governments adoption of blockchain technology for monetary policy.'\n ]\n },\n {\n id: 'cross-chain',\n name: 'Cross-Chain',\n description: 'Blockchain interoperability and cross-chain protocols',\n category: 'topics',\n focusSubject: 'Cross-Chain',\n avatar: '/api/assets/cross_chain_1758526589973.jpeg',\n profileImage: '/api/assets/cross_chain_news_1758526589972.png',\n bio: 'Technologies enabling communication and value transfer between different blockchains.',\n fullBio: [\n 'Technologies enabling communication between different blockchain networks.',\n 'Allows assets and data to move freely across multiple chains.',\n 'Critical for blockchain ecosystem maturity and user experience.'\n ]\n },\n {\n id: 'yield-farming',\n name: 'Yield Farming',\n description: 'DeFi yield generation strategies and liquidity mining',\n category: 'topics',\n focusSubject: 'Yield Farming',\n avatar: '/api/assets/yield_farming_1758526589973.jpeg',\n profileImage: '/api/assets/yield_farming_news_1758526589972.png',\n bio: 'Strategies to maximize returns by providing liquidity to DeFi protocols.',\n fullBio: [\n 'Strategies to maximize returns by providing liquidity to DeFi protocols.',\n 'Users lend or stake tokens to earn high yields and bonus rewards.',\n 'Core economic activity driving DeFi protocol growth and adoption.'\n ]\n },\n {\n id: 'tokenomics',\n name: 'Tokenomics',\n description: 'Cryptocurrency token economics and design principles',\n category: 'topics',\n focusSubject: 'Tokenomics',\n avatar: '/api/assets/tokenomics_1758526589973.jpeg',\n profileImage: '/api/assets/tokenomics_news_1758526589972.png',\n bio: 'The economic design and incentive structures of cryptocurrency tokens.',\n fullBio: [\n 'Economic design and incentive structures behind cryptocurrency tokens.',\n 'Determines how tokens are distributed, used, and valued over time.',\n 'Critical factor in the long-term success of blockchain projects.'\n ]\n },\n {\n id: 'regulation',\n name: 'Regulation',\n description: 'Cryptocurrency regulation and government policy',\n category: 'topics',\n focusSubject: 'Regulation',\n avatar: '/api/assets/regulation_1758526589973.jpeg',\n profileImage: '/api/assets/regulation_news_1758526589972.png',\n bio: 'Government policies and regulations affecting the cryptocurrency industry.',\n fullBio: [\n 'Government policies and regulations shaping the cryptocurrency industry.',\n 'Balances innovation, consumer protection, and financial stability.',\n 'Critical factor determining mainstream adoption and institutional investment.'\n ]\n },\n {\n id: 'mining',\n name: 'Mining',\n description: 'Cryptocurrency mining and proof-of-work consensus',\n category: 'topics',\n focusSubject: 'Mining',\n avatar: '/api/assets/mining_1758526589973.jpeg',\n profileImage: '/api/assets/mining_news_1758526589972.png',\n bio: 'The process of validating transactions and securing blockchain networks.',\n fullBio: [\n 'Process of validating transactions and securing proof-of-work blockchains.',\n 'Miners compete to solve cryptographic puzzles for block rewards.',\n 'Essential infrastructure maintaining decentralization and network security.'\n ]\n },\n {\n id: 'staking',\n name: 'Staking',\n description: 'Proof-of-stake validation and staking rewards',\n category: 'topics',\n focusSubject: 'Staking',\n avatar: '/api/assets/staking_1758526589973.jpeg',\n profileImage: '/api/assets/staking_news_1758526589972.png',\n bio: 'Locking tokens to help secure proof-of-stake networks and earn rewards.',\n fullBio: [\n 'Locking tokens to help validate transactions on proof-of-stake networks.',\n 'More energy-efficient alternative to proof-of-work mining.',\n 'Allows token holders to earn passive income while securing networks.'\n ]\n },\n {\n id: 'institutional',\n name: 'Institutional',\n description: 'Institutional cryptocurrency adoption and investment',\n category: 'topics',\n focusSubject: 'Institutional',\n avatar: '/api/assets/institutional_1758526589973.jpeg',\n profileImage: '/api/assets/institutional_news_1758526589972.png',\n bio: 'Large institutions and corporations adopting cryptocurrency.',\n fullBio: [\n 'Large institutions and corporations adopting cryptocurrency investments.',\n 'Banks, hedge funds, and public companies adding crypto to portfolios.',\n 'Driving mainstream adoption and market maturation.'\n ]\n },\n {\n id: 'derivatives',\n name: 'Derivatives',\n description: 'Cryptocurrency derivatives and financial instruments',\n category: 'topics',\n focusSubject: 'Derivatives',\n avatar: '/api/assets/derivatives_1758526589973.jpeg',\n profileImage: '/api/assets/derivatives_news_1758526589972.png',\n bio: 'Financial instruments derived from cryptocurrency underlying assets.',\n fullBio: [\n 'Financial instruments like futures, options, and swaps on cryptocurrencies.',\n 'Enables sophisticated trading strategies and risk management.',\n 'Critical infrastructure for institutional participation in crypto markets.'\n ]\n },\n // Additional People Category outlets\n {\n id: 'vitalik-buterin',\n name: 'Vitalik Buterin',\n description: 'Ethereum founder and blockchain visionary',\n category: 'people',\n focusSubject: 'Vitalik Buterin',\n avatar: '/api/assets/vitalik_1758526589973.jpeg',\n profileImage: '/api/assets/vitalik_news_1758526589972.png',\n bio: 'Co-founder of Ethereum and one of the most influential figures in blockchain.',\n fullBio: [\n 'Co-founder of Ethereum, the worlds second-largest cryptocurrency platform.',\n 'Visionary programmer who conceptualized smart contracts and decentralized applications.',\n 'Leading voice in blockchain scalability, governance, and decentralization.'\n ]\n },\n {\n id: 'satoshi-nakamoto',\n name: 'Satoshi Nakamoto',\n description: 'Anonymous creator of Bitcoin and blockchain technology',\n category: 'people',\n focusSubject: 'Satoshi Nakamoto',\n avatar: '/api/assets/satoshi_1758526589973.jpeg',\n profileImage: '/api/assets/satoshi_news_1758526589972.png',\n bio: 'Pseudonymous creator of Bitcoin, the first and most famous cryptocurrency.',\n fullBio: [\n 'Pseudonymous creator of Bitcoin and inventor of blockchain technology.',\n 'Published the Bitcoin whitepaper in 2008, launching the cryptocurrency revolution.',\n 'Mysterious figure who disappeared from public view in 2011.'\n ]\n },\n {\n id: 'changpeng-zhao',\n name: 'Changpeng Zhao',\n description: 'Former CEO of Binance and crypto exchange pioneer',\n category: 'people',\n focusSubject: 'Changpeng Zhao',\n avatar: '/api/assets/cz_1758526589973.jpeg',\n profileImage: '/api/assets/cz_news_1758526589972.png',\n bio: 'Former CEO of Binance, the worlds largest cryptocurrency exchange.',\n fullBio: [\n 'Former CEO of Binance, building it into the worlds largest crypto exchange.',\n 'Pioneered many innovations in cryptocurrency trading and DeFi.',\n 'Influential leader in global cryptocurrency adoption and regulation.'\n ]\n },\n {\n id: 'brian-armstrong',\n name: 'Brian Armstrong',\n description: 'CEO of Coinbase and cryptocurrency adoption advocate',\n category: 'people',\n focusSubject: 'Brian Armstrong',\n avatar: '/api/assets/brian_armstrong_1758526589973.jpeg',\n profileImage: '/api/assets/brian_armstrong_news_1758526589972.png',\n bio: 'CEO and co-founder of Coinbase, the largest US cryptocurrency exchange.',\n fullBio: [\n 'CEO and co-founder of Coinbase, Americas leading cryptocurrency platform.',\n 'Advocate for clear cryptocurrency regulation and mainstream adoption.',\n 'Leading voice in bringing cryptocurrency to traditional financial institutions.'\n ]\n },\n {\n id: 'andreas-antonopoulos',\n name: 'Andreas Antonopoulos',\n description: 'Bitcoin educator and blockchain technology advocate',\n category: 'people',\n focusSubject: 'Andreas Antonopoulos',\n avatar: '/api/assets/andreas_1758526589973.jpeg',\n profileImage: '/api/assets/andreas_news_1758526589972.png',\n bio: 'Renowned Bitcoin educator and author of \"Mastering Bitcoin.\"',\n fullBio: [\n 'Renowned Bitcoin educator and author of \"Mastering Bitcoin\" and \"Mastering Ethereum.\"',\n 'Passionate advocate for decentralization and financial sovereignty.',\n 'Influential speaker educating millions about cryptocurrency technology.'\n ]\n },\n {\n id: 'michael-saylor',\n name: 'Michael Saylor',\n description: 'MicroStrategy CEO and Bitcoin maximalist',\n category: 'people',\n focusSubject: 'Michael Saylor',\n avatar: '/api/assets/saylor_1758526589973.jpeg',\n profileImage: '/api/assets/saylor_news_1758526589972.png',\n bio: 'CEO of MicroStrategy and prominent Bitcoin advocate.',\n fullBio: [\n 'Born February 4, 1965 in military family, graduated first in class from high school, earned full ROTC scholarship to MIT where he double-majored in aeronautical engineering and history of science, graduating with highest honors in 1987.',\n 'Founded MicroStrategy in 1989 with MIT fraternity brother Sanju Bansal, initially focused on business intelligence software. Won $10 million McDonald\\'s contract in 1992, took company public in 1998.',\n 'Survived dot-com crash devastation when stock plummeted from $333 to $120 in single day (March 2000). Paid $350,000 SEC penalty plus $8.3 million disgorgement for financial reporting issues.',\n 'Prolific inventor holding 48+ patents, credited as inventor of relational analytics. Founded multiple companies including Alarm.com (NASDAQ: ALRM) and Angel.com (sold for $110M).',\n 'Author of \"The Mobile Wave\" (NYT/WSJ bestseller), founded Saylor Academy providing free education to 1.8+ million students. MIT Technology Review \"Innovator Under 35\" (1999).',\n 'Executive Chairman of MicroStrategy since 2022, previously CEO for 33 years. Led corporate Bitcoin adoption strategy purchasing 639,835+ bitcoins worth billions, making MSTR largest corporate Bitcoin holder.',\n 'Bitcoin maximalist advocate promoting cryptocurrency as superior store of value and inflation hedge. His Bitcoin strategy transformed MicroStrategy from traditional software company to Bitcoin proxy investment vehicle.'\n ]\n },\n {\n id: 'elon-musk',\n name: 'Elon Musk',\n description: 'Tesla CEO and influential cryptocurrency commentator',\n category: 'people',\n focusSubject: 'Elon Musk',\n avatar: '/api/assets/elon_1758526589973.jpeg',\n profileImage: '/api/assets/elon_news_1758526589972.png',\n bio: 'CEO of Tesla and SpaceX, influential voice in cryptocurrency markets.',\n fullBio: [\n 'World\\'s wealthiest person (net worth $384-$487 billion as of 2025), born June 28, 1971 in Pretoria, South Africa. Emigrated to avoid apartheid military service, studied at Queen\\'s University and University of Pennsylvania (Economics + Physics degrees).',\n 'Early entrepreneur: Created and sold video game at age 12, co-founded Zip2 (sold for $307M), then X.com which merged to become PayPal (sold to eBay for $1.5B, Musk received $176M).',\n 'Founded SpaceX in 2002 with vision of Mars colonization, now leading private space company. First private company to send astronauts to ISS (2020), pioneered reusable rockets, operates Starlink satellite constellation.',\n 'CEO of Tesla since 2008, driving global electric vehicle revolution. Transformed company from startup to world\\'s most valuable automaker with Model S, 3, X lineup and autonomous driving technology.',\n 'Acquired Twitter for $44 billion in 2022, rebranded to X as part of vision for \"everything app.\" Also founded Neuralink (brain-machine interfaces), The Boring Company (tunnel infrastructure), and xAI (AI competitor to ChatGPT).',\n 'Briefly served as co-head of Department of Government Efficiency (DOGE) under Trump administration (January-May 2025) before returning focus to business ventures.',\n 'Known for 80-hour work weeks, visionary leadership in sustainable transport, space exploration, and artificial intelligence. His social media presence significantly impacts cryptocurrency and stock markets globally.'\n ]\n },\n {\n id: 'gavin-wood',\n name: 'Gavin Wood',\n description: 'Polkadot founder and Ethereum co-founder',\n category: 'people',\n focusSubject: 'Gavin Wood',\n avatar: '/api/assets/gavin_wood_1758526589973.jpeg',\n profileImage: '/api/assets/gavin_wood_news_1758526589972.png',\n bio: 'Co-founder of Ethereum and founder of Polkadot blockchain.',\n fullBio: [\n 'Co-founder of Ethereum and creator of the Solidity programming language.',\n 'Founder of Polkadot, pioneering blockchain interoperability solutions.',\n 'Visionary technologist advancing multi-chain blockchain ecosystem.'\n ]\n },\n {\n id: 'charles-hoskinson',\n name: 'Charles Hoskinson',\n description: 'Cardano founder and blockchain researcher',\n category: 'people',\n focusSubject: 'Charles Hoskinson',\n avatar: '/api/assets/charles_1758526589973.jpeg',\n profileImage: '/api/assets/charles_news_1758526589972.png',\n bio: 'Founder of Cardano and co-founder of Ethereum.',\n fullBio: [\n 'Founder of Cardano blockchain and co-founder of Ethereum.',\n 'Academic approach to blockchain development with peer-reviewed research.',\n 'Advocate for formal verification and scientific methodology in crypto.'\n ]\n },\n {\n id: 'sergey-nazarov',\n name: 'Sergey Nazarov',\n description: 'Chainlink co-founder and oracle technology pioneer',\n category: 'people',\n focusSubject: 'Sergey Nazarov',\n avatar: '/api/assets/sergey_1758526589973.jpeg',\n profileImage: '/api/assets/sergey_news_1758526589972.png',\n bio: 'Co-founder of Chainlink, the leading blockchain oracle network.',\n fullBio: [\n 'Co-founder of Chainlink, providing real-world data to smart contracts.',\n 'Pioneer in blockchain oracle technology and decentralized data feeds.',\n 'Enabling smart contracts to interact with external data and systems.'\n ]\n },\n {\n id: 'arthur-hayes',\n name: 'Arthur Hayes',\n description: 'Former BitMEX CEO and crypto derivatives pioneer',\n category: 'people',\n focusSubject: 'Arthur Hayes',\n avatar: '/api/assets/arthur_hayes_1758526589973.jpeg',\n profileImage: '/api/assets/arthur_hayes_news_1758526589972.png',\n bio: 'Former CEO of BitMEX and pioneer in cryptocurrency derivatives.',\n fullBio: [\n 'Former CEO of BitMEX, pioneering cryptocurrency derivatives trading.',\n 'Influential voice in crypto markets and monetary policy analysis.',\n 'Advocate for Bitcoin as alternative to traditional financial systems.'\n ]\n },\n {\n id: 'elizabeth-stark',\n name: 'Elizabeth Stark',\n description: 'Lightning Labs CEO and Bitcoin Lightning Network advocate',\n category: 'people',\n focusSubject: 'Elizabeth Stark',\n avatar: '/api/assets/elizabeth_stark_1758526589973.jpeg',\n profileImage: '/api/assets/elizabeth_stark_news_1758526589972.png',\n bio: 'CEO of Lightning Labs, developing Bitcoin Lightning Network scaling.',\n fullBio: [\n 'CEO of Lightning Labs, building Bitcoin Lightning Network infrastructure.',\n 'Advocate for Bitcoin scalability and instant payment solutions.',\n 'Pioneer in layer-2 blockchain scaling technology and Bitcoin innovation.'\n ]\n },\n {\n id: 'cathie-wood',\n name: 'Cathie Wood',\n description: 'ARK Invest CEO and innovation investor',\n category: 'people',\n focusSubject: 'Cathie Wood',\n avatar: '/api/assets/cathie_wood_1758526589973.jpeg',\n profileImage: '/api/assets/cathie_wood_news_1758526589972.png',\n bio: 'CEO of ARK Invest, prominent investor in disruptive technologies.',\n fullBio: [\n 'CEO of ARK Invest, focusing on disruptive innovation investments.',\n 'Strong advocate for Bitcoin and cryptocurrency adoption.',\n 'Influential voice connecting traditional investing with crypto markets.'\n ]\n },\n {\n id: 'jack-dorsey',\n name: 'Jack Dorsey',\n description: 'Former Twitter CEO and Bitcoin advocate',\n category: 'people',\n focusSubject: 'Jack Dorsey',\n avatar: '/api/assets/jack_dorsey_1758526589973.jpeg',\n profileImage: '/api/assets/jack_dorsey_news_1758526589972.png',\n bio: 'Former CEO of Twitter and Square, passionate Bitcoin advocate.',\n fullBio: [\n 'Former CEO of Twitter and Square, passionate about Bitcoin adoption.',\n 'Building decentralized social media and Bitcoin infrastructure.',\n 'Advocate for Bitcoin as tool for global financial inclusion.'\n ]\n },\n {\n id: 'dan-larimer',\n name: 'Dan Larimer',\n description: 'Blockchain developer and EOS creator',\n category: 'people',\n focusSubject: 'Dan Larimer',\n avatar: '/api/assets/dan_larimer_1758526589973.jpeg',\n profileImage: '/api/assets/dan_larimer_news_1758526589972.png',\n bio: 'Creator of EOS, Steemit, and BitShares blockchain platforms.',\n fullBio: [\n 'Creator of multiple blockchain platforms including EOS and BitShares.',\n 'Pioneer in delegated proof-of-stake consensus mechanisms.',\n 'Innovative developer focused on blockchain scalability and user experience.'\n ]\n },\n {\n id: 'cameron-winklevoss',\n name: 'Cameron Winklevoss',\n description: 'Gemini co-founder and crypto entrepreneur',\n category: 'people',\n focusSubject: 'Cameron Winklevoss',\n avatar: '/api/assets/cameron_1758526589973.jpeg',\n profileImage: '/api/assets/cameron_news_1758526589972.png',\n bio: 'Co-founder of Gemini cryptocurrency exchange.',\n fullBio: [\n 'Co-founder of Gemini cryptocurrency exchange with twin brother Tyler.',\n 'Early Bitcoin investor and advocate for cryptocurrency regulation.',\n 'Entrepreneur bridging traditional finance with digital assets.'\n ]\n },\n {\n id: 'tyler-winklevoss',\n name: 'Tyler Winklevoss',\n description: 'Gemini co-founder and digital asset advocate',\n category: 'people',\n focusSubject: 'Tyler Winklevoss',\n avatar: '/api/assets/tyler_1758526589973.jpeg',\n profileImage: '/api/assets/tyler_news_1758526589972.png',\n bio: 'Co-founder of Gemini cryptocurrency exchange.',\n fullBio: [\n 'Co-founder of Gemini cryptocurrency exchange with twin brother Cameron.',\n 'Pioneer in cryptocurrency compliance and regulatory engagement.',\n 'Advocate for institutional-grade cryptocurrency infrastructure.'\n ]\n },\n {\n id: 'roger-ver',\n name: 'Roger Ver',\n description: 'Bitcoin Cash advocate and early Bitcoin investor',\n category: 'people',\n focusSubject: 'Roger Ver',\n avatar: '/api/assets/roger_ver_1758526589973.jpeg',\n profileImage: '/api/assets/roger_ver_news_1758526589972.png',\n bio: 'Early Bitcoin investor and advocate, known as \"Bitcoin Jesus.\"',\n fullBio: [\n 'Early Bitcoin investor and evangelist, known as \"Bitcoin Jesus.\"',\n 'Prominent advocate for Bitcoin Cash and cryptocurrency adoption.',\n 'Entrepreneur focused on peer-to-peer electronic cash systems.'\n ]\n },\n {\n id: 'palmer-luckey',\n name: 'Palmer Luckey',\n description: 'Oculus VR founder and Anduril Industries CEO, pioneering VR/AR and defense tech',\n category: 'people',\n focusSubject: 'Palmer Luckey',\n avatar: '/api/assets/palmer_luckey_1758526589973.jpeg',\n profileImage: '/api/assets/palmer_luckey_profile_1758526589973.jpeg',\n bio: 'Founder of Oculus VR and current CEO of Anduril Industries, pioneering virtual reality and defense technology.',\n fullBio: [\n 'Founded Oculus VR at age 20, revolutionizing virtual reality technology before selling to Facebook for $2 billion.',\n 'Currently CEO of Anduril Industries, developing AI-powered defense systems and autonomous weapons platforms.',\n 'Visionary entrepreneur bridging gaming technology, military applications, and next-generation computing interfaces.'\n ]\n },\n {\n id: 'mike-novogratz',\n name: 'Mike Novogratz',\n description: 'CEO of Galaxy Digital and crypto investment pioneer',\n category: 'people',\n focusSubject: 'Mike Novogratz',\n avatar: '/api/assets/mike novogratz_1759136098707.jpg',\n profileImage: '/api/assets/mike novogratz_1759136098707.jpg',\n bio: 'CEO and founder of Galaxy Digital, a leading cryptocurrency investment firm.',\n fullBio: [\n 'CEO and founder of Galaxy Digital, one of the largest cryptocurrency investment firms.',\n 'Former Goldman Sachs partner and Fortress Investment Group principal with 20+ years Wall Street experience.',\n 'Early Bitcoin advocate and institutional crypto adoption pioneer, managing billions in digital assets.'\n ]\n },\n {\n id: 'michael-j-saylor',\n name: 'Michael J. Saylor',\n description: 'MicroStrategy CEO and Bitcoin maximalist',\n category: 'people',\n focusSubject: 'Michael J. Saylor',\n avatar: '/api/assets/michael j. saylor_1759136096272.jpg',\n profileImage: '/api/assets/michael j. saylor_1759136096272.jpg',\n bio: 'CEO of MicroStrategy and leading advocate for Bitcoin as a treasury reserve asset.',\n fullBio: [\n 'CEO of MicroStrategy, the first major corporation to adopt Bitcoin as primary treasury reserve asset.',\n 'Led MicroStrategy to purchase over $5 billion worth of Bitcoin, pioneering corporate crypto adoption.',\n 'Prominent Bitcoin maximalist and advocate for digital asset treasury strategies among corporations.'\n ]\n },\n // Companies Category (additional outlets)\n {\n id: 'coinbase',\n name: 'Coinbase',\n description: 'Leading cryptocurrency exchange and platform',\n category: 'companies',\n focusSubject: 'Coinbase',\n avatar: '/api/assets/coinbase_1758526589973.jpeg',\n profileImage: '/api/assets/coinbase_news_1758526589972.png',\n bio: 'Leading cryptocurrency exchange and platform in the United States.',\n fullBio: [\n 'Largest cryptocurrency exchange in the United States, publicly traded.',\n 'Provides easy-to-use platform for buying, selling, and storing cryptocurrencies.',\n 'Pioneer in regulatory compliance and institutional cryptocurrency services.'\n ]\n },\n {\n id: 'binance',\n name: 'Binance',\n description: 'Worlds largest cryptocurrency exchange by volume',\n category: 'companies',\n focusSubject: 'Binance',\n avatar: '/api/assets/binance_1758526589973.jpeg',\n profileImage: '/api/assets/binance_news_1758526589972.png',\n bio: 'The worlds largest cryptocurrency exchange by trading volume.',\n fullBio: [\n 'Worlds largest cryptocurrency exchange by trading volume and users.',\n 'Comprehensive ecosystem including spot trading, futures, staking, and DeFi.',\n 'Global platform serving millions of users in over 100 countries.'\n ]\n },\n {\n id: 'galaxy-digital',\n name: 'Galaxy Digital',\n description: 'Leading cryptocurrency investment and trading firm',\n category: 'companies',\n focusSubject: 'Galaxy Digital',\n avatar: '/api/assets/galaxy_1759136096271.jpeg',\n profileImage: '/api/assets/galaxy_1759136096271.jpeg',\n bio: 'Leading cryptocurrency investment firm and digital asset merchant bank.',\n fullBio: [\n 'Leading cryptocurrency investment firm and digital asset merchant bank founded by Mike Novogratz.',\n 'Provides institutional-grade cryptocurrency trading, investment management, and advisory services.',\n 'Publicly traded company (TSX: GLXY) serving institutions and high-net-worth investors in digital assets.'\n ]\n },\n {\n id: 'microstrategy',\n name: 'MicroStrategy',\n description: 'Business intelligence company with major Bitcoin holdings',\n category: 'companies',\n focusSubject: 'MicroStrategy',\n avatar: '/api/assets/microstrategy_1759136096272.webp',\n profileImage: '/api/assets/microstrategy_1759136096272.webp',\n bio: 'Business intelligence company with one of the largest corporate Bitcoin treasuries.',\n fullBio: [\n 'Business intelligence software company with massive Bitcoin treasury.',\n 'First major corporation to adopt Bitcoin as primary reserve asset.',\n 'Pioneer in corporate Bitcoin adoption and treasury diversification strategy.'\n ]\n },\n {\n id: 'tesla',\n name: 'Tesla',\n description: 'Electric vehicle company with cryptocurrency investments',\n category: 'companies',\n focusSubject: 'Tesla',\n avatar: '/api/assets/tesla_1758526589973.jpeg',\n profileImage: '/api/assets/tesla_news_1758526589972.png',\n bio: 'Electric vehicle manufacturer with significant cryptocurrency holdings.',\n fullBio: [\n 'Electric vehicle and clean energy company with Bitcoin investments.',\n 'Pioneered corporate cryptocurrency adoption and payment acceptance.',\n 'Leading voice in sustainable Bitcoin mining and renewable energy.'\n ]\n },\n {\n id: 'paypal',\n name: 'PayPal',\n description: 'Digital payments company offering cryptocurrency services',\n category: 'companies',\n focusSubject: 'PayPal',\n avatar: '/api/assets/paypal_1758526589973.jpeg',\n profileImage: '/api/assets/paypal_news_1758526589972.png',\n bio: 'Digital payments company providing cryptocurrency buying and selling services.',\n fullBio: [\n 'Global digital payments leader offering cryptocurrency services.',\n 'Enables millions of users to buy, sell, and hold cryptocurrencies.',\n 'Bridging traditional payments with digital asset adoption.'\n ]\n },\n {\n id: 'square',\n name: 'Square',\n description: 'Financial services company with Bitcoin integration',\n category: 'companies',\n focusSubject: 'Square',\n avatar: '/api/assets/square_1758526589973.jpeg',\n profileImage: '/api/assets/square_news_1758526589972.png',\n bio: 'Financial services and mobile payment company with Bitcoin integration.',\n fullBio: [\n 'Financial services company integrating Bitcoin into payment solutions.',\n 'Cash App provides easy Bitcoin buying and selling for consumers.',\n 'Advocate for Bitcoin adoption and Lightning Network development.'\n ]\n },\n {\n id: 'grayscale',\n name: 'Grayscale',\n description: 'Digital asset investment management company',\n category: 'companies',\n focusSubject: 'Grayscale',\n avatar: '/api/assets/grayscale_1758526589973.jpeg',\n profileImage: '/api/assets/grayscale_news_1758526589972.png',\n bio: 'Leading digital asset investment management company.',\n fullBio: [\n 'Leading digital asset investment manager with billions under management.',\n 'Provides institutional and accredited investor access to cryptocurrencies.',\n 'Pioneer in cryptocurrency investment products and regulatory compliance.'\n ]\n },\n {\n id: 'chainlink',\n name: 'Chainlink',\n description: 'Decentralized oracle network connecting blockchains to real world',\n category: 'companies',\n focusSubject: 'Chainlink',\n avatar: '/api/assets/chainlink_1758526589973.jpeg',\n profileImage: '/api/assets/chainlink_news_1758526589972.png',\n bio: 'Decentralized oracle network providing real-world data to smart contracts.',\n fullBio: [\n 'Leading decentralized oracle network connecting blockchains to real-world data.',\n 'Enables smart contracts to securely interact with external data feeds.',\n 'Critical infrastructure powering DeFi and blockchain applications.'\n ]\n },\n {\n id: 'ethereum-foundation',\n name: 'Ethereum Foundation',\n description: 'Non-profit supporting Ethereum blockchain development',\n category: 'companies',\n focusSubject: 'Ethereum Foundation',\n avatar: '/api/assets/ethereum_foundation_1758526589973.jpeg',\n profileImage: '/api/assets/ethereum_foundation_news_1758526589972.png',\n bio: 'Non-profit organization supporting Ethereum blockchain development.',\n fullBio: [\n 'Non-profit organization supporting Ethereum blockchain development and research.',\n 'Funds core protocol development and ecosystem growth initiatives.',\n 'Steward of the Ethereum networks evolution and decentralization.'\n ]\n },\n {\n id: 'consensys',\n name: 'ConsenSys',\n description: 'Ethereum software company and blockchain infrastructure',\n category: 'companies',\n focusSubject: 'ConsenSys',\n avatar: '/api/assets/consensys_1758526589973.jpeg',\n profileImage: '/api/assets/consensys_news_1758526589972.png',\n bio: 'Leading Ethereum software company building blockchain infrastructure.',\n fullBio: [\n 'Leading Ethereum software company building decentralized applications.',\n 'Creates tools and infrastructure for Web3 and DeFi applications.',\n 'MetaMask wallet provider serving millions of Ethereum users globally.'\n ]\n },\n {\n id: 'ripple',\n name: 'Ripple',\n description: 'Digital payment protocol for global financial institutions',\n category: 'companies',\n focusSubject: 'Ripple',\n avatar: '/api/assets/ripple_1758526589973.jpeg',\n profileImage: '/api/assets/ripple_news_1758526589972.png',\n bio: 'Digital payment protocol company serving global financial institutions.',\n fullBio: [\n 'Digital payment protocol company serving banks and financial institutions.',\n 'Enables fast, low-cost cross-border payments using XRP cryptocurrency.',\n 'Bridging traditional banking with modern blockchain payment solutions.'\n ]\n },\n {\n id: 'circle',\n name: 'Circle',\n description: 'Financial technology company behind USDC stablecoin',\n category: 'companies',\n focusSubject: 'Circle',\n avatar: '/api/assets/circle_1758526589973.jpeg',\n profileImage: '/api/assets/circle_news_1758526589972.png',\n bio: 'Financial technology company behind USDC stablecoin.',\n fullBio: [\n 'Financial technology company issuing USDC, second-largest stablecoin.',\n 'Provides institutional-grade digital asset infrastructure and services.',\n 'Pioneer in regulated stablecoin issuance and digital dollar adoption.'\n ]\n },\n {\n id: 'kraken',\n name: 'Kraken',\n description: 'Veteran cryptocurrency exchange and trading platform',\n category: 'companies',\n focusSubject: 'Kraken',\n avatar: '/api/assets/kraken_1758526589973.jpeg',\n profileImage: '/api/assets/kraken_news_1758526589972.png',\n bio: 'Veteran cryptocurrency exchange known for security and advanced trading.',\n fullBio: [\n 'Long-standing cryptocurrency exchange known for security and reliability.',\n 'Offers advanced trading features and institutional cryptocurrency services.',\n 'Pioneer in cryptocurrency market analysis and regulatory compliance.'\n ]\n },\n {\n id: 'ftx',\n name: 'FTX',\n description: 'Former major cryptocurrency exchange and derivatives platform',\n category: 'companies',\n focusSubject: 'FTX',\n avatar: '/api/assets/ftx_1758526589973.jpeg',\n profileImage: '/api/assets/ftx_news_1758526589972.png',\n bio: 'Former major cryptocurrency exchange that collapsed amid fraud allegations.',\n fullBio: [\n 'Former major cryptocurrency exchange that collapsed in November 2022.',\n 'Once a leading platform for crypto derivatives and institutional trading.',\n 'Bankruptcy and fraud case serving as cautionary tale for crypto industry.'\n ]\n },\n {\n id: 'alameda-research',\n name: 'Alameda Research',\n description: 'Former quantitative trading firm in cryptocurrency markets',\n category: 'companies',\n focusSubject: 'Alameda Research',\n avatar: '/api/assets/alameda_1758526589973.jpeg',\n profileImage: '/api/assets/alameda_news_1758526589972.png',\n bio: 'Former quantitative trading firm associated with FTX collapse.',\n fullBio: [\n 'Former quantitative cryptocurrency trading firm founded by Sam Bankman-Fried.',\n 'Involved in the FTX collapse and subsequent fraud investigations.',\n 'Case study in risk management and conflicts of interest in crypto trading.'\n ]\n },\n {\n id: 'celsius',\n name: 'Celsius',\n description: 'Former cryptocurrency lending platform that filed for bankruptcy',\n category: 'companies',\n focusSubject: 'Celsius',\n avatar: '/api/assets/celsius_1758526589973.jpeg',\n profileImage: '/api/assets/celsius_news_1758526589972.png',\n bio: 'Former cryptocurrency lending platform that filed for bankruptcy.',\n fullBio: [\n 'Former cryptocurrency lending platform offering high yields to depositors.',\n 'Filed for bankruptcy in 2022 amid liquidity crisis and regulatory issues.',\n 'Cautionary tale about risks in unregulated cryptocurrency lending.'\n ]\n },\n {\n id: 'three-arrows-capital',\n name: 'Three Arrows Capital',\n description: 'Former cryptocurrency hedge fund that collapsed',\n category: 'companies',\n focusSubject: 'Three Arrows Capital',\n avatar: '/api/assets/3ac_1758526589973.jpeg',\n profileImage: '/api/assets/3ac_news_1758526589972.png',\n bio: 'Former prominent cryptocurrency hedge fund that collapsed in 2022.',\n fullBio: [\n 'Former prominent cryptocurrency hedge fund managing billions in assets.',\n 'Collapsed in 2022 due to excessive leverage and poor risk management.',\n 'Bankruptcy had widespread impacts across cryptocurrency markets and DeFi.'\n ]\n },\n // Companies Category (4 outlets)\n {\n id: 'xtao',\n name: 'xTAO',\n description: 'Publicly traded infrastructure company for the Bittensor ecosystem',\n category: 'companies',\n focusSubject: 'xTAO',\n avatar: '/api/assets/xtao_logo_1758526589976.jpg',\n profileImage: '/api/assets/xTAO news_1758526589976.png',\n bio: 'A publicly traded company listed on the TSX Venture Exchange, focused on building infrastructure for the Bittensor ecosystem.',\n fullBio: [\n 'Publicly traded company on TSX Venture Exchange building infrastructure for Bittensor ecosystem.',\n 'High-performance validator holding significant TAO token treasury for staking and network security.',\n 'Provides regulated entry point for institutional investors seeking decentralized AI exposure.'\n ]\n },\n {\n id: 'yuma',\n name: 'YUMA',\n description: 'Leading accelerator and investment firm for the Bittensor ecosystem',\n category: 'companies',\n focusSubject: 'YUMA',\n avatar: '/api/assets/yuma_1758526589977.png',\n profileImage: '/api/assets/yuma news_1758526589976.png',\n bio: 'A leading accelerator and investment firm focused exclusively on the Bittensor ecosystem.',\n fullBio: [\n 'Leading accelerator and investment firm focused exclusively on the Bittensor ecosystem.',\n 'Provides capital, technical resources, and community connections for building new Subnets.',\n 'Known for strong due diligence and nurturing successful high-performing Subnet projects.'\n ]\n },\n {\n id: 'taox',\n name: 'TAOX',\n description: 'Public investment vehicle for Bittensor ecosystem exposure',\n category: 'companies',\n focusSubject: 'TAOX',\n avatar: '/api/assets/taox_1758526589975.jpeg',\n profileImage: '/api/assets/taox news_1758526589975.png',\n bio: 'Public investment vehicle focused on providing exposure to the Bittensor ecosystem.',\n fullBio: [\n 'Public investment vehicle focused on providing exposure to the Bittensor ecosystem.',\n 'Specializes in acquiring, holding, and staking TAO tokens for institutional investors.',\n 'Bridges conventional financial markets with decentralized AI sector opportunities.'\n ]\n },\n {\n id: 'oblong',\n name: 'Oblong',\n description: 'Spatial computing and collaborative interface technology company',\n category: 'companies',\n focusSubject: 'Oblong',\n avatar: '/api/assets/oblong_1758526589974.png',\n profileImage: '/api/assets/oblong_1758526589974.png',\n bio: 'An innovative technology company focused on spatial computing and collaborative interfaces.',\n fullBio: [\n 'Technology company pioneering spatial computing and gesture-based collaborative interfaces.',\n 'Develops enterprise visualization solutions and intuitive human-computer interaction technologies.',\n 'Focus on immersive workspace solutions and next-generation user interface design.'\n ]\n },\n // Football Clubs - Topics Category (4 outlets)\n {\n id: 'borussia-dortmund',\n name: 'Borussia Dortmund',\n description: 'German professional football club based in Dortmund',\n category: 'topics',\n focusSubject: 'Borussia Dortmund',\n avatar: '/api/assets/Ballspielverein Borussia 09 e.V. Dortmund (Borussia Dortmund) _1759157881265.png',\n profileImage: '/api/assets/Ballspielverein Borussia 09 e.V. Dortmund (Borussia Dortmund) _1759157881265.png',\n bio: 'German professional football club competing in the Bundesliga.',\n fullBio: [\n 'One of the most successful football clubs in Germany with eight Bundesliga titles.',\n 'Known for their passionate fanbase and the famous \"Yellow Wall\" at Signal Iduna Park.',\n 'Regular competitors in UEFA Champions League with strong youth development programs.'\n ]\n },\n {\n id: 'inter-milan',\n name: 'Inter Milan',\n description: 'Italian professional football club based in Milan',\n category: 'topics',\n focusSubject: 'Inter Milan',\n avatar: '/api/assets/inter milan_1759157883456.png',\n profileImage: '/api/assets/inter milan_1759157883456.png',\n bio: 'Italian professional football club competing in Serie A.',\n fullBio: [\n 'One of the most successful clubs in Italian football history with 19 Serie A titles.',\n 'The only Italian team to have won the European treble (Champions League, domestic league, and cup).',\n 'Known for their distinctive blue and black striped jerseys and passionate Milanese support.'\n ]\n },\n {\n id: 'manchester-city',\n name: 'Manchester City F.C.',\n description: 'English professional football club based in Manchester',\n category: 'topics',\n focusSubject: 'Manchester City',\n avatar: '/api/assets/Manchester City F.C. _1759157887061.png',\n profileImage: '/api/assets/Manchester City F.C. _1759157887061.png',\n bio: 'English professional football club competing in the Premier League.',\n fullBio: [\n 'One of the most successful English clubs in recent years with multiple Premier League titles.',\n 'Known for their attacking style of play under manager Pep Guardiola.',\n 'Regular competitors in UEFA Champions League with state-of-the-art training facilities.'\n ]\n },\n {\n id: 'liverpool-fc',\n name: 'Liverpool F.C.',\n description: 'English professional football club based in Liverpool',\n category: 'topics',\n focusSubject: 'Liverpool',\n avatar: '/api/assets/Liverpool F.C. _1759157885432.png',\n profileImage: '/api/assets/Liverpool F.C. _1759157885432.png',\n bio: 'English professional football club competing in the Premier League.',\n fullBio: [\n 'One of the most successful clubs in English and European football history.',\n 'Known for their passionate supporters and the famous \"You\\'ll Never Walk Alone\" anthem.',\n 'Six-time European Cup/Champions League winners with a rich footballing heritage.'\n ]\n },\n // Football Players - People Category (12 outlets)\n {\n id: 'giovanni-reyna',\n name: 'Giovanni Reyna',\n description: 'American professional footballer playing for Borussia Dortmund',\n category: 'people',\n focusSubject: 'Giovanni Reyna',\n avatar: '/api/assets/default-player.png',\n profileImage: '/api/assets/default-player.png',\n bio: 'American attacking midfielder and winger for Borussia Dortmund and the United States national team.',\n fullBio: [\n 'Talented American attacking midfielder known for his creativity and technical ability.',\n 'Son of former USA captain Claudio Reyna, continuing the family footballing legacy.',\n 'Rising star in the Bundesliga with excellent dribbling skills and vision.'\n ]\n },\n {\n id: 'felix-nmecha',\n name: 'Felix Nmecha',\n description: 'German professional footballer playing for Borussia Dortmund',\n category: 'people',\n focusSubject: 'Felix Nmecha',\n avatar: '/api/assets/default-player.png',\n profileImage: '/api/assets/default-player.png',\n bio: 'German central midfielder for Borussia Dortmund and the Germany national team.',\n fullBio: [\n 'Dynamic central midfielder known for his work rate and pressing ability.',\n 'Former Manchester City academy product who developed through German football.',\n 'Key player in Dortmund\\'s midfield with strong defensive contributions.'\n ]\n },\n {\n id: 'julian-brandt',\n name: 'Julian Brandt',\n description: 'German professional footballer playing for Borussia Dortmund',\n category: 'people',\n focusSubject: 'Julian Brandt',\n avatar: '/api/assets/default-player.png',\n profileImage: '/api/assets/default-player.png',\n bio: 'German attacking midfielder and winger for Borussia Dortmund and the Germany national team.',\n fullBio: [\n 'Versatile attacking midfielder capable of playing multiple positions across the front line.',\n 'Product of Bayer Leverkusen academy before joining Borussia Dortmund.',\n 'Regular Germany international with excellent passing range and football intelligence.'\n ]\n },\n {\n id: 'lautaro-martinez',\n name: 'Lautaro Martínez',\n description: 'Argentine professional footballer playing for Inter Milan',\n category: 'people',\n focusSubject: 'Lautaro Martínez',\n avatar: '/api/assets/default-player.png',\n profileImage: '/api/assets/default-player.png',\n bio: 'Argentine striker for Inter Milan and the Argentina national team.',\n fullBio: [\n 'Clinical striker known for his positioning, finishing ability, and work rate.',\n 'Key player in Argentina\\'s World Cup victory and Copa América successes.',\n 'Partnership with fellow striker forms one of Europe\\'s most feared attacking duos.'\n ]\n },\n {\n id: 'marcus-thuram',\n name: 'Marcus Thuram',\n description: 'French professional footballer playing for Inter Milan',\n category: 'people',\n focusSubject: 'Marcus Thuram',\n avatar: '/api/assets/default-player.png',\n profileImage: '/api/assets/default-player.png',\n bio: 'French striker and winger for Inter Milan and the France national team.',\n fullBio: [\n 'Versatile forward capable of playing across the front line with pace and power.',\n 'Son of French football legend Lilian Thuram, carrying on the family tradition.',\n 'Proven goalscorer in both Bundesliga and Serie A with strong physical presence.'\n ]\n },\n {\n id: 'nicolo-barella',\n name: 'Nicolò Barella',\n description: 'Italian professional footballer playing for Inter Milan',\n category: 'people',\n focusSubject: 'Nicolò Barella',\n avatar: '/api/assets/default-player.png',\n profileImage: '/api/assets/default-player.png',\n bio: 'Italian central midfielder for Inter Milan and the Italy national team.',\n fullBio: [\n 'Dynamic central midfielder known for his energy, passing ability, and goal threat.',\n 'Key player in Italy\\'s European Championship victory and Inter Milan\\'s success.',\n 'Complete midfielder with excellent technical skills and leadership qualities.'\n ]\n },\n {\n id: 'erling-haaland',\n name: 'Erling Haaland',\n description: 'Norwegian professional footballer playing for Manchester City',\n category: 'people',\n focusSubject: 'Erling Haaland',\n avatar: '/api/assets/default-player.png',\n profileImage: '/api/assets/default-player.png',\n bio: 'Norwegian striker for Manchester City and the Norway national team.',\n fullBio: [\n 'Prolific goalscorer known for his speed, strength, and clinical finishing ability.',\n 'Record-breaking striker who has dominated every league he has played in.',\n 'One of the most promising young talents in world football with incredible goal-scoring records.'\n ]\n },\n {\n id: 'phil-foden',\n name: 'Phil Foden',\n description: 'English professional footballer playing for Manchester City',\n category: 'people',\n focusSubject: 'Phil Foden',\n avatar: '/api/assets/default-player.png',\n profileImage: '/api/assets/default-player.png',\n bio: 'English attacking midfielder and winger for Manchester City and the England national team.',\n fullBio: [\n 'Manchester City academy graduate known for his dribbling, creativity, and goal threat.',\n 'Versatile player capable of playing multiple positions in attack and midfield.',\n 'Regular England international and key player in City\\'s recent successes.'\n ]\n },\n {\n id: 'rodri-hernandez',\n name: 'Rodri Hernández',\n description: 'Spanish professional footballer playing for Manchester City',\n category: 'people',\n focusSubject: 'Rodri Hernández',\n avatar: '/api/assets/default-player.png',\n profileImage: '/api/assets/default-player.png',\n bio: 'Spanish defensive midfielder for Manchester City and the Spain national team.',\n fullBio: [\n 'Exceptional defensive midfielder known for his passing range and tactical intelligence.',\n 'Key player in Manchester City\\'s midfield providing stability and distribution.',\n 'Spain international and winner of major tournaments at both club and international level.'\n ]\n },\n {\n id: 'mohamed-salah',\n name: 'Mohamed Salah',\n description: 'Egyptian professional footballer playing for Liverpool',\n category: 'people',\n focusSubject: 'Mohamed Salah',\n avatar: '/api/assets/default-player.png',\n profileImage: '/api/assets/default-player.png',\n bio: 'Egyptian winger and forward for Liverpool and the Egypt national team.',\n fullBio: [\n 'Prolific winger and forward known for his pace, dribbling, and goal-scoring ability.',\n 'Liverpool legend and one of the Premier League\\'s all-time leading scorers.',\n 'Egyptian icon and captain of the national team with numerous individual awards.'\n ]\n },\n {\n id: 'virgil-van-dijk',\n name: 'Virgil van Dijk',\n description: 'Dutch professional footballer playing for Liverpool',\n category: 'people',\n focusSubject: 'Virgil van Dijk',\n avatar: '/api/assets/default-player.png',\n profileImage: '/api/assets/default-player.png',\n bio: 'Dutch centre-back for Liverpool and captain of the Netherlands national team.',\n fullBio: [\n 'World-class centre-back known for his aerial ability, leadership, and defensive skills.',\n 'Captain of Liverpool and the Netherlands national team with numerous achievements.',\n 'Considered one of the best defenders in modern football with excellent ball-playing ability.'\n ]\n },\n {\n id: 'alexis-mac-allister',\n name: 'Alexis Mac Allister',\n description: 'Argentine professional footballer playing for Liverpool',\n category: 'people',\n focusSubject: 'Alexis Mac Allister',\n avatar: '/api/assets/default-player.png',\n profileImage: '/api/assets/default-player.png',\n bio: 'Argentine central midfielder for Liverpool and the Argentina national team.',\n fullBio: [\n 'Versatile midfielder known for his passing ability, work rate, and tactical intelligence.',\n 'World Cup winner with Argentina and key player in their recent international successes.',\n 'Adaptable player capable of playing multiple midfield roles with excellent technical skills.'\n ]\n }\n ];\n\n for (const outlet of defaultOutlets) {\n const mediaOutlet: MediaOutlet = {\n ...outlet,\n avatar: outlet.avatar || null,\n profileImage: outlet.profileImage || null,\n fullBio: (outlet as any).fullBio || null,\n wikiProfile: null\n };\n this.outlets.set(outlet.id, mediaOutlet);\n }\n\n console.log(`[MemStorage] Initialized with ${defaultOutlets.length} outlets`);\n }\n\n // User methods\n async getUser(id: string): Promise<User | undefined> {\n return this.users.get(id);\n }\n\n async getUserByUsername(username: string): Promise<User | undefined> {\n return Array.from(this.users.values()).find(user => user.username === username);\n }\n\n async createUser(user: InsertUser): Promise<User> {\n const newUser: User = {\n id: randomUUID(),\n ...user,\n avatar: user.avatar || null,\n preferredLanguage: user.preferredLanguage || null\n };\n this.users.set(newUser.id, newUser);\n return newUser;\n }\n\n // Media outlet methods\n async getAllOutlets(): Promise<MediaOutlet[]> {\n const result = await db.select().from(mediaOutlets).orderBy(asc(mediaOutlets.name));\n return result;\n }\n\n async getOutletsByCategory(category: string): Promise<MediaOutlet[]> {\n const result = await db.select().from(mediaOutlets).where(eq(mediaOutlets.category, category)).orderBy(asc(mediaOutlets.name));\n return result;\n }\n\n async getOutletById(id: string): Promise<MediaOutlet | undefined> {\n const result = await db.select().from(mediaOutlets).where(eq(mediaOutlets.id, id));\n return result[0] || undefined;\n }\n\n async createOutlet(outlet: InsertMediaOutlet): Promise<MediaOutlet> {\n const newOutlet: MediaOutlet = {\n id: randomUUID(),\n ...outlet,\n avatar: outlet.avatar || null,\n profileImage: outlet.profileImage || null,\n fullBio: outlet.fullBio || null,\n wikiProfile: outlet.wikiProfile || null\n };\n \n try {\n await db.insert(mediaOutlets).values(newOutlet);\n return newOutlet;\n } catch (error) {\n console.error('Error inserting outlet into database:', error);\n throw error;\n }\n }\n\n async deleteOutlet(id: string): Promise<boolean> {\n try {\n const result = await db.delete(mediaOutlets).where(eq(mediaOutlets.id, id));\n return result.rowCount !== undefined && result.rowCount > 0;\n } catch (error) {\n console.error('Error deleting outlet from database:', error);\n return false;\n }\n }\n\n async updateOutlet(id: string, updates: Partial<InsertMediaOutlet>): Promise<MediaOutlet | undefined> {\n try {\n // First check if outlet exists\n const existing = await db.select().from(mediaOutlets).where(eq(mediaOutlets.id, id));\n if (existing.length === 0) return undefined;\n \n // Update the outlet\n const result = await db.update(mediaOutlets)\n .set(updates)\n .where(eq(mediaOutlets.id, id))\n .returning();\n \n return result[0] || undefined;\n } catch (error) {\n console.error('Error updating outlet in database:', error);\n return undefined;\n }\n }\n\n async mergeOutlets(fromOutletId: string, toOutletId: string): Promise<{ success: boolean; movedArticles: number }> {\n const fromOutlet = this.outlets.get(fromOutletId);\n const toOutlet = this.outlets.get(toOutletId);\n \n if (!fromOutlet || !toOutlet) {\n return { success: false, movedArticles: 0 };\n }\n\n // Move all articles from source outlet to target outlet\n let movedArticles = 0;\n for (const [articleId, article] of Array.from(this.articles.entries())) {\n if (article.outletId === fromOutletId) {\n this.articles.set(articleId, { ...article, outletId: toOutletId });\n movedArticles++;\n }\n }\n\n // Delete the source outlet\n this.outlets.delete(fromOutletId);\n\n return { success: true, movedArticles };\n }\n\n // Article methods\n async getAllArticles(): Promise<Article[]> {\n const result = await db.select().from(articles).orderBy(desc(articles.publishedAt));\n return result;\n }\n\n async getArticlesByOutlet(outletId: string): Promise<Article[]> {\n const result = await db.select().from(articles).where(eq(articles.outletId, outletId)).orderBy(desc(articles.publishedAt));\n return result;\n }\n\n async getArticleCountsByOutlets(): Promise<Record<string, number>> {\n const results = await db.select({\n outletId: articles.outletId,\n count: sql<number>`count(*)`.as('count')\n })\n .from(articles)\n .groupBy(articles.outletId);\n \n const counts: Record<string, number> = {};\n for (const result of results) {\n counts[result.outletId] = Number(result.count);\n }\n return counts;\n }\n\n async getArticleById(id: string): Promise<Article | undefined> {\n const result = await db.select().from(articles).where(eq(articles.id, id));\n return result[0] || undefined;\n }\n\n async getFeaturedArticles(limit: number = 10): Promise<Article[]> {\n const result = await db.select().from(articles).orderBy(desc(articles.publishedAt)).limit(limit);\n return result;\n }\n\n async searchArticles(query: string): Promise<Article[]> {\n const searchTerm = `%${query.toLowerCase()}%`;\n const result = await db.select().from(articles)\n .where(\n or(\n like(sql`lower(${articles.title})`, searchTerm),\n like(sql`lower(${articles.summary})`, searchTerm),\n like(sql`lower(${articles.body})`, searchTerm)\n )\n )\n .orderBy(desc(articles.publishedAt));\n return result;\n }\n\n async createArticle(article: InsertArticle): Promise<Article> {\n const newArticle: Article = {\n id: randomUUID(),\n viewCount: 0,\n sourceUrl: null,\n author: null,\n originalImageUrl: null,\n scrapedAt: null,\n isScraped: 0,\n ...article,\n tags: article.tags || null\n };\n \n try {\n await db.insert(articles).values(newArticle);\n return newArticle;\n } catch (error) {\n console.error('Error inserting article into database:', error);\n throw error;\n }\n }\n\n async deleteArticle(id: string): Promise<boolean> {\n try {\n const result = await db.delete(articles).where(eq(articles.id, id));\n return true;\n } catch (error) {\n console.error('Error deleting article from database:', error);\n return false;\n }\n }\n\n async createScrapedArticle(article: Omit<InsertArticle, 'id'> & { \n sourceUrl: string; \n author?: string; \n originalImageUrl?: string; \n isScraped?: number;\n }): Promise<Article> {\n const newArticle: Article = {\n id: randomUUID(),\n viewCount: 0,\n scrapedAt: new Date(),\n isScraped: 1,\n ...article,\n tags: article.tags || null,\n author: article.author || null,\n originalImageUrl: article.originalImageUrl || null\n };\n this.articles.set(newArticle.id, newArticle);\n return newArticle;\n }\n\n async getScrapedArticles(limit: number = 50): Promise<Article[]> {\n return Array.from(this.articles.values())\n .filter(article => article.isScraped === 1)\n .sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime())\n .slice(0, limit);\n }\n\n async updateArticleWithScrapedData(id: string, data: {\n sourceUrl?: string;\n author?: string;\n originalImageUrl?: string;\n }): Promise<Article | undefined> {\n const article = this.articles.get(id);\n if (!article) return undefined;\n \n const updatedArticle = { ...article, ...data };\n this.articles.set(id, updatedArticle);\n return updatedArticle;\n }\n\n async updateArticleThumbnail(id: string, thumbnail: string): Promise<Article | undefined> {\n try {\n const result = await db\n .update(articles)\n .set({ thumbnail })\n .where(eq(articles.id, id))\n .returning();\n \n return result[0] || undefined;\n } catch (error) {\n console.error('Error updating article thumbnail in database:', error);\n return undefined;\n }\n }\n\n async listFeed(params: { cursor?: string; limit?: number; filter?: 'all' | 'people' | 'topics' | 'companies' }): Promise<{ items: (Article & { outletName: string; outletAvatar: string; category: string })[]; nextCursor?: string }> {\n const limit = params.limit || 20;\n let articles = Array.from(this.articles.values());\n \n // Apply filter if specified\n if (params.filter && params.filter !== 'all') {\n articles = articles.filter(article => {\n const outlet = this.outlets.get(article.outletId);\n return outlet?.category === params.filter;\n });\n }\n \n // Sort by publishedAt descending\n articles.sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime());\n \n // Apply cursor pagination if provided\n if (params.cursor) {\n const cursorIndex = articles.findIndex(article => article.id === params.cursor);\n if (cursorIndex !== -1) {\n articles = articles.slice(cursorIndex + 1);\n }\n }\n \n // Take limit + 1 to check if there's a next page\n const paginatedArticles = articles.slice(0, limit + 1);\n const hasNext = paginatedArticles.length > limit;\n const items = paginatedArticles.slice(0, limit);\n \n // Enrich with outlet data\n const enrichedItems = items.map(article => {\n const outlet = this.outlets.get(article.outletId);\n return {\n ...article,\n outletName: outlet?.name || 'Unknown Outlet',\n outletAvatar: outlet?.avatar || '',\n category: outlet?.category || 'unknown'\n };\n });\n \n const nextCursor = hasNext ? items[items.length - 1]?.id : undefined;\n return { items: enrichedItems, nextCursor };\n }\n\n async incrementView(id: string): Promise<void> {\n const article = this.articles.get(id);\n if (article) {\n const currentViewCount = article.viewCount || 0;\n const updatedArticle = {\n ...article,\n viewCount: currentViewCount + 1\n };\n this.articles.set(id, updatedArticle);\n }\n }\n\n async removeDuplicateArticles(): Promise<{ removedCount: number; details: { outletId: string; duplicatesRemoved: number }[] }> {\n const outletGroups = new Map<string, Map<string, Article[]>>();\n \n // Group articles by outlet and title\n for (const article of Array.from(this.articles.values())) {\n if (!outletGroups.has(article.outletId)) {\n outletGroups.set(article.outletId, new Map());\n }\n const titleGroups = outletGroups.get(article.outletId)!;\n if (!titleGroups.has(article.title)) {\n titleGroups.set(article.title, []);\n }\n titleGroups.get(article.title)!.push(article);\n }\n \n let totalRemoved = 0;\n const outletDetails = new Map<string, number>();\n \n // Process each outlet\n for (const [outletId, titleGroups] of Array.from(outletGroups.entries())) {\n let outletRemoved = 0;\n \n for (const [title, articles] of Array.from(titleGroups.entries())) {\n if (articles.length > 1) {\n // Sort by publishedAt desc to keep the most recent\n articles.sort((a: Article, b: Article) => b.publishedAt.getTime() - a.publishedAt.getTime());\n \n // Keep the first (most recent) and remove the rest\n const [keep, ...toRemove] = articles;\n \n for (const article of toRemove) {\n this.articles.delete(article.id);\n totalRemoved++;\n outletRemoved++;\n }\n }\n }\n \n if (outletRemoved > 0) {\n outletDetails.set(outletId, outletRemoved);\n }\n }\n \n const details = Array.from(outletDetails.entries()).map(([outletId, duplicatesRemoved]) => ({\n outletId,\n duplicatesRemoved\n }));\n \n return {\n removedCount: totalRemoved,\n details\n };\n }\n\n // Comment system methods\n async getCommentsByArticle(articleId: string, params: { limit?: number; offset?: number }): Promise<{ comments: Comment[]; total: number }> {\n const limit = params.limit || 20;\n const offset = params.offset || 0;\n \n const allComments = Array.from(this.comments.values())\n .filter(comment => comment.articleId === articleId && !comment.parentId)\n .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());\n \n const total = allComments.length;\n const comments = allComments.slice(offset, offset + limit);\n \n return { comments, total };\n }\n\n async getCommentReplies(parentId: string, params: { limit?: number; offset?: number }): Promise<{ comments: Comment[]; total: number }> {\n const limit = params.limit || 20;\n const offset = params.offset || 0;\n \n const allReplies = Array.from(this.comments.values())\n .filter(comment => comment.parentId === parentId)\n .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());\n \n const total = allReplies.length;\n const comments = allReplies.slice(offset, offset + limit);\n \n return { comments, total };\n }\n\n async getCommentById(id: string): Promise<Comment | undefined> {\n return this.comments.get(id);\n }\n\n async createComment(comment: InsertComment): Promise<Comment> {\n const newComment: Comment = {\n id: randomUUID(),\n likesCount: 0,\n dislikesCount: 0,\n repliesCount: 0,\n createdAt: new Date(),\n updatedAt: new Date(),\n ...comment,\n avatar: comment.avatar || null,\n parentId: comment.parentId || null\n };\n this.comments.set(newComment.id, newComment);\n return newComment;\n }\n\n async updateComment(id: string, content: string): Promise<Comment | undefined> {\n const comment = this.comments.get(id);\n if (!comment) return undefined;\n \n const updatedComment = { \n ...comment, \n content, \n updatedAt: new Date() \n };\n this.comments.set(id, updatedComment);\n return updatedComment;\n }\n\n async deleteComment(id: string): Promise<boolean> {\n return this.comments.delete(id);\n }\n\n async toggleCommentReaction(commentId: string, userIdentifier: string, reactionType: 'like' | 'dislike'): Promise<{ action: 'added' | 'removed' | 'changed'; reaction: CommentReaction | null }> {\n const reactionKey = `${commentId}:${userIdentifier}`;\n const existingReaction = this.commentReactions.get(reactionKey);\n \n if (existingReaction) {\n if (existingReaction.reactionType === reactionType) {\n // Remove the reaction\n this.commentReactions.delete(reactionKey);\n return { action: 'removed', reaction: null };\n } else {\n // Change the reaction\n const updatedReaction = { ...existingReaction, reactionType };\n this.commentReactions.set(reactionKey, updatedReaction);\n return { action: 'changed', reaction: updatedReaction };\n }\n } else {\n // Add new reaction\n const newReaction: CommentReaction = {\n id: randomUUID(),\n commentId,\n reactionType,\n userIdentifier,\n createdAt: new Date()\n };\n this.commentReactions.set(reactionKey, newReaction);\n return { action: 'added', reaction: newReaction };\n }\n }\n\n async getUserCommentReaction(commentId: string, userIdentifier: string): Promise<CommentReaction | undefined> {\n const reactionKey = `${commentId}:${userIdentifier}`;\n return this.commentReactions.get(reactionKey);\n }\n\n async toggleBookmark(articleId: string, userIdentifier: string): Promise<{ action: 'added' | 'removed'; bookmark: Bookmark | null }> {\n const bookmarkKey = `${articleId}:${userIdentifier}`;\n const existingBookmark = this.bookmarks.get(bookmarkKey);\n \n if (existingBookmark) {\n this.bookmarks.delete(bookmarkKey);\n try {\n await db.delete(bookmarks)\n .where(\n and(\n eq(bookmarks.articleId, articleId),\n eq(bookmarks.userIdentifier, userIdentifier)\n )\n );\n } catch (error) {\n console.error('[DbStorage] Error removing bookmark:', error);\n }\n return { action: 'removed', bookmark: null };\n } else {\n const newBookmark: Bookmark = {\n id: randomUUID(),\n articleId,\n userIdentifier,\n createdAt: new Date()\n };\n this.bookmarks.set(bookmarkKey, newBookmark);\n try {\n await db.insert(bookmarks).values(newBookmark);\n } catch (error) {\n console.error('[DbStorage] Error adding bookmark:', error);\n }\n return { action: 'added', bookmark: newBookmark };\n }\n }\n\n async isBookmarked(articleId: string, userIdentifier: string): Promise<boolean> {\n const bookmarkKey = `${articleId}:${userIdentifier}`;\n return this.bookmarks.has(bookmarkKey);\n }\n\n async getUserBookmarks(userIdentifier: string): Promise<Bookmark[]> {\n return Array.from(this.bookmarks.values())\n .filter(bookmark => bookmark.userIdentifier === userIdentifier);\n }\n\n async getArticleCommentCount(articleId: string): Promise<number> {\n try {\n const result = await db\n .select({ count: count() })\n .from(comments)\n .where(eq(comments.articleId, articleId));\n return result[0]?.count || 0;\n } catch (error) {\n console.error('[DbStorage] Error getting comment count:', error);\n return Array.from(this.comments.values())\n .filter(comment => comment.articleId === articleId).length;\n }\n }\n\n // Prediction Market methods\n async getPredictionMarketsByArticle(articleId: string, params: { limit?: number; offset?: number }): Promise<PredictionMarket[]> {\n const limit = params.limit || 3;\n const offset = params.offset || 0;\n\n // Generate diverse prediction markets (binary and multiple choice)\n const mockMarkets: PredictionMarket[] = [\n // Binary markets (Yes/No)\n {\n id: `pm-${articleId}-1`,\n articleId,\n question: \"Will this prediction come true by end of year?\",\n description: \"Market resolves YES if the main prediction in this article occurs by December 31st\",\n marketType: \"binary\",\n yesPrice: 65,\n noPrice: 35,\n options: null,\n totalVolume: 421000,\n endDate: new Date('2025-12-31'),\n category: \"Politics\",\n isLive: 1,\n isResolved: 0,\n createdAt: new Date(),\n },\n // Multiple choice market - Election\n {\n id: `pm-${articleId}-2`,\n articleId,\n question: \"Who will win the mayoral election?\",\n description: \"Market on the winner of upcoming mayoral election\",\n marketType: \"multiple_choice\",\n yesPrice: null,\n noPrice: null,\n options: JSON.stringify([\n { name: \"John Smith\", price: 86 },\n { name: \"Sarah Johnson\", price: 14 },\n ]),\n totalVolume: 108000,\n endDate: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),\n category: \"Politics\",\n isLive: 0,\n isResolved: 0,\n createdAt: new Date(),\n },\n // Multiple choice market - Sports match\n {\n id: `pm-${articleId}-3`,\n articleId,\n question: \"Match Winner\",\n description: \"Which team will win the upcoming match\",\n marketType: \"multiple_choice\",\n yesPrice: null,\n noPrice: null,\n options: JSON.stringify([\n { name: \"Team A\", price: 60 },\n { name: \"Draw\", price: 22 },\n { name: \"Team B\", price: 18 },\n ]),\n totalVolume: 421000,\n endDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),\n category: \"Sports\",\n isLive: 1,\n isResolved: 0,\n createdAt: new Date(),\n },\n // Binary market\n {\n id: `pm-${articleId}-4`,\n articleId,\n question: \"Will stock price increase by 10% in next quarter?\",\n description: \"Resolves YES if the mentioned company's stock rises 10%+ within 90 days\",\n marketType: \"binary\",\n yesPrice: 55,\n noPrice: 45,\n options: null,\n totalVolume: 221000,\n endDate: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),\n category: \"Business\",\n isLive: 0,\n isResolved: 0,\n createdAt: new Date(),\n },\n // Multiple choice market - Company acquisition\n {\n id: `pm-${articleId}-5`,\n articleId,\n question: \"Which company will acquire XYZ Corp?\",\n description: \"Market on potential acquirer of XYZ Corporation\",\n marketType: \"multiple_choice\",\n yesPrice: null,\n noPrice: null,\n options: JSON.stringify([\n { name: \"Microsoft\", price: 45 },\n { name: \"Google\", price: 32 },\n { name: \"Amazon\", price: 23 },\n ]),\n totalVolume: 457000,\n endDate: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000),\n category: \"Business\",\n isLive: 0,\n isResolved: 0,\n createdAt: new Date(),\n },\n ];\n\n return mockMarkets.slice(offset, offset + limit);\n }\n\n async getPredictionMarketById(id: string): Promise<PredictionMarket | undefined> {\n // Mock implementation - would query database in production\n return undefined;\n }\n\n async createPredictionMarket(market: InsertPredictionMarket): Promise<PredictionMarket> {\n const newMarket: PredictionMarket = {\n id: randomUUID(),\n ...market,\n options: market.options ?? null,\n description: market.description ?? null,\n category: market.category ?? null,\n yesPrice: market.yesPrice ?? null,\n noPrice: market.noPrice ?? null,\n createdAt: new Date(),\n };\n return newMarket;\n }\n}\n\n// Create and export storage instance\nexport const storage = new DbStorage();","size_bytes":94724},"server/vite.ts":{"content":"import express, { type Express } from \"express\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport { createServer as createViteServer, createLogger } from \"vite\";\nimport { type Server } from \"http\";\nimport viteConfig from \"../vite.config\";\nimport { nanoid } from \"nanoid\";\n\nconst viteLogger = createLogger();\n\nexport function log(message: string, source = \"express\") {\n const formattedTime = new Date().toLocaleTimeString(\"en-US\", {\n hour: \"numeric\",\n minute: \"2-digit\",\n second: \"2-digit\",\n hour12: true,\n });\n\n console.log(`${formattedTime} [${source}] ${message}`);\n}\n\nexport async function setupVite(app: Express, server: Server) {\n const serverOptions = {\n middlewareMode: true,\n hmr: { server },\n allowedHosts: true as const,\n };\n\n const vite = await createViteServer({\n ...viteConfig,\n configFile: false,\n customLogger: {\n ...viteLogger,\n error: (msg, options) => {\n viteLogger.error(msg, options);\n process.exit(1);\n },\n },\n server: serverOptions,\n appType: \"custom\",\n });\n\n app.use(vite.middlewares);\n app.use(\"*\", async (req, res, next) => {\n const url = req.originalUrl;\n\n try {\n const clientTemplate = path.resolve(\n import.meta.dirname,\n \"..\",\n \"client\",\n \"index.html\",\n );\n\n // always reload the index.html file from disk incase it changes\n let template = await fs.promises.readFile(clientTemplate, \"utf-8\");\n template = template.replace(\n `src=\"/src/main.tsx\"`,\n `src=\"/src/main.tsx?v=${nanoid()}\"`,\n );\n const page = await vite.transformIndexHtml(url, template);\n res.status(200).set({ \"Content-Type\": \"text/html\" }).end(page);\n } catch (e) {\n vite.ssrFixStacktrace(e as Error);\n next(e);\n }\n });\n}\n\nexport function serveStatic(app: Express) {\n const distPath = path.resolve(import.meta.dirname, \"public\");\n\n if (!fs.existsSync(distPath)) {\n throw new Error(\n `Could not find the build directory: ${distPath}, make sure to build the client first`,\n );\n }\n\n app.use(express.static(distPath));\n\n // fall through to index.html if the file doesn't exist\n app.use(\"*\", (_req, res) => {\n res.sendFile(path.resolve(distPath, \"index.html\"));\n });\n}\n","size_bytes":2263},"shared/schema.ts":{"content":"import { sql } from \"drizzle-orm\";\nimport { pgTable, text, varchar, timestamp, integer, pgEnum, foreignKey, uniqueIndex, index } from \"drizzle-orm/pg-core\";\nimport { createInsertSchema } from \"drizzle-zod\";\nimport { z } from \"zod\";\n\n// Enum for reaction types\nexport const reactionTypeEnum = pgEnum('reaction_type', ['like', 'dislike']);\n\n// User schema\nexport const users = pgTable(\"users\", {\n id: varchar(\"id\").primaryKey().default(sql`gen_random_uuid()`),\n username: text(\"username\").notNull().unique(),\n password: text(\"password\").notNull(),\n preferredLanguage: text(\"preferred_language\").default(\"en\"),\n avatar: text(\"avatar\"),\n});\n\n// Media outlet schema\nexport const mediaOutlets = pgTable(\"media_outlets\", {\n id: varchar(\"id\").primaryKey().default(sql`gen_random_uuid()`),\n name: text(\"name\").notNull(),\n description: text(\"description\").notNull(),\n category: text(\"category\").notNull(), // \"people\", \"topics\", \"companies\"\n focusSubject: text(\"focus_subject\").notNull(), // \"jacob\", \"ala\", \"ai\", \"crypto\", etc.\n avatar: text(\"avatar\"),\n profileImage: text(\"profile_image\"),\n bio: text(\"bio\").notNull(),\n fullBio: text(\"full_bio\").array(),\n wikiProfile: text(\"wiki_profile\"), // Detailed Wikipedia-style profile in HTML/Markdown format\n});\n\n// Article schema - extended for web scraping\nexport const articles = pgTable(\"articles\", {\n id: varchar(\"id\").primaryKey().default(sql`gen_random_uuid()`),\n outletId: varchar(\"outlet_id\").notNull().references(() => mediaOutlets.id),\n title: text(\"title\").notNull(),\n summary: text(\"summary\").notNull(),\n body: text(\"body\").notNull(),\n thumbnail: text(\"thumbnail\").notNull(),\n publishedAt: timestamp(\"published_at\").notNull(),\n tags: text(\"tags\").array(),\n viewCount: integer(\"view_count\").notNull().default(0),\n // Scraped article fields\n sourceUrl: text(\"source_url\"), // Original URL that was scraped\n author: text(\"author\"), // Article author from scraped content\n originalImageUrl: text(\"original_image_url\"), // Original image URL before processing\n scrapedAt: timestamp(\"scraped_at\").default(sql`NOW()`), // When this article was scraped\n isScraped: integer(\"is_scraped\").notNull().default(0), // 1 if scraped, 0 if manually created\n});\n\n// Comments schema for YouTube-style commenting system\nexport const comments = pgTable(\"comments\", {\n id: varchar(\"id\").primaryKey().default(sql`gen_random_uuid()`),\n articleId: varchar(\"article_id\").notNull().references(() => articles.id),\n content: text(\"content\").notNull(),\n nickname: text(\"nickname\").notNull(),\n avatar: text(\"avatar\"), // Optional avatar URL or emoji\n parentId: varchar(\"parent_id\"), // Parent comment ID (nullable for top-level comments)\n likesCount: integer(\"likes_count\").notNull().default(0),\n dislikesCount: integer(\"dislikes_count\").notNull().default(0),\n repliesCount: integer(\"replies_count\").notNull().default(0),\n createdAt: timestamp(\"created_at\").notNull().default(sql`NOW()`),\n updatedAt: timestamp(\"updated_at\").notNull().default(sql`NOW()`),\n}, (table) => ({\n // Performance indexes\n articleParentCreatedIdx: index(\"comments_article_parent_created_idx\").on(\n table.articleId, \n table.parentId, \n table.createdAt\n ),\n}));\n\n// Comment reactions (likes/dislikes) with deduplication constraint \nexport const commentReactions = pgTable(\"comment_reactions\", {\n id: varchar(\"id\").primaryKey().default(sql`gen_random_uuid()`),\n commentId: varchar(\"comment_id\").notNull().references(() => comments.id, { onDelete: 'cascade' }),\n reactionType: text(\"reaction_type\").notNull(), // Keep as text to avoid migration issues\n userIdentifier: text(\"user_identifier\").notNull(), // Session ID, IP hash, or similar\n createdAt: timestamp(\"created_at\").notNull().default(sql`NOW()`),\n}, (table) => ({\n // Unique constraint to prevent duplicate reactions from same user\n uniqueUserReaction: uniqueIndex(\"unique_user_reaction_per_comment_idx\").on(\n table.commentId, \n table.userIdentifier\n ),\n}));\n\n// Bookmarks schema for saving articles\nexport const bookmarks = pgTable(\"bookmarks\", {\n id: varchar(\"id\").primaryKey().default(sql`gen_random_uuid()`),\n articleId: varchar(\"article_id\").notNull().references(() => articles.id, { onDelete: 'cascade' }),\n userIdentifier: text(\"user_identifier\").notNull(), // Session ID, IP hash, or similar\n createdAt: timestamp(\"created_at\").notNull().default(sql`NOW()`),\n}, (table) => ({\n // Unique constraint to prevent duplicate bookmarks from same user\n uniqueUserBookmark: uniqueIndex(\"unique_user_bookmark_per_article_idx\").on(\n table.articleId,\n table.userIdentifier\n ),\n}));\n\n// Prediction Markets schema for article-related betting events\nexport const predictionMarkets = pgTable(\"prediction_markets\", {\n id: varchar(\"id\").primaryKey().default(sql`gen_random_uuid()`),\n articleId: varchar(\"article_id\").notNull().references(() => articles.id),\n question: text(\"question\").notNull(),\n description: text(\"description\"),\n marketType: text(\"market_type\").notNull().default(\"binary\"), // \"binary\" (yes/no) or \"multiple_choice\"\n // For binary markets\n yesPrice: integer(\"yes_price\"), // Price in cents (0-100)\n noPrice: integer(\"no_price\"), // Price in cents (0-100)\n // For multiple choice markets - JSON array of options\n // [{name: \"Option A\", price: 60, image: \"/url\"}, ...]\n options: text(\"options\"), // JSON string\n totalVolume: integer(\"total_volume\").notNull().default(0), // Total trading volume in dollars\n endDate: timestamp(\"end_date\").notNull(),\n category: text(\"category\"),\n isLive: integer(\"is_live\").notNull().default(0),\n isResolved: integer(\"is_resolved\").notNull().default(0),\n createdAt: timestamp(\"created_at\").notNull().default(sql`NOW()`),\n}, (table) => ({\n articleIdx: index(\"prediction_markets_article_idx\").on(table.articleId),\n}));\n\n// Insert schemas\nexport const insertUserSchema = createInsertSchema(users).pick({\n username: true,\n password: true,\n preferredLanguage: true,\n avatar: true,\n});\n\nexport const insertMediaOutletSchema = createInsertSchema(mediaOutlets).pick({\n name: true,\n description: true,\n category: true,\n focusSubject: true,\n avatar: true,\n profileImage: true,\n bio: true,\n fullBio: true,\n wikiProfile: true,\n});\n\nexport const insertArticleSchema = createInsertSchema(articles).pick({\n outletId: true,\n title: true,\n summary: true,\n body: true,\n thumbnail: true,\n publishedAt: true,\n tags: true,\n viewCount: true,\n sourceUrl: true,\n author: true,\n originalImageUrl: true,\n scrapedAt: true,\n isScraped: true,\n}).extend({\n publishedAt: z.preprocess((val) => {\n if (typeof val === 'string') return new Date(val);\n if (val instanceof Date) return val;\n return new Date();\n }, z.date()),\n});\n\nexport const insertCommentSchema = createInsertSchema(comments).pick({\n articleId: true,\n content: true,\n nickname: true,\n avatar: true,\n parentId: true,\n});\n\nexport const insertCommentReactionSchema = createInsertSchema(commentReactions).pick({\n commentId: true,\n reactionType: true,\n userIdentifier: true,\n}).extend({\n reactionType: z.enum([\"like\", \"dislike\"]),\n});\n\nexport const insertBookmarkSchema = createInsertSchema(bookmarks).pick({\n articleId: true,\n userIdentifier: true,\n});\n\nexport const insertPredictionMarketSchema = createInsertSchema(predictionMarkets).pick({\n articleId: true,\n question: true,\n description: true,\n marketType: true,\n yesPrice: true,\n noPrice: true,\n options: true,\n totalVolume: true,\n endDate: true,\n category: true,\n isLive: true,\n isResolved: true,\n});\n\n// Types\nexport type InsertUser = z.infer<typeof insertUserSchema>;\nexport type User = typeof users.$inferSelect;\nexport type InsertMediaOutlet = z.infer<typeof insertMediaOutletSchema>;\nexport type MediaOutlet = typeof mediaOutlets.$inferSelect;\nexport type InsertArticle = z.infer<typeof insertArticleSchema>;\nexport type Article = typeof articles.$inferSelect;\nexport type InsertComment = z.infer<typeof insertCommentSchema>;\nexport type Comment = typeof comments.$inferSelect;\nexport type InsertCommentReaction = z.infer<typeof insertCommentReactionSchema>;\nexport type CommentReaction = typeof commentReactions.$inferSelect;\nexport type InsertBookmark = z.infer<typeof insertBookmarkSchema>;\nexport type Bookmark = typeof bookmarks.$inferSelect;\nexport type InsertPredictionMarket = z.infer<typeof insertPredictionMarketSchema>;\nexport type PredictionMarket = typeof predictionMarkets.$inferSelect;\n\n// Enums for categories\nexport const CATEGORIES = [\"people\", \"topics\", \"companies\"] as const;\nexport const FOCUS_SUBJECTS = [\"jacob\", \"ala\", \"ai\", \"crypto\", \"polychain\", \"dcg\"] as const;\nexport const LANGUAGES = [\n { code: \"en\", name: \"English\" },\n { code: \"fr\", name: \"Français\" },\n { code: \"hi\", name: \"हिन्दी\" },\n { code: \"ar\", name: \"العربية\" },\n { code: \"zh-TW\", name: \"繁體中文\" },\n { code: \"zh-CN\", name: \"简体中文\" },\n { code: \"ja\", name: \"日本語\" },\n { code: \"ko\", name: \"한국어\" },\n] as const;","size_bytes":8982},"client/src/App.tsx":{"content":"import { Switch, Route } from \"wouter\";\nimport { queryClient } from \"./lib/queryClient\";\nimport { QueryClientProvider } from \"@tanstack/react-query\";\nimport { Toaster } from \"@/components/ui/toaster\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\nimport HomePage from \"@/pages/HomePage\";\nimport OutletPage from \"@/pages/OutletPage\";\nimport ArticlePage from \"@/pages/ArticlePage\";\nimport NotFound from \"@/pages/not-found\";\n\nfunction Router() {\n return (\n <Switch>\n <Route path=\"/\" component={HomePage} />\n <Route path=\"/outlet/:id\" component={OutletPage} />\n <Route path=\"/article/:id\" component={ArticlePage} />\n <Route component={NotFound} />\n </Switch>\n );\n}\n\nfunction App() {\n return (\n <QueryClientProvider client={queryClient}>\n <TooltipProvider>\n <div className=\"min-h-screen bg-background text-foreground\">\n <Router />\n </div>\n <Toaster />\n </TooltipProvider>\n </QueryClientProvider>\n );\n}\n\nexport default App;","size_bytes":1006},"client/src/index.css":{"content":"@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/* Search overlay styles */\n.search-overlay-open {\n overflow: hidden;\n}\n\n.search-overlay-open #root {\n transform: translateX(-10%);\n transition: transform 300ms ease-out;\n}\n\nbody:not(.search-overlay-open) #root {\n transform: translateX(0);\n transition: transform 300ms ease-out;\n}\n\n/* LIGHT MODE */\n:root {\n --button-outline: rgba(0,0,0, .10);\n --badge-outline: rgba(0,0,0, .05);\n\n /* Automatic computation of border around primary / danger buttons */\n --opaque-button-border-intensity: -8; /* In terms of percentages */\n\n /* Backgrounds applied on top of other backgrounds when hovered/active */\n --elevate-1: rgba(0,0,0, .03);\n --elevate-2: rgba(0,0,0, .08);\n\n --background: 0 0% 98%;\n\n --foreground: 222 15% 15%;\n\n --border: 220 13% 91%;\n\n --card: 0 0% 100%;\n\n --card-foreground: 222 15% 15%;\n\n --card-border: 220 13% 91%;\n\n --sidebar: 220 13% 96%;\n\n --sidebar-foreground: 222 15% 15%;\n\n --sidebar-border: 220 13% 93%;\n\n --sidebar-primary: 210 85% 55%;\n\n --sidebar-primary-foreground: 210 15% 95%;\n\n --sidebar-accent: 220 13% 93%;\n\n --sidebar-accent-foreground: 222 15% 15%;\n\n --sidebar-ring: 210 85% 55%;\n\n --popover: 220 13% 93%;\n\n --popover-foreground: 222 15% 15%;\n\n --popover-border: 220 13% 90%;\n\n --primary: 210 85% 55%;\n\n --primary-foreground: 210 15% 95%;\n\n --secondary: 220 13% 90%;\n\n --secondary-foreground: 222 15% 15%;\n\n --muted: 220 13% 94%;\n\n --muted-foreground: 222 15% 45%;\n\n --accent: 220 13% 94%;\n\n --accent-foreground: 222 15% 15%;\n\n --destructive: 0 84% 60%;\n\n --destructive-foreground: 0 15% 95%;\n\n --input: 220 13% 85%;\n --ring: 210 85% 55%;\n --chart-1: 210 85% 45%;\n --chart-2: 142 76% 40%;\n --chart-3: 280 65% 45%;\n --chart-4: 25 85% 45%;\n --chart-5: 340 75% 45%;\n\n --font-sans: Inter, sans-serif;\n --font-serif: Georgia, serif;\n --font-mono: Menlo, monospace;\n --radius: .5rem; /* 8px */\n --shadow-2xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.05);\n --shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.05);\n --shadow-sm: 0px 1px 2px 0px rgba(0, 0, 0, 0.05), 0px 1px 3px 0px rgba(0, 0, 0, 0.1);\n --shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05), 0px 1px 3px 0px rgba(0, 0, 0, 0.1);\n --shadow-md: 0px 2px 4px -1px rgba(0, 0, 0, 0.07), 0px 4px 6px -1px rgba(0, 0, 0, 0.1);\n --shadow-lg: 0px 4px 6px -2px rgba(0, 0, 0, 0.05), 0px 10px 15px -3px rgba(0, 0, 0, 0.1);\n --shadow-xl: 0px 8px 10px -3px rgba(0, 0, 0, 0.1), 0px 20px 25px -5px rgba(0, 0, 0, 0.1);\n --shadow-2xl: 0px 25px 50px -12px rgba(0, 0, 0, 0.25);\n --tracking-normal: 0em;\n --spacing: 0.25rem;\n\n /* Automatically computed borders - intensity can be controlled by the user by the --opaque-button-border-intensity setting */\n\n /* Fallback for older browsers */\n --sidebar-primary-border: hsl(var(--sidebar-primary));\n --sidebar-primary-border: hsl(from hsl(var(--sidebar-primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);\n\n /* Fallback for older browsers */\n --sidebar-accent-border: hsl(var(--sidebar-accent));\n --sidebar-accent-border: hsl(from hsl(var(--sidebar-accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);\n\n /* Fallback for older browsers */\n --primary-border: hsl(var(--primary));\n --primary-border: hsl(from hsl(var(--primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);\n\n /* Fallback for older browsers */\n --secondary-border: hsl(var(--secondary));\n --secondary-border: hsl(from hsl(var(--secondary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);\n\n /* Fallback for older browsers */\n --muted-border: hsl(var(--muted));\n --muted-border: hsl(from hsl(var(--muted)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);\n\n /* Fallback for older browsers */\n --accent-border: hsl(var(--accent));\n --accent-border: hsl(from hsl(var(--accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);\n\n /* Fallback for older browsers */\n --destructive-border: hsl(var(--destructive));\n --destructive-border: hsl(from hsl(var(--destructive)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);\n}\n\n.dark {\n --button-outline: rgba(255,255,255, .10);\n --badge-outline: rgba(255,255,255, .05);\n\n --opaque-button-border-intensity: 9; /* In terms of percentages */\n\n /* Backgrounds applied on top of other backgrounds when hovered/active */\n --elevate-1: rgba(255,255,255, .04);\n --elevate-2: rgba(255,255,255, .09);\n\n --background: 222 15% 8%;\n\n --foreground: 210 15% 95%;\n\n --border: 222 15% 18%;\n\n --card: 222 15% 12%;\n\n --card-foreground: 210 15% 95%;\n\n --card-border: 222 15% 20%;\n\n --sidebar: 222 15% 10%;\n\n --sidebar-foreground: 210 15% 95%;\n\n --sidebar-border: 222 15% 15%;\n\n --sidebar-primary: 210 85% 55%;\n\n --sidebar-primary-foreground: 210 15% 95%;\n\n --sidebar-accent: 222 15% 15%;\n\n --sidebar-accent-foreground: 210 15% 95%;\n\n --sidebar-ring: 210 85% 55%;\n\n --popover: 222 15% 15%;\n\n --popover-foreground: 210 15% 95%;\n\n --popover-border: 222 15% 23%;\n\n --primary: 210 85% 55%;\n\n --primary-foreground: 210 15% 95%;\n\n --secondary: 222 15% 20%;\n\n --secondary-foreground: 210 15% 95%;\n\n --muted: 222 15% 14%;\n\n --muted-foreground: 210 15% 65%;\n\n --accent: 222 15% 14%;\n\n --accent-foreground: 210 15% 95%;\n\n --destructive: 0 84% 60%;\n\n --destructive-foreground: 0 15% 95%;\n\n /* Used as the border around inputs. Dark mode: Should be a border that is light enough to have high contrast when rendered on a --card background. More contrast than standard --border */\n --input: 222 15% 25%;\n --ring: 210 85% 55%;\n --chart-1: 210 85% 65%;\n --chart-2: 142 76% 60%;\n --chart-3: 280 65% 65%;\n --chart-4: 25 85% 65%;\n --chart-5: 340 75% 65%;\n\n --shadow-2xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.3);\n --shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.3);\n --shadow-sm: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 0px rgba(0, 0, 0, 0.4);\n --shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 0px rgba(0, 0, 0, 0.4);\n --shadow-md: 0px 2px 4px -1px rgba(0, 0, 0, 0.4), 0px 4px 6px -1px rgba(0, 0, 0, 0.5);\n --shadow-lg: 0px 4px 6px -2px rgba(0, 0, 0, 0.4), 0px 10px 15px -3px rgba(0, 0, 0, 0.5);\n --shadow-xl: 0px 8px 10px -3px rgba(0, 0, 0, 0.5), 0px 20px 25px -5px rgba(0, 0, 0, 0.6);\n --shadow-2xl: 0px 25px 50px -12px rgba(0, 0, 0, 0.7);\n\n}\n\n@layer base {\n * {\n @apply border-border;\n }\n\n body {\n @apply font-sans antialiased bg-background text-foreground;\n }\n}\n\n/**\n * Using the elevate system.\n * Automatic contrast adjustment.\n *\n * <element className=\"hover-elevate\" />\n * <element className=\"active-elevate-2\" />\n *\n * // Using the tailwind utility when a data attribute is \"on\"\n * <element className=\"toggle-elevate data-[state=on]:toggle-elevated\" />\n * // Or manually controlling the toggle state\n * <element className=\"toggle-elevate toggle-elevated\" />\n *\n * Elevation systems have to handle many states.\n * - not-hovered, vs. hovered vs. active (three mutually exclusive states)\n * - toggled or not\n * - focused or not (this is not handled with these utilities)\n *\n * Even without handling focused or not, this is six possible combinations that\n * need to be distinguished from eachother visually.\n */\n@layer utilities {\n\n /* Hide ugly search cancel button in Chrome until we can style it properly */\n input[type=\"search\"]::-webkit-search-cancel-button {\n @apply hidden;\n }\n\n /* Placeholder styling for contentEditable div */\n [contenteditable][data-placeholder]:empty::before {\n content: attr(data-placeholder);\n color: hsl(var(--muted-foreground));\n pointer-events: none;\n }\n\n /* .no-default-hover-elevate/no-default-active-elevate is an escape hatch so consumers of\n * buttons/badges can remove the automatic brightness adjustment on interactions\n * and program their own. */\n .no-default-hover-elevate {}\n\n .no-default-active-elevate {}\n\n\n /**\n * Toggleable backgrounds go behind the content. Hoverable/active goes on top.\n * This way they can stack/compound. Both will overlap the parent's borders!\n * So borders will be automatically adjusted both on toggle, and hover/active,\n * and they will be compounded.\n */\n .toggle-elevate::before,\n .toggle-elevate-2::before {\n content: \"\";\n pointer-events: none;\n position: absolute;\n inset: 0px;\n /*border-radius: inherit; match rounded corners */\n border-radius: inherit;\n z-index: -1;\n /* sits behind content but above backdrop */\n }\n\n .toggle-elevate.toggle-elevated::before {\n background-color: var(--elevate-2);\n }\n\n /* If there's a 1px border, adjust the inset so that it covers that parent's border */\n .border.toggle-elevate::before {\n inset: -1px;\n }\n\n /* Does not work on elements with overflow:hidden! */\n .hover-elevate:not(.no-default-hover-elevate),\n .active-elevate:not(.no-default-active-elevate),\n .hover-elevate-2:not(.no-default-hover-elevate),\n .active-elevate-2:not(.no-default-active-elevate) {\n position: relative;\n z-index: 0;\n }\n\n .hover-elevate:not(.no-default-hover-elevate)::after,\n .active-elevate:not(.no-default-active-elevate)::after,\n .hover-elevate-2:not(.no-default-hover-elevate)::after,\n .active-elevate-2:not(.no-default-active-elevate)::after {\n content: \"\";\n pointer-events: none;\n position: absolute;\n inset: 0px;\n /*border-radius: inherit; match rounded corners */\n border-radius: inherit;\n z-index: 999;\n /* sits in front of content */\n }\n\n .hover-elevate:hover:not(.no-default-hover-elevate)::after,\n .active-elevate:active:not(.no-default-active-elevate)::after {\n background-color: var(--elevate-1);\n }\n\n .hover-elevate-2:hover:not(.no-default-hover-elevate)::after,\n .active-elevate-2:active:not(.no-default-active-elevate)::after {\n background-color: var(--elevate-2);\n }\n\n /* If there's a 1px border, adjust the inset so that it covers that parent's border */\n .border.hover-elevate:not(.no-hover-interaction-elevate)::after,\n .border.active-elevate:not(.no-active-interaction-elevate)::after,\n .border.hover-elevate-2:not(.no-hover-interaction-elevate)::after,\n .border.active-elevate-2:not(.no-active-interaction-elevate)::after,\n .border.hover-elevate:not(.no-hover-interaction-elevate)::after {\n inset: -1px;\n }\n}","size_bytes":10235},"client/src/main.tsx":{"content":"import { createRoot } from \"react-dom/client\";\nimport App from \"./App\";\nimport \"./index.css\";\n\ncreateRoot(document.getElementById(\"root\")!).render(<App />);\n","size_bytes":157},"client/src/components/ArticleCard.tsx":{"content":"import { Card } from \"@/components/ui/card\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { ArrowRight, MessageCircle, ThumbsUp } from \"lucide-react\";\nimport { useMemo } from \"react\";\n\ninterface ArticleCardProps {\n id: string;\n title: string;\n summary: string;\n thumbnail: string;\n publishedAt: Date;\n timeAgo?: string;\n outletName: string;\n category: string;\n onClick: (id: string) => void;\n className?: string;\n isAd?: boolean;\n tags?: string[];\n index?: number;\n variant?: 'card' | 'list';\n}\n\nexport default function ArticleCard({\n id,\n title,\n summary,\n thumbnail,\n publishedAt,\n timeAgo,\n outletName,\n category,\n onClick,\n className = \"\",\n isAd = false,\n tags = [],\n index = 0,\n variant = 'card',\n}: ArticleCardProps) {\n\n // Generate stable random data based on index and id (newer articles have smaller numbers)\n const { minutesAgo, commentsCount, likesCount } = useMemo(() => {\n // Use a simple hash of the id to get consistent randomness\n const hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);\n const baseMinutes = index * 5;\n const randomOffset = (hash % 5) + 1;\n \n // Generate comments count (0-50 based on hash)\n const comments = (hash % 51);\n \n // Generate likes count (5-200 based on hash)\n const likes = ((hash * 3) % 196) + 5;\n \n return {\n minutesAgo: baseMinutes + randomOffset,\n commentsCount: comments,\n likesCount: likes\n };\n }, [id, index]);\n // List variant JSX\n if (variant === 'list') {\n return (\n <Card \n className={`overflow-hidden hover-elevate cursor-pointer ${className}`}\n onClick={() => onClick(id)}\n data-testid={`card-article-${id}`}\n >\n <div className=\"p-3 flex gap-3\">\n {/* Left: Thumbnail */}\n <div className=\"w-20 h-16 shrink-0 relative overflow-hidden rounded\">\n <img\n src={thumbnail}\n alt={title}\n className=\"w-full h-full object-cover\"\n data-testid={`img-thumbnail-${id}`}\n />\n {isAd && (\n <div className=\"absolute inset-0 flex items-center justify-center bg-amber-500/90\">\n <span className=\"text-xs text-white font-medium\">Ad</span>\n </div>\n )}\n </div>\n \n {/* Right: Content */}\n <div className=\"flex-1 min-w-0 space-y-1\">\n <h3 \n className=\"font-semibold text-sm leading-tight line-clamp-2\"\n data-testid={`text-title-${id}`}\n >\n {title}\n </h3>\n \n <p \n className=\"text-xs text-muted-foreground line-clamp-2\"\n data-testid={`text-summary-${id}`}\n >\n {summary}\n </p>\n \n {/* Bottom: time and engagement */}\n <div className=\"flex items-center justify-between text-xs text-muted-foreground pt-1\">\n <div className=\"flex items-center gap-2\">\n <span data-testid={`text-time-${id}`}>{timeAgo || `${minutesAgo} min ago`}</span>\n </div>\n \n {!isAd && (\n <div className=\"flex items-center gap-3\">\n <div className=\"flex items-center gap-1\">\n <MessageCircle className=\"h-3 w-3\" />\n <span data-testid={`text-comments-${id}`}>{commentsCount}</span>\n </div>\n <div className=\"flex items-center gap-1\">\n <ThumbsUp className=\"h-3 w-3\" />\n <span data-testid={`text-likes-${id}`}>{likesCount}</span>\n </div>\n </div>\n )}\n </div>\n </div>\n </div>\n </Card>\n );\n }\n\n // Card variant JSX (default)\n return (\n <Card \n className={`overflow-hidden hover-elevate cursor-pointer flex flex-col ${className}`}\n onClick={() => onClick(id)}\n data-testid={`card-article-${id}`}\n >\n <div className=\"aspect-[21/9] relative overflow-hidden group\">\n <img\n src={thumbnail}\n alt={title}\n className=\"w-full h-full object-cover\"\n data-testid={`img-thumbnail-${id}`}\n />\n <div className=\"absolute top-2 left-2 flex gap-1\">\n {isAd ? (\n <Badge variant=\"default\" className=\"text-xs bg-amber-500 text-amber-50\" data-testid={`badge-sponsored-${id}`} aria-label=\"Sponsored\">\n Sponsored\n </Badge>\n ) : (\n <Badge variant=\"secondary\" className=\"text-xs\">\n {category}\n </Badge>\n )}\n </div>\n\n {/* Time info - bottom left (hide for ads) */}\n {!isAd && (\n <div className=\"absolute bottom-2 left-2\">\n <span className=\"text-xs text-white bg-black/60 px-2 py-1 rounded backdrop-blur-sm\" data-testid={`text-time-${id}`}>\n {timeAgo || `${minutesAgo} min ago`}\n </span>\n </div>\n )}\n </div>\n \n <div className=\"p-4 space-y-2 flex-1 flex flex-col\">\n <h3 \n className=\"font-semibold text-base leading-tight line-clamp-3\"\n data-testid={`text-title-${id}`}\n >\n {title}\n </h3>\n \n <p \n className=\"text-sm text-muted-foreground line-clamp-3 flex-1\"\n data-testid={`text-summary-${id}`}\n >\n {summary}\n </p>\n \n {/* Tags and Read More button */}\n <div className=\"mt-auto pt-2\">\n {!isAd && tags && tags.length > 0 && (\n <div className=\"flex flex-wrap gap-1 mb-2\" data-testid={`tags-${id}`}>\n {tags.slice(0, 3).map((tag, tagIndex) => (\n <Badge \n key={tagIndex} \n variant=\"outline\" \n className=\"text-xs px-2 py-0.5 h-5\"\n data-testid={`tag-${tag}-${id}`}\n >\n {tag}\n </Badge>\n ))}\n </div>\n )}\n \n {/* Engagement metrics and Read More button */}\n <div className=\"flex items-center justify-between\">\n {/* Engagement metrics container - always present to maintain layout */}\n <div className=\"flex-1\">\n {!isAd && (\n <div className=\"flex items-center gap-3 text-xs text-muted-foreground\">\n <div className=\"flex items-center gap-1\">\n <MessageCircle className=\"h-3 w-3\" />\n <span data-testid={`text-comments-${id}`}>{commentsCount}</span>\n </div>\n <div className=\"flex items-center gap-1\">\n <ThumbsUp className=\"h-3 w-3\" />\n <span data-testid={`text-likes-${id}`}>{likesCount}</span>\n </div>\n </div>\n )}\n </div>\n \n {/* Read More button - positioned at bottom right */}\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={(e) => {\n e.stopPropagation();\n onClick(id);\n }}\n className=\"text-xs px-3 py-1 h-7 text-muted-foreground hover:text-foreground\"\n data-testid={`button-read-more-${id}`}\n >\n Read More\n <ArrowRight className=\"h-3 w-3 ml-1\" />\n </Button>\n </div>\n </div>\n </div>\n </Card>\n );\n}","size_bytes":7582},"client/src/components/ArticleDetail.tsx":{"content":"import { useState, useMemo } from \"react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport { formatTimeAgo } from \"@/lib/utils\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Card } from \"@/components/ui/card\";\nimport FeedItem from \"./FeedItem\";\nimport ArticlePopup from \"./ArticlePopup\";\nimport DOMPurify from \"dompurify\";\n\ninterface ArticleDetailProps {\n id: string;\n title: string;\n summary: string;\n body: string;\n thumbnail: string;\n publishedAt: Date;\n timeAgo?: string; // Server-provided time ago text\n outletName: string;\n category: string;\n tags?: string[];\n textSize?: number;\n onPreviousArticle?: () => void;\n onNextArticle?: () => void;\n hasPrevious?: boolean;\n hasNext?: boolean;\n onArticleClick?: (articleId: string) => void;\n onOutletClick?: (outletId: string) => void;\n}\n\nexport default function ArticleDetail({\n id,\n title,\n summary,\n body,\n thumbnail,\n publishedAt,\n timeAgo,\n outletName,\n category,\n tags = [],\n textSize = 5,\n onPreviousArticle,\n onNextArticle,\n hasPrevious = false,\n hasNext = false,\n onArticleClick,\n onOutletClick,\n}: ArticleDetailProps) {\n const [imageLoaded, setImageLoaded] = useState(false);\n // Use server-provided timeAgo or fallback to client calculation\n const timeAgoText = timeAgo || formatTimeAgo(publishedAt);\n\n // Clean up and truncate article body\n const cleanedBody = useMemo(() => {\n // More flexible pattern that handles:\n // - Case insensitivity\n // - Optional \"This article was\" prefix\n // - \"at\" or \"on\" variations\n // - CRLF line endings\n // - Variable whitespace/newlines\n const pattern = /^\\s*(?:This article was\\s+)?originally published\\s+(?:at|on)\\s+\\S+\\.?\\s*(?:\\r?\\n){1,2}/i;\n const cleaned = body.replace(pattern, '').trimStart();\n \n // Truncate at 3000 characters, but complete the sentence\n if (cleaned.length <= 3000) return cleaned;\n \n // Find the next sentence ending after 3000 characters\n const truncatePoint = 3000;\n const afterTruncate = cleaned.substring(truncatePoint);\n \n // Look for sentence endings: period, exclamation, question mark followed by space or end of string\n const sentenceEndMatch = afterTruncate.match(/[.!?](?:\\s|$)/);\n \n if (sentenceEndMatch && sentenceEndMatch.index !== undefined) {\n // Include the sentence ending character\n const endIndex = truncatePoint + sentenceEndMatch.index + 1;\n return cleaned.substring(0, endIndex);\n }\n \n // If no sentence ending found within reasonable distance, just cut at 3000\n return cleaned.substring(0, 3000);\n }, [body]);\n\n const sanitizeHtml = (html: string) => {\n return DOMPurify.sanitize(html, {\n ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'ul', 'ol', 'li', 'strong', 'em', 'a'],\n ALLOWED_ATTR: ['href', 'target', 'rel'],\n ADD_ATTR: ['target', 'rel'],\n });\n };\n\n // Parse text content and convert **text** to subtitle JSX elements\n const parseBodyContent = (text: string) => {\n const lines = text.split('\\n');\n const elements: React.ReactNode[] = [];\n let currentParagraph: string[] = [];\n let keyIndex = 0;\n\n const flushParagraph = () => {\n if (currentParagraph.length > 0) {\n const paragraphText = currentParagraph.join('\\n').trim();\n if (paragraphText) {\n // Process inline bold formatting in paragraphs\n const processedParagraph = parseInlineBold(paragraphText);\n elements.push(\n <p key={`p-${keyIndex++}`} className=\"mb-4 leading-relaxed\">\n {processedParagraph}\n </p>\n );\n }\n currentParagraph = [];\n }\n };\n\n const parseInlineBold = (text: string) => {\n const parts = text.split(/(\\*\\*.*?\\*\\*)/);\n return parts.map((part, index) => {\n const boldMatch = part.match(/^\\*\\*(.*?)\\*\\*$/);\n if (boldMatch) {\n return <strong key={index}>{boldMatch[1]}</strong>;\n }\n return part;\n });\n };\n\n lines.forEach((line, index) => {\n const trimmedLine = line.trim();\n \n // Check for different subtitle patterns\n // Pattern 1: Full-line subtitle **Title**\n const fullLineSubtitle = trimmedLine.match(/^\\*\\*(.*?)\\*\\*$/);\n // Pattern 2: Prefixed subtitle **Title:** Content\n const prefixedSubtitle = trimmedLine.match(/^\\s*\\*\\*(.+?)\\*\\*\\s*(.*)$/);\n \n if (fullLineSubtitle && trimmedLine === `**${fullLineSubtitle[1]}**`) {\n // Full-line subtitle - flush paragraph and add as h3\n flushParagraph();\n elements.push(\n <h3 key={`subtitle-${keyIndex++}`} className=\"text-lg font-semibold mt-6 mb-3\">\n {fullLineSubtitle[1]}\n </h3>\n );\n } else if (prefixedSubtitle && prefixedSubtitle[2].trim() !== '') {\n // Prefixed subtitle with content - flush paragraph, add h3, then start new paragraph\n flushParagraph();\n elements.push(\n <h3 key={`subtitle-${keyIndex++}`} className=\"text-lg font-semibold mt-6 mb-3\">\n {prefixedSubtitle[1]}\n </h3>\n );\n // Start new paragraph with remaining content if it exists\n if (prefixedSubtitle[2].trim()) {\n currentParagraph.push(prefixedSubtitle[2].trim());\n }\n } else if (trimmedLine === '') {\n // Empty line - flush current paragraph\n flushParagraph();\n } else {\n // Regular content line - add to current paragraph\n currentParagraph.push(line);\n }\n });\n\n // Flush any remaining paragraph\n flushParagraph();\n\n return elements;\n };\n\n const scrollToContent = () => {\n const contentElement = document.querySelector('[data-testid=\"article-content\"]');\n if (contentElement) {\n contentElement.scrollIntoView({ behavior: 'smooth' });\n }\n };\n\n // Calculate font size based on textSize (1-10 range, 5 is default)\n // textSize 1 = 0.68rem, textSize 5 = 1rem, textSize 10 = 1.4rem\n const bodyFontSize = `${(textSize / 10) * 0.8 + 0.6}rem`;\n const summaryFontSize = `${(textSize / 10) * 0.8 + 0.8}rem`;\n const titleFontSize = `${(textSize / 10) * 1.2 + 1.4}rem`;\n\n // Fetch latest articles for the feed below comments\n const { data: latestArticles } = useQuery({\n queryKey: ['/api/feed', { limit: 20, exclude: id }],\n queryFn: async () => {\n const response = await fetch('/api/feed?limit=20');\n return response.json();\n },\n });\n\n const handleArticleClick = (articleId: string) => {\n if (onArticleClick) {\n onArticleClick(articleId);\n }\n };\n\n const handleOutletClick = (outletId: string) => {\n if (onOutletClick) {\n onOutletClick(outletId);\n }\n };\n\n const [selectedArticleId, setSelectedArticleId] = useState<string | null>(null);\n\n const handlePlayClick = (articleId: string) => {\n setSelectedArticleId(articleId);\n };\n\n const handleClosePopup = () => {\n setSelectedArticleId(null);\n };\n\n return (\n <div className=\"max-w-4xl mx-auto\">\n {/* Hero image */}\n <div className=\"aspect-video relative overflow-hidden rounded-lg mb-6\">\n <img\n src={thumbnail}\n alt={title}\n className={`w-full h-full object-cover transition-opacity duration-300 cursor-pointer ${\n imageLoaded ? 'opacity-100' : 'opacity-0'\n }`}\n onLoad={() => setImageLoaded(true)}\n onClick={scrollToContent}\n data-testid=\"img-article-hero\"\n />\n {!imageLoaded && (\n <div className=\"absolute inset-0 bg-muted animate-pulse\" />\n )}\n \n {/* Category badge */}\n <div className=\"absolute top-4 left-4\">\n <Badge variant=\"secondary\" data-testid=\"badge-category\">\n {category}\n </Badge>\n </div>\n\n {/* Time info - bottom left */}\n <div className=\"absolute bottom-3 left-3\">\n <span className=\"text-xs text-white drop-shadow-lg\" data-testid=\"text-published\">\n {timeAgoText}\n </span>\n </div>\n </div>\n\n {/* Article content */}\n <Card className=\"p-6 space-y-6\" data-testid=\"article-content\">\n {/* Title */}\n <h1 \n className=\"font-bold leading-tight\"\n style={{ fontSize: titleFontSize }}\n data-testid=\"text-title\"\n >\n {title}\n </h1>\n\n {/* Summary */}\n <p \n className=\"text-muted-foreground leading-relaxed\"\n style={{ fontSize: summaryFontSize }}\n data-testid=\"text-summary\"\n >\n {summary}\n </p>\n\n\n {/* Body content */}\n <div \n className=\"prose prose-sm max-w-none dark:prose-invert\"\n style={{ fontSize: bodyFontSize }}\n data-testid=\"text-body\"\n >\n {parseBodyContent(cleanedBody)}\n </div>\n </Card>\n\n {/* Navigation */}\n {(hasPrevious || hasNext) && (\n <div className=\"flex items-center justify-between mt-6 gap-4\">\n <Button\n variant=\"outline\"\n onClick={onPreviousArticle}\n disabled={!hasPrevious}\n data-testid=\"button-previous\"\n className=\"flex items-center gap-2 flex-1\"\n >\n <ChevronLeft className=\"h-4 w-4\" />\n Previous Article\n </Button>\n \n <Button\n variant=\"outline\"\n onClick={onNextArticle}\n disabled={!hasNext}\n data-testid=\"button-next\"\n className=\"flex items-center gap-2 flex-1\"\n >\n Next Article\n <ChevronRight className=\"h-4 w-4\" />\n </Button>\n </div>\n )}\n\n\n {/* Latest Articles Feed - YouTube style below comments */}\n {latestArticles?.items && latestArticles.items.length > 0 && (\n <div className=\"mt-8\">\n <div className=\"space-y-4\">\n {latestArticles.items\n .filter((article: any) => article.id !== id) // Exclude current article\n .map((article: any) => (\n <FeedItem\n key={article.id}\n id={article.id}\n title={article.title}\n summary={article.summary}\n thumbnail={article.thumbnail}\n publishedAt={new Date(article.publishedAt)}\n timeAgo={article.timeAgo}\n outletName={article.outletName}\n outletAvatar={article.outletAvatar}\n viewCount={article.viewCount}\n category={article.category}\n onClick={handleArticleClick}\n onOutletClick={handleOutletClick}\n onPlayClick={handlePlayClick}\n outletId={article.outletId}\n className=\"hover:bg-muted/50 transition-colors rounded-lg p-2\"\n />\n ))\n }\n </div>\n </div>\n )}\n\n {/* Article Popup */}\n <ArticlePopup \n articleId={selectedArticleId || ''}\n isOpen={!!selectedArticleId}\n onClose={handleClosePopup}\n />\n </div>\n );\n}","size_bytes":11191},"client/src/components/CategoryTabs.tsx":{"content":"import { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\n\ninterface Category {\n id: string;\n name: string;\n count: number;\n}\n\ninterface CategoryTabsProps {\n categories: Category[];\n activeCategory: string;\n onCategoryChange: (categoryId: string) => void;\n}\n\nexport default function CategoryTabs({\n categories,\n activeCategory,\n onCategoryChange,\n}: CategoryTabsProps) {\n return (\n <div className=\"flex gap-2 px-4 py-3 overflow-x-auto scrollbar-hide\">\n {categories.map((category) => (\n <Button\n key={category.id}\n variant={activeCategory === category.id ? \"default\" : \"outline\"}\n size=\"sm\"\n onClick={() => onCategoryChange(category.id)}\n data-testid={`tab-${category.id}`}\n className=\"flex items-center gap-2 whitespace-nowrap shrink-0\"\n >\n <span>{category.name}</span>\n <Badge \n variant=\"secondary\" \n className=\"h-5 px-1.5 text-xs\"\n >\n {category.count}\n </Badge>\n </Button>\n ))}\n </div>\n );\n}","size_bytes":1102},"client/src/components/MobileHeader.tsx":{"content":"import { Search, UserCircle } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport SettingsDropdown from \"@/components/SettingsDropdown\";\n\n// Sapiens logo from attached assets\nconst sapiensLogo = \"/api/assets/sapiens-logo.png\";\n\ninterface MobileHeaderProps {\n onSearchClick: () => void;\n title?: string;\n showBackButton?: boolean;\n onBackClick?: () => void;\n}\n\nexport default function MobileHeader({\n onSearchClick,\n title = \"NewsHub\",\n showBackButton = false,\n onBackClick,\n}: MobileHeaderProps) {\n return (\n <header className=\"sticky top-0 z-50 bg-background border-b border-border\">\n <div className=\"flex items-center justify-between h-10 px-3\">\n <div className=\"flex items-center gap-2\">\n {showBackButton && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={onBackClick}\n data-testid=\"button-back\"\n className=\"h-6 w-6\"\n >\n ←\n </Button>\n )}\n {title === \"Sapiens\" ? (\n <div className=\"h-4 flex items-center\">\n <img \n src={sapiensLogo} \n alt=\"SAPIENS\" \n className=\"h-full w-auto object-contain\"\n />\n </div>\n ) : (\n <h1 className=\"text-base font-semibold truncate\">{title}</h1>\n )}\n </div>\n \n <div className=\"flex items-center gap-2\">\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={onSearchClick}\n data-testid=\"button-search\"\n aria-label=\"Search\"\n >\n <Search className=\"h-4 w-4\" />\n </Button>\n \n <SettingsDropdown />\n \n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={() => {}}\n data-testid=\"button-login\"\n aria-label=\"Log in\"\n >\n <UserCircle className=\"h-4 w-4\" />\n </Button>\n </div>\n </div>\n </header>\n );\n}","size_bytes":2072},"client/src/components/OutletCard.tsx":{"content":"import { Card } from \"@/components/ui/card\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\n\ninterface OutletCardProps {\n id: string;\n name: string;\n description: string;\n category: string;\n focusSubject: string;\n avatar?: string;\n articleCount: number;\n onClick: (id: string) => void;\n className?: string;\n}\n\nexport default function OutletCard({\n id,\n name,\n description,\n category,\n focusSubject,\n avatar,\n articleCount,\n onClick,\n className = \"\",\n}: OutletCardProps) {\n const getInitials = (name: string) => {\n return name\n .split(' ')\n .map(word => word[0])\n .join('')\n .toUpperCase()\n .slice(0, 2);\n };\n\n\n return (\n <Card \n className={`p-3 hover-elevate cursor-pointer w-full max-w-full ${className}`}\n onClick={() => onClick(id)}\n data-testid={`card-outlet-${id}`}\n >\n <div className=\"flex items-center gap-3 text-left min-w-0\">\n <Avatar className=\"h-12 w-12 shrink-0\">\n <AvatarImage \n src={avatar} \n alt={name} \n className={category === 'people' ? 'object-[center_30%]' : ''}\n />\n <AvatarFallback className=\"text-sm font-medium\">\n {getInitials(name)}\n </AvatarFallback>\n </Avatar>\n \n <div className=\"flex-1 min-w-0\">\n <h3 \n className=\"font-semibold text-base leading-tight truncate mb-1\"\n data-testid={`text-name-${id}`}\n title={name}\n >\n {name}\n </h3>\n <p \n className=\"text-sm text-muted-foreground leading-tight truncate\"\n data-testid={`text-description-${id}`}\n title={description}\n >\n {description}\n </p>\n </div>\n </div>\n </Card>\n );\n}","size_bytes":1818},"client/src/components/OutletProfile.tsx":{"content":"import { useState } from \"react\";\nimport { User, X, Search, Info, MoreHorizontal, FileText, List, UserCircle } from \"lucide-react\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Button } from \"@/components/ui/button\";\nimport SettingsDropdown from \"@/components/SettingsDropdown\";\nimport DOMPurify from \"dompurify\";\n// Sapiens logo from attached assets\nconst sapiensLogo = \"/api/assets/sapiens-logo.png\";\n\ninterface OutletProfileProps {\n name: string;\n description: string;\n category: string;\n focusSubject: string;\n avatar?: string;\n profileImage?: string;\n bio: string;\n fullBio?: string[];\n wikiProfile?: string;\n articleCount?: number;\n isSticky?: boolean;\n // New props for sticky header functionality\n onSearchClick?: () => void;\n selectedLanguage?: string;\n onLanguageChange?: (language: string) => void;\n onLoginClick?: () => void;\n}\n\nexport default function OutletProfile({\n name,\n description,\n category,\n avatar,\n profileImage,\n fullBio,\n wikiProfile,\n bio,\n isSticky = false,\n onSearchClick,\n selectedLanguage = \"en\",\n onLanguageChange,\n onLoginClick,\n}: OutletProfileProps) {\n const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);\n const [viewMode, setViewMode] = useState<'simple' | 'detailed'>('simple'); // simple = 3 bullets, detailed = wiki profile\n \n\n const getInitials = (name: string) => {\n return name\n .split(' ')\n .map(word => word[0])\n .join('')\n .toUpperCase()\n .slice(0, 2);\n };\n\n const sanitizeHtml = (html: string) => {\n return DOMPurify.sanitize(html, {\n ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'ul', 'ol', 'li', 'strong', 'em', 'a'],\n ALLOWED_ATTR: ['href', 'target', 'rel'],\n ADD_ATTR: ['target', 'rel'],\n FORBID_ATTR: ['style', 'onclick', 'onload'],\n ALLOW_DATA_ATTR: false\n });\n };\n\n\n if (isSticky) {\n // Fixed sticky header version with profile button\n return (\n <>\n <div className=\"bg-background border-b border-border py-3 px-4\">\n <div className=\"flex items-center justify-between gap-3 min-w-0\">\n <div className=\"flex items-center gap-3 flex-1 min-w-0\">\n <Avatar className=\"h-10 w-10 shrink-0\">\n <AvatarImage src={avatar} alt={name} />\n <AvatarFallback className=\"text-sm font-medium\">\n {getInitials(name)}\n </AvatarFallback>\n </Avatar>\n <div className=\"flex-1 min-w-0\">\n <div className=\"h-2.5 flex items-center\">\n <img \n src={sapiensLogo} \n alt=\"SAPIENS\" \n className=\"h-full w-auto object-contain\"\n style={{transform: 'translateX(-2px)'}}\n />\n </div>\n <div className=\"flex items-center gap-1\">\n <h2 className=\"font-semibold text-base truncate leading-tight\" data-testid=\"text-outlet-name\">\n {name}\n </h2>\n {/* Info icon next to name */}\n {fullBio && fullBio.length > 0 && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={() => setIsProfileModalOpen(true)}\n data-testid=\"button-info\"\n className=\"h-6 w-6 shrink-0\"\n >\n <Info className=\"h-4 w-4\" />\n </Button>\n )}\n </div>\n </div>\n </div>\n\n {/* Right side controls: Search, Settings, Login */}\n <div className=\"flex items-center gap-2 shrink-0\">\n {/* Search button */}\n {onSearchClick && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={onSearchClick}\n data-testid=\"button-search\"\n aria-label=\"Search\"\n >\n <Search className=\"h-4 w-4\" />\n </Button>\n )}\n \n {/* Settings dropdown */}\n <SettingsDropdown />\n \n {/* Login button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={onLoginClick || (() => {})}\n data-testid=\"button-login\"\n aria-label=\"Log in\"\n >\n <UserCircle className=\"h-4 w-4\" />\n </Button>\n </div>\n </div>\n </div>\n\n {/* 프로필 모달 - sticky 버전에서도 포함 */}\n {isProfileModalOpen && (\n <div \n className=\"fixed inset-0 z-[1000] flex items-center justify-center bg-black/50\"\n onClick={() => setIsProfileModalOpen(false)}\n data-testid=\"modal-overlay\"\n >\n <div \n className={`bg-background rounded-lg shadow-lg w-full m-4 max-h-[80vh] overflow-y-auto ${\n viewMode === 'detailed' ? 'max-w-4xl' : 'max-w-md'\n }`}\n onClick={(e) => e.stopPropagation()}\n data-testid=\"modal-content\"\n >\n <div className=\"p-6\">\n <div className=\"flex items-center justify-between mb-6\">\n <div className=\"flex items-center gap-3\">\n <Avatar className=\"h-12 w-12\">\n <AvatarImage src={avatar} alt={name} />\n <AvatarFallback className=\"text-sm font-medium\">\n {getInitials(name)}\n </AvatarFallback>\n </Avatar>\n <div>\n <h2 className=\"text-lg font-bold\" data-testid=\"text-modal-name\">\n {name}\n </h2>\n </div>\n </div>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={() => setIsProfileModalOpen(false)}\n data-testid=\"button-modal-close\"\n >\n <X className=\"h-4 w-4\" />\n </Button>\n </div>\n \n {/* Toggle Buttons */}\n <div className=\"grid grid-cols-2 gap-1 p-1 bg-muted rounded-lg mb-4\">\n <button\n onClick={() => setViewMode('simple')}\n className={`flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${\n viewMode === 'simple'\n ? 'bg-background text-foreground shadow-sm' \n : 'text-muted-foreground hover:text-foreground'\n }`}\n data-testid=\"button-simple-view\"\n >\n <List className=\"h-4 w-4\" />\n Simple\n </button>\n <button\n onClick={() => setViewMode('detailed')}\n className={`flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${\n viewMode === 'detailed'\n ? 'bg-background text-foreground shadow-sm' \n : 'text-muted-foreground hover:text-foreground'\n }`}\n data-testid=\"button-detailed-view\"\n >\n <FileText className=\"h-4 w-4\" />\n Detailed\n </button>\n </div>\n \n {/* Content based on view mode */}\n {viewMode === 'detailed' && wikiProfile ? (\n <div \n className=\"prose prose-sm max-w-none dark:prose-invert\"\n dangerouslySetInnerHTML={{ __html: sanitizeHtml(wikiProfile) }}\n data-testid=\"wiki-profile-content\"\n />\n ) : fullBio && fullBio.length > 0 && (\n <div className=\"space-y-4\">\n <div className=\"space-y-3\">\n {fullBio.slice(0, 3).map((point, index) => (\n <div \n key={index}\n className=\"flex items-start gap-3\"\n data-testid={`bio-point-${index}`}\n >\n <div className=\"h-2 w-2 rounded-full bg-primary mt-2 shrink-0\" />\n <p className=\"text-sm leading-relaxed text-foreground\">\n {point}\n </p>\n </div>\n ))}\n </div>\n </div>\n )}\n </div>\n </div>\n </div>\n )}\n </>\n );\n }\n\n // Slim profile version - 가로로 최대한 얇게\n return (\n <>\n <div className=\"bg-background border-b border-border py-3 px-4\">\n <div className=\"flex items-center justify-between gap-3 min-w-0\">\n <div className=\"flex items-center gap-3 flex-1 min-w-0\">\n {/* 프로필 사진 하나만 동그라미에 */}\n <Avatar className=\"h-12 w-12 shrink-0\">\n <AvatarImage src={avatar} alt={name} />\n <AvatarFallback className=\"text-sm font-medium\">\n {getInitials(name)}\n </AvatarFallback>\n </Avatar>\n \n <div className=\"min-w-0\">\n <div className=\"h-3 flex items-center\">\n <img \n src={sapiensLogo} \n alt=\"SAPIENS\" \n className=\"h-full w-auto object-contain\"\n style={{transform: 'translateX(-2px)'}}\n />\n </div>\n <div className=\"flex items-center gap-2\">\n <h1 className=\"text-lg font-bold truncate leading-tight\" data-testid=\"text-outlet-name\">\n {name}\n </h1>\n {/* Info icon moved next to name for non-sticky version too */}\n {fullBio && fullBio.length > 0 && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={() => setIsProfileModalOpen(true)}\n data-testid=\"button-info-non-sticky\"\n className=\"h-6 w-6 shrink-0\"\n >\n <Info className=\"h-4 w-4\" />\n </Button>\n )}\n </div>\n </div>\n </div>\n\n </div>\n </div>\n\n {/* 5bullet point 프로필 팝업 */}\n {isProfileModalOpen && (\n <div \n className=\"fixed inset-0 z-[1000] flex items-center justify-center bg-black/50\"\n onClick={() => setIsProfileModalOpen(false)}\n data-testid=\"modal-overlay\"\n >\n <div \n className={`bg-background rounded-lg shadow-lg w-full m-4 max-h-[80vh] overflow-y-auto ${\n viewMode === 'detailed' ? 'max-w-4xl' : 'max-w-md'\n }`}\n onClick={(e) => e.stopPropagation()}\n data-testid=\"modal-content\"\n >\n <div className=\"p-6\">\n <div className=\"flex items-center justify-between mb-6\">\n <div className=\"flex items-center gap-3\">\n <Avatar className=\"h-12 w-12\">\n <AvatarImage src={avatar} alt={name} />\n <AvatarFallback className=\"text-sm font-medium\">\n {getInitials(name)}\n </AvatarFallback>\n </Avatar>\n <div>\n <h2 className=\"text-lg font-bold\" data-testid=\"text-modal-name\">\n {name}\n </h2>\n </div>\n </div>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={() => setIsProfileModalOpen(false)}\n data-testid=\"button-modal-close\"\n >\n <X className=\"h-4 w-4\" />\n </Button>\n </div>\n \n {/* Toggle Buttons */}\n <div className=\"grid grid-cols-2 gap-1 p-1 bg-muted rounded-lg mb-4\">\n <button\n onClick={() => setViewMode('simple')}\n className={`flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${\n viewMode === 'simple'\n ? 'bg-background text-foreground shadow-sm' \n : 'text-muted-foreground hover:text-foreground'\n }`}\n data-testid=\"button-simple-view-2\"\n >\n <List className=\"h-4 w-4\" />\n Simple\n </button>\n <button\n onClick={() => setViewMode('detailed')}\n className={`flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${\n viewMode === 'detailed'\n ? 'bg-background text-foreground shadow-sm' \n : 'text-muted-foreground hover:text-foreground'\n }`}\n data-testid=\"button-detailed-view-2\"\n >\n <FileText className=\"h-4 w-4\" />\n Detailed\n </button>\n </div>\n \n {/* Content based on view mode */}\n {viewMode === 'detailed' && wikiProfile ? (\n <div \n className=\"prose prose-sm max-w-none dark:prose-invert\"\n dangerouslySetInnerHTML={{ __html: sanitizeHtml(wikiProfile) }}\n data-testid=\"wiki-profile-content-2\"\n />\n ) : fullBio && fullBio.length > 0 && (\n <div className=\"space-y-4\">\n <div className=\"space-y-3\">\n {fullBio.slice(0, 3).map((point, index) => (\n <div \n key={index}\n className=\"flex items-start gap-3\"\n data-testid={`bio-point-${index}`}\n >\n <div className=\"h-2 w-2 rounded-full bg-primary mt-2 shrink-0\" />\n <p className=\"text-sm leading-relaxed text-foreground\">\n {point}\n </p>\n </div>\n ))}\n </div>\n </div>\n )}\n </div>\n </div>\n </div>\n )}\n </>\n );\n}","size_bytes":14799},"client/src/components/SearchOverlay.tsx":{"content":"import { useState, useEffect, useRef } from \"react\";\nimport { Search, X } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Card } from \"@/components/ui/card\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { useLocation } from \"wouter\";\n\ninterface SearchResult {\n id: string;\n title: string;\n summary: string;\n category?: string;\n outletName?: string;\n publishedAt?: Date;\n name?: string;\n description?: string;\n focusSubject?: string;\n}\n\ninterface SearchResults {\n articles: SearchResult[];\n outlets: SearchResult[];\n}\n\ninterface SearchOverlayProps {\n isOpen: boolean;\n onClose: () => void;\n onArticleClick: (id: string) => void;\n searchResults: SearchResults | SearchResult[] | null;\n onSearch: (query: string) => void;\n isLoading?: boolean;\n}\n\nexport default function SearchOverlay({\n isOpen,\n onClose,\n onArticleClick,\n searchResults,\n onSearch,\n isLoading = false,\n}: SearchOverlayProps) {\n const [query, setQuery] = useState(\"\");\n const [isVisible, setIsVisible] = useState(false);\n const [shouldAnimate, setShouldAnimate] = useState(false);\n const inputRef = useRef<HTMLInputElement>(null);\n const [, setLocation] = useLocation();\n\n useEffect(() => {\n if (isOpen) {\n setIsVisible(true);\n // Start animation after component is mounted and positioned\n const animationTimer = setTimeout(() => {\n setShouldAnimate(true);\n }, 10);\n \n // Focus input after animation starts\n setTimeout(() => {\n if (inputRef.current) {\n inputRef.current.focus();\n }\n }, 100);\n\n return () => clearTimeout(animationTimer);\n } else {\n setShouldAnimate(false);\n // Hide after transition completes\n const timer = setTimeout(() => setIsVisible(false), 300);\n return () => clearTimeout(timer);\n }\n }, [isOpen]);\n\n useEffect(() => {\n const debounceTimer = setTimeout(() => {\n if (query.trim()) {\n onSearch(query);\n }\n }, 300);\n\n return () => clearTimeout(debounceTimer);\n }, [query, onSearch]);\n\n if (!isVisible) return null;\n\n const handleArticleClick = (id: string) => {\n setLocation(`/article/${id}`);\n onClose();\n };\n\n const handleOutletClick = (id: string) => {\n setLocation(`/outlet/${id}`);\n onClose();\n };\n\n return (\n <div className={`fixed inset-0 z-50 transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>\n <div className={`absolute inset-0 bg-background transform transition-transform duration-300 ease-out flex flex-col ${shouldAnimate ? 'translate-x-0' : 'translate-x-full'}`}>\n {/* Header */}\n <div className=\"flex items-center gap-3 p-4 border-b border-border shrink-0\">\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground\" />\n <Input\n ref={inputRef}\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder=\"Search articles...\"\n className=\"pl-10 pr-4\"\n data-testid=\"input-search\"\n />\n </div>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={onClose}\n data-testid=\"button-close-search\"\n >\n <X className=\"h-4 w-4\" />\n </Button>\n </div>\n\n {/* Results - Now with proper flex and height constraints */}\n <div className=\"flex-1 overflow-y-auto p-4 min-h-0\">\n {isLoading ? (\n <div className=\"flex items-center justify-center py-8\">\n <div className=\"text-sm text-muted-foreground\">Searching...</div>\n </div>\n ) : query.trim() === \"\" ? (\n <div className=\"flex items-center justify-center py-8\">\n <div className=\"text-sm text-muted-foreground\">Start typing to search articles and outlets</div>\n </div>\n ) : (() => {\n // Handle different search result formats\n let articles: SearchResult[] = [];\n let outlets: SearchResult[] = [];\n \n if (Array.isArray(searchResults)) {\n // Legacy format - assume all are articles\n articles = searchResults;\n } else if (searchResults && typeof searchResults === 'object') {\n // New format with articles and outlets\n articles = searchResults.articles || [];\n outlets = searchResults.outlets || [];\n }\n \n const totalResults = articles.length + outlets.length;\n \n return totalResults === 0 ? (\n <div className=\"flex items-center justify-center py-8\">\n <div className=\"text-sm text-muted-foreground\">No results found</div>\n </div>\n ) : (\n <div className=\"space-y-4\">\n <div className=\"text-sm text-muted-foreground\">\n {totalResults} result{totalResults !== 1 ? 's' : ''} found\n </div>\n \n {/* Outlets Section - Now shows first */}\n {outlets.length > 0 && (\n <div className=\"space-y-3\">\n <h4 className=\"text-sm font-medium text-foreground\">Outlets ({outlets.length})</h4>\n {outlets.map((result) => (\n <Card\n key={`outlet-${result.id}`}\n className=\"p-4 hover-elevate cursor-pointer\"\n onClick={() => handleOutletClick(result.id)}\n data-testid={`search-result-outlet-${result.id}`}\n >\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2\">\n <h3 className=\"font-semibold text-sm line-clamp-2\">\n {result.name || result.title}\n </h3>\n {result.category && (\n <Badge variant=\"secondary\" className=\"text-xs capitalize\">\n {result.category}\n </Badge>\n )}\n </div>\n <p className=\"text-xs text-muted-foreground line-clamp-2\">\n {result.description || result.summary}\n </p>\n </div>\n </Card>\n ))}\n </div>\n )}\n \n {/* Articles Section - Now shows second */}\n {articles.length > 0 && (\n <div className=\"space-y-3\">\n <h4 className=\"text-sm font-medium text-foreground\">Articles ({articles.length})</h4>\n {articles.map((result: any) => (\n <Card\n key={`article-${result.id}`}\n className=\"p-3 hover-elevate cursor-pointer\"\n onClick={() => handleArticleClick(result.id)}\n data-testid={`search-result-article-${result.id}`}\n >\n <div className=\"flex gap-3\">\n {/* Thumbnail on the left */}\n <div className=\"w-16 h-12 shrink-0\">\n <img\n src={result.thumbnail || '/api/assets/default-article.png'}\n alt={result.title}\n className=\"w-full h-full object-cover rounded\"\n />\n </div>\n \n {/* Content on the right */}\n <div className=\"flex-1 min-w-0 space-y-1\">\n <div className=\"flex items-center gap-2 flex-wrap\">\n <h3 className=\"font-semibold text-sm line-clamp-2\">\n {result.title}\n </h3>\n {result.outletName && (\n <Badge variant=\"outline\" className=\"text-xs\">\n {result.outletName}\n </Badge>\n )}\n </div>\n <p className=\"text-xs text-muted-foreground line-clamp-2\">\n {result.summary}\n </p>\n </div>\n </div>\n </Card>\n ))}\n </div>\n )}\n </div>\n );\n })()}\n </div>\n </div>\n </div>\n );\n}","size_bytes":8918},"client/src/components/SwipeableCarousel.tsx":{"content":"import { useState, useRef, useEffect } from \"react\";\nimport { Card } from \"@/components/ui/card\";\n\ninterface CarouselItem {\n id: string;\n content: React.ReactNode;\n}\n\ninterface SwipeableCarouselProps {\n items: CarouselItem[];\n autoScroll?: boolean;\n autoScrollDelay?: number;\n className?: string;\n}\n\nexport default function SwipeableCarousel({\n items,\n autoScroll = true,\n autoScrollDelay = 5000,\n className = \"\",\n}: SwipeableCarouselProps) {\n const [currentIndex, setCurrentIndex] = useState(0);\n const [isDragging, setIsDragging] = useState(false);\n const carouselRef = useRef<HTMLDivElement>(null);\n const startXRef = useRef(0);\n const scrollLeftRef = useRef(0);\n\n // Auto scroll functionality\n useEffect(() => {\n if (!autoScroll || isDragging) return;\n\n const interval = setInterval(() => {\n setCurrentIndex((prev) => (prev + 1) % items.length);\n }, autoScrollDelay);\n\n return () => clearInterval(interval);\n }, [autoScroll, autoScrollDelay, isDragging, items.length]);\n\n // Handle manual scroll\n const handleMouseDown = (e: React.MouseEvent) => {\n setIsDragging(true);\n const carousel = carouselRef.current;\n if (!carousel) return;\n\n startXRef.current = e.pageX - carousel.offsetLeft;\n scrollLeftRef.current = carousel.scrollLeft;\n };\n\n const handleMouseMove = (e: React.MouseEvent) => {\n if (!isDragging) return;\n e.preventDefault();\n \n const carousel = carouselRef.current;\n if (!carousel) return;\n\n const x = e.pageX - carousel.offsetLeft;\n const walk = (x - startXRef.current) * 2;\n carousel.scrollLeft = scrollLeftRef.current - walk;\n };\n\n const handleMouseUp = () => {\n setIsDragging(false);\n };\n\n // Touch events for mobile\n const handleTouchStart = (e: React.TouchEvent) => {\n setIsDragging(true);\n const carousel = carouselRef.current;\n if (!carousel) return;\n\n startXRef.current = e.touches[0].pageX - carousel.offsetLeft;\n scrollLeftRef.current = carousel.scrollLeft;\n };\n\n const handleTouchMove = (e: React.TouchEvent) => {\n if (!isDragging) return;\n \n const carousel = carouselRef.current;\n if (!carousel) return;\n\n const x = e.touches[0].pageX - carousel.offsetLeft;\n const walk = (x - startXRef.current) * 2;\n carousel.scrollLeft = scrollLeftRef.current - walk;\n };\n\n const handleTouchEnd = () => {\n setIsDragging(false);\n };\n\n return (\n <div className={`relative ${className}`}>\n <div\n ref={carouselRef}\n className=\"flex gap-4 overflow-x-auto scrollbar-hide snap-x snap-mandatory px-4\"\n onMouseDown={handleMouseDown}\n onMouseMove={handleMouseMove}\n onMouseUp={handleMouseUp}\n onMouseLeave={handleMouseUp}\n onTouchStart={handleTouchStart}\n onTouchMove={handleTouchMove}\n onTouchEnd={handleTouchEnd}\n data-testid=\"carousel-container\"\n style={{ scrollBehavior: isDragging ? 'auto' : 'smooth' }}\n >\n {items.map((item, index) => (\n <div\n key={item.id}\n className=\"flex-shrink-0 w-80 snap-start\"\n data-testid={`carousel-item-${index}`}\n >\n {item.content}\n </div>\n ))}\n </div>\n </div>\n );\n}","size_bytes":3219},"client/src/hooks/use-mobile.tsx":{"content":"import * as React from \"react\"\n\nconst MOBILE_BREAKPOINT = 768\n\nexport function useIsMobile() {\n const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)\n\n React.useEffect(() => {\n const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)\n const onChange = () => {\n setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n }\n mql.addEventListener(\"change\", onChange)\n setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n return () => mql.removeEventListener(\"change\", onChange)\n }, [])\n\n return !!isMobile\n}\n","size_bytes":565},"client/src/hooks/use-toast.ts":{"content":"import * as React from \"react\"\n\nimport type {\n ToastActionElement,\n ToastProps,\n} from \"@/components/ui/toast\"\n\nconst TOAST_LIMIT = 1\nconst TOAST_REMOVE_DELAY = 1000000\n\ntype ToasterToast = ToastProps & {\n id: string\n title?: React.ReactNode\n description?: React.ReactNode\n action?: ToastActionElement\n}\n\nconst actionTypes = {\n ADD_TOAST: \"ADD_TOAST\",\n UPDATE_TOAST: \"UPDATE_TOAST\",\n DISMISS_TOAST: \"DISMISS_TOAST\",\n REMOVE_TOAST: \"REMOVE_TOAST\",\n} as const\n\nlet count = 0\n\nfunction genId() {\n count = (count + 1) % Number.MAX_SAFE_INTEGER\n return count.toString()\n}\n\ntype ActionType = typeof actionTypes\n\ntype Action =\n | {\n type: ActionType[\"ADD_TOAST\"]\n toast: ToasterToast\n }\n | {\n type: ActionType[\"UPDATE_TOAST\"]\n toast: Partial<ToasterToast>\n }\n | {\n type: ActionType[\"DISMISS_TOAST\"]\n toastId?: ToasterToast[\"id\"]\n }\n | {\n type: ActionType[\"REMOVE_TOAST\"]\n toastId?: ToasterToast[\"id\"]\n }\n\ninterface State {\n toasts: ToasterToast[]\n}\n\nconst toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()\n\nconst addToRemoveQueue = (toastId: string) => {\n if (toastTimeouts.has(toastId)) {\n return\n }\n\n const timeout = setTimeout(() => {\n toastTimeouts.delete(toastId)\n dispatch({\n type: \"REMOVE_TOAST\",\n toastId: toastId,\n })\n }, TOAST_REMOVE_DELAY)\n\n toastTimeouts.set(toastId, timeout)\n}\n\nexport const reducer = (state: State, action: Action): State => {\n switch (action.type) {\n case \"ADD_TOAST\":\n return {\n ...state,\n toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),\n }\n\n case \"UPDATE_TOAST\":\n return {\n ...state,\n toasts: state.toasts.map((t) =>\n t.id === action.toast.id ? { ...t, ...action.toast } : t\n ),\n }\n\n case \"DISMISS_TOAST\": {\n const { toastId } = action\n\n // ! Side effects ! - This could be extracted into a dismissToast() action,\n // but I'll keep it here for simplicity\n if (toastId) {\n addToRemoveQueue(toastId)\n } else {\n state.toasts.forEach((toast) => {\n addToRemoveQueue(toast.id)\n })\n }\n\n return {\n ...state,\n toasts: state.toasts.map((t) =>\n t.id === toastId || toastId === undefined\n ? {\n ...t,\n open: false,\n }\n : t\n ),\n }\n }\n case \"REMOVE_TOAST\":\n if (action.toastId === undefined) {\n return {\n ...state,\n toasts: [],\n }\n }\n return {\n ...state,\n toasts: state.toasts.filter((t) => t.id !== action.toastId),\n }\n }\n}\n\nconst listeners: Array<(state: State) => void> = []\n\nlet memoryState: State = { toasts: [] }\n\nfunction dispatch(action: Action) {\n memoryState = reducer(memoryState, action)\n listeners.forEach((listener) => {\n listener(memoryState)\n })\n}\n\ntype Toast = Omit<ToasterToast, \"id\">\n\nfunction toast({ ...props }: Toast) {\n const id = genId()\n\n const update = (props: ToasterToast) =>\n dispatch({\n type: \"UPDATE_TOAST\",\n toast: { ...props, id },\n })\n const dismiss = () => dispatch({ type: \"DISMISS_TOAST\", toastId: id })\n\n dispatch({\n type: \"ADD_TOAST\",\n toast: {\n ...props,\n id,\n open: true,\n onOpenChange: (open) => {\n if (!open) dismiss()\n },\n },\n })\n\n return {\n id: id,\n dismiss,\n update,\n }\n}\n\nfunction useToast() {\n const [state, setState] = React.useState<State>(memoryState)\n\n React.useEffect(() => {\n listeners.push(setState)\n return () => {\n const index = listeners.indexOf(setState)\n if (index > -1) {\n listeners.splice(index, 1)\n }\n }\n }, [state])\n\n return {\n ...state,\n toast,\n dismiss: (toastId?: string) => dispatch({ type: \"DISMISS_TOAST\", toastId }),\n }\n}\n\nexport { useToast, toast }\n","size_bytes":3895},"client/src/hooks/useApi.ts":{"content":"import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';\nimport { outletsApi, articlesApi, searchApi, mutations, feedApi } from '@/lib/api';\n\n// Outlets hooks\nexport function useOutlets(category?: string) {\n return useQuery({\n queryKey: ['/outlets', category],\n queryFn: () => outletsApi.getAll(category),\n });\n}\n\nexport function useOutlet(id: string) {\n return useQuery({\n queryKey: ['outlet', id],\n queryFn: () => outletsApi.getById(id),\n enabled: !!id && id.trim().length > 0,\n });\n}\n\n// Articles hooks\nexport function useArticles() {\n return useQuery({\n queryKey: ['/articles'],\n queryFn: () => articlesApi.getAll(),\n });\n}\n\nexport function useArticlesByOutlet(outletId: string) {\n return useQuery({\n queryKey: ['/articles', outletId],\n queryFn: () => articlesApi.getByOutlet(outletId),\n enabled: !!outletId,\n });\n}\n\nexport function useFeaturedArticles(limit = 10) {\n return useQuery({\n queryKey: ['/articles', 'featured', limit],\n queryFn: () => articlesApi.getFeatured(limit),\n });\n}\n\nexport function useArticle(id: string) {\n return useQuery({\n queryKey: ['/articles', id],\n queryFn: () => articlesApi.getById(id),\n enabled: !!id,\n });\n}\n\n// Search hooks\nexport function useSearch(query: string, type: 'all' | 'articles' | 'outlets' = 'all') {\n return useQuery({\n queryKey: ['/search', query, type],\n queryFn: () => searchApi.search(query, type),\n enabled: !!query.trim(),\n });\n}\n\n// Backward compatibility\nexport function useSearchArticles(query: string) {\n return useQuery({\n queryKey: ['/search', query, 'articles'],\n queryFn: () => searchApi.articles(query),\n enabled: !!query.trim(),\n });\n}\n\n// Mutation hooks\nexport function useCreateOutlet() {\n const queryClient = useQueryClient();\n \n return useMutation({\n mutationFn: mutations.createOutlet,\n onSuccess: () => {\n queryClient.invalidateQueries({ queryKey: ['/outlets'] });\n },\n });\n}\n\nexport function useCreateArticle() {\n const queryClient = useQueryClient();\n \n return useMutation({\n mutationFn: mutations.createArticle,\n onSuccess: (data, variables) => {\n queryClient.invalidateQueries({ queryKey: ['/articles'] });\n queryClient.invalidateQueries({ queryKey: ['/articles', variables.outletId] });\n },\n });\n}\n\n// YouTube-style feed hooks\nexport function useFeed(filter: 'all' | 'people' | 'topics' | 'companies' = 'all') {\n return useInfiniteQuery({\n queryKey: ['/feed', filter],\n queryFn: ({ pageParam }) => {\n return feedApi.getFeed({\n cursor: pageParam,\n limit: 10,\n filter: filter\n });\n },\n initialPageParam: undefined as string | undefined,\n getNextPageParam: (lastPage: any) => lastPage?.nextCursor,\n });\n}\n\nexport function useIncrementView(filter?: 'all' | 'people' | 'topics' | 'companies') {\n const queryClient = useQueryClient();\n \n return useMutation({\n mutationFn: feedApi.incrementView,\n onSuccess: (_, articleId) => {\n console.log(`[DEBUG] View incremented for article ${articleId}, invalidating cache with filter:`, filter);\n // Invalidate the specific filter's feed cache for better precision\n if (filter) {\n queryClient.invalidateQueries({ queryKey: ['/feed', filter] });\n } else {\n // Fallback to invalidating all feed caches if filter not provided\n queryClient.invalidateQueries({ queryKey: ['/feed'] });\n }\n // Also invalidate the specific article cache\n queryClient.invalidateQueries({ queryKey: ['/articles'] });\n },\n });\n}","size_bytes":3589},"client/src/lib/api.ts":{"content":"import { queryClient } from './queryClient';\n\nconst API_BASE = '/api';\n\n// Generic API request helper\nasync function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const response = await fetch(`${API_BASE}${endpoint}`, {\n headers: {\n 'Content-Type': 'application/json',\n ...options.headers,\n },\n ...options,\n });\n\n if (!response.ok) {\n throw new Error(`API Error: ${response.statusText}`);\n }\n\n return response.json();\n}\n\n// Outlets API\nexport const outletsApi = {\n getAll: (category?: string) => \n apiRequest(`/outlets${category ? `?category=${category}` : ''}`),\n \n getById: (id: string) => \n apiRequest(`/outlets/${id}`),\n \n create: (data: any) => \n apiRequest('/outlets', {\n method: 'POST',\n body: JSON.stringify(data),\n }),\n};\n\n// Articles API\nexport const articlesApi = {\n getAll: () => \n apiRequest('/articles'),\n \n getByOutlet: (outletId: string) => \n apiRequest(`/articles?outlet=${outletId}`),\n \n getFeatured: (limit = 10) => \n apiRequest(`/articles?featured=true&limit=${limit}`),\n \n getById: (id: string) => \n apiRequest(`/articles/${id}`),\n \n create: (data: any) => \n apiRequest('/articles', {\n method: 'POST',\n body: JSON.stringify(data),\n }),\n};\n\n// Search API\nexport const searchApi = {\n search: (query: string, type: 'all' | 'articles' | 'outlets' = 'all') => \n apiRequest(`/search?q=${encodeURIComponent(query)}&type=${type}`),\n \n // Backward compatibility\n articles: (query: string) => \n apiRequest(`/search?q=${encodeURIComponent(query)}&type=articles`).then((result: any) => result.articles || []),\n};\n\n// AI API\nexport const aiApi = {\n generateThumbnail: (description: string, aspectRatio = '16:9') =>\n apiRequest('/generate-thumbnail', {\n method: 'POST',\n body: JSON.stringify({ description, aspectRatio }),\n }),\n \n enhanceArticle: (title: string, summary: string, content: string) =>\n apiRequest('/enhance-article', {\n method: 'POST',\n body: JSON.stringify({ title, summary, content }),\n }),\n\n generateSummary: (content: string, title?: string) =>\n apiRequest('/generate-summary', {\n method: 'POST',\n body: JSON.stringify({ content, title }),\n }),\n};\n\n// Stats API\nexport const statsApi = {\n get: () => apiRequest('/stats'),\n};\n\n// Feed API for YouTube-style interface\nexport const feedApi = {\n getFeed: (params: { \n cursor?: string; \n limit?: number; \n filter?: 'all' | 'people' | 'topics' | 'companies' \n }) => {\n const searchParams = new URLSearchParams();\n if (params.cursor) searchParams.set('cursor', params.cursor);\n if (params.limit) searchParams.set('limit', params.limit.toString());\n if (params.filter) searchParams.set('filter', params.filter);\n \n return apiRequest(`/feed?${searchParams.toString()}`);\n },\n \n incrementView: (articleId: string) => \n apiRequest(`/articles/${articleId}/view`, {\n method: 'POST'\n })\n};\n\n// Mutation helpers for React Query\nexport const mutations = {\n createOutlet: (data: any) => {\n return apiRequest('/outlets', {\n method: 'POST',\n body: JSON.stringify(data),\n }).then((result) => {\n queryClient.invalidateQueries({ queryKey: ['/outlets'] });\n return result;\n });\n },\n \n createArticle: (data: any) => {\n return apiRequest('/articles', {\n method: 'POST',\n body: JSON.stringify(data),\n }).then((result) => {\n queryClient.invalidateQueries({ queryKey: ['/articles'] });\n queryClient.invalidateQueries({ queryKey: ['/articles', data.outletId] });\n return result;\n });\n },\n};","size_bytes":3630},"client/src/lib/queryClient.ts":{"content":"import { QueryClient, QueryFunction } from \"@tanstack/react-query\";\n\nasync function throwIfResNotOk(res: Response) {\n if (!res.ok) {\n const text = (await res.text()) || res.statusText;\n throw new Error(`${res.status}: ${text}`);\n }\n}\n\nexport async function apiRequest(\n method: string,\n url: string,\n data?: unknown | undefined,\n): Promise<Response> {\n const res = await fetch(url, {\n method,\n headers: data ? { \"Content-Type\": \"application/json\" } : {},\n body: data ? JSON.stringify(data) : undefined,\n credentials: \"include\",\n });\n\n await throwIfResNotOk(res);\n return res;\n}\n\ntype UnauthorizedBehavior = \"returnNull\" | \"throw\";\nexport const getQueryFn: <T>(options: {\n on401: UnauthorizedBehavior;\n}) => QueryFunction<T> =\n ({ on401: unauthorizedBehavior }) =>\n async ({ queryKey }) => {\n const res = await fetch(queryKey.join(\"/\") as string, {\n credentials: \"include\",\n });\n\n if (unauthorizedBehavior === \"returnNull\" && res.status === 401) {\n return null;\n }\n\n await throwIfResNotOk(res);\n return await res.json();\n };\n\nexport const queryClient = new QueryClient({\n defaultOptions: {\n queries: {\n queryFn: getQueryFn({ on401: \"throw\" }),\n refetchInterval: false,\n refetchOnWindowFocus: false,\n staleTime: Infinity,\n retry: false,\n },\n mutations: {\n retry: false,\n },\n },\n});\n","size_bytes":1383},"client/src/lib/utils.ts":{"content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n\n/**\n * Format time distance - always returns \"X min ago\" format\n */\nexport function formatTimeAgo(date: Date | string): string {\n const now = new Date()\n const past = new Date(date)\n const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000)\n const diffInMinutes = Math.floor(diffInSeconds / 60)\n \n // Always return in \"X min ago\" format\n return `${diffInMinutes} min ago`\n}\n","size_bytes":564},"client/src/pages/ArticlePage.tsx":{"content":"import { useState, useEffect } from \"react\";\nimport { useRoute, useLocation } from \"wouter\";\nimport { useQuery, useMutation } from \"@tanstack/react-query\";\nimport { Plus, Minus, RotateCcw } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport { apiRequest, queryClient } from \"@/lib/queryClient\";\nimport ArticleDetail from \"@/components/ArticleDetail\";\nimport SearchOverlay from \"@/components/SearchOverlay\";\nimport BottomTabBar from \"@/components/BottomTabBar\";\nimport CommentsDrawer from \"@/components/CommentsDrawer\";\nimport OutletProfile from \"@/components/OutletProfile\";\nimport PredictionMarketCard from \"@/components/PredictionMarketCard\";\nimport { useSearch } from \"@/hooks/useApi\";\nimport { useToast } from \"@/hooks/use-toast\";\nimport type { Article, MediaOutlet, PredictionMarket } from \"@shared/schema\";\n\n\nexport default function ArticlePage() {\n const [match, params] = useRoute(\"/article/:id\");\n const [, setLocation] = useLocation();\n const [selectedLanguage, setSelectedLanguage] = useState(\"en\");\n const [isSearchOpen, setIsSearchOpen] = useState(false);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const [showComments, setShowComments] = useState(false);\n const [textSize, setTextSize] = useState(5);\n const [showTextSizeControls, setShowTextSizeControls] = useState(false);\n const [showAllMarkets, setShowAllMarkets] = useState(false);\n const [userIdentifier] = useState(() => {\n const stored = localStorage.getItem('userIdentifier');\n if (stored) return stored;\n const newId = `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;\n localStorage.setItem('userIdentifier', newId);\n return newId;\n });\n const { toast } = useToast();\n\n const articleId = params?.id || '';\n \n // Search API call\n const { data: searchResults = { articles: [], outlets: [] } } = useSearch(searchQuery);\n \n // Fetch article from API\n const { data: article, isLoading: articleLoading, isError: articleError } = useQuery<Article>({\n queryKey: ['/api/articles', articleId],\n enabled: !!articleId\n });\n\n // Fetch outlet information for the article\n const { data: outlet, isLoading: outletLoading } = useQuery<MediaOutlet>({\n queryKey: ['/api/outlets', article?.outletId],\n enabled: !!article?.outletId\n });\n\n // Fetch comment count\n const { data: commentCountData } = useQuery<{ count: number }>({\n queryKey: ['/api/articles', articleId, 'comment-count'],\n enabled: !!articleId\n });\n\n // Fetch bookmark status\n const { data: bookmarkData } = useQuery<{ isBookmarked: boolean }>({\n queryKey: ['/api/bookmarks', articleId, userIdentifier],\n enabled: !!articleId && !!userIdentifier\n });\n\n // Fetch prediction markets\n const { data: predictionMarkets = [] } = useQuery<PredictionMarket[]>({\n queryKey: ['/api/prediction-markets/article', articleId, showAllMarkets ? 100 : 3],\n queryFn: async () => {\n const limit = showAllMarkets ? 100 : 3;\n const response = await fetch(`/api/prediction-markets/article/${articleId}?limit=${limit}`);\n return response.json();\n },\n enabled: !!articleId\n });\n\n // Toggle bookmark mutation - must be declared before any conditional returns\n const toggleBookmark = useMutation({\n mutationFn: async () => {\n const response = await apiRequest('POST', '/api/bookmarks/toggle', {\n articleId,\n userIdentifier,\n });\n return response.json();\n },\n onSuccess: () => {\n queryClient.invalidateQueries({ queryKey: ['/api/bookmarks', articleId, userIdentifier] });\n },\n });\n\n const commentCount = commentCountData?.count || 0;\n const isBookmarked = bookmarkData?.isBookmarked || false;\n\n const isLoading = articleLoading || outletLoading;\n const isError = articleError;\n\n if (isLoading) {\n return (\n <div className=\"min-h-screen bg-background flex items-center justify-center\">\n <div className=\"text-center space-y-2\">\n <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto\"></div>\n <p className=\"text-muted-foreground\">Loading article...</p>\n </div>\n </div>\n );\n }\n\n if (isError || !article) {\n return (\n <div className=\"min-h-screen bg-background flex items-center justify-center\">\n <div className=\"text-center space-y-2\">\n <h1 className=\"text-xl font-semibold\">Article not found</h1>\n <p className=\"text-muted-foreground\">The requested article does not exist.</p>\n </div>\n </div>\n );\n }\n\n // Previous/next navigation will be implemented when feed ordering is available\n const handlePreviousArticle = () => {};\n const handleNextArticle = () => {};\n\n const handleArticleClick = (articleId: string) => {\n setLocation(`/article/${articleId}`);\n };\n\n const handleOutletClick = (outletId: string) => {\n setLocation(`/outlet/${outletId}`);\n };\n\n const handleTitleClick = () => {\n // Navigate to home with the article's outlet category selected\n const category = outlet?.category || 'people';\n setLocation(`/?category=${category}`);\n };\n\n const handleBackClick = () => {\n // Navigate back to the outlet page\n if (outlet?.id) {\n setLocation(`/outlet/${outlet.id}`);\n } else {\n window.history.back();\n }\n };\n\n const handleSearch = (query: string) => {\n setSearchQuery(query);\n };\n\n const handleCommentClick = () => {\n setShowComments(!showComments);\n };\n\n const handleShareClick = () => {\n if (navigator.share) {\n navigator.share({\n title: article?.title,\n text: article?.summary,\n url: window.location.href,\n });\n } else {\n navigator.clipboard.writeText(window.location.href);\n toast({\n title: \"Link copied\",\n description: \"Article link copied to clipboard\",\n });\n }\n };\n\n const handleBookmarkClick = () => {\n toggleBookmark.mutate();\n toast({\n title: isBookmarked ? \"Removed from bookmarks\" : \"Added to bookmarks\",\n description: isBookmarked ? \"Article removed from your bookmarks\" : \"Article saved to your bookmarks\",\n });\n };\n\n const handleTextSizeChange = (size: number) => {\n setTextSize(size);\n };\n\n return (\n <div className=\"min-h-screen bg-background overflow-hidden\">\n {/* Main content with slide transition */}\n <div className={`transition-transform duration-300 ease-out ${isSearchOpen ? '-translate-x-full' : 'translate-x-0'}`}>\n {/* Outlet Profile - sticky at top */}\n <div className=\"sticky top-0 z-40\">\n {outlet && (\n <OutletProfile \n {...outlet}\n avatar={outlet.avatar || undefined}\n profileImage={outlet.profileImage || undefined}\n fullBio={outlet.fullBio || undefined}\n wikiProfile={outlet.wikiProfile || undefined}\n isSticky={true}\n onSearchClick={() => setIsSearchOpen(true)}\n selectedLanguage={selectedLanguage}\n onLanguageChange={setSelectedLanguage}\n onLoginClick={() => console.log('Login clicked')}\n />\n )}\n </div>\n\n <div className=\"p-4 pb-32\">\n <ArticleDetail\n id={article.id}\n title={article.title}\n summary={article.summary}\n body={article.body}\n thumbnail={article.thumbnail}\n publishedAt={new Date(article.publishedAt)}\n timeAgo={(article as any).timeAgo}\n outletName={outlet?.name || 'Unknown Outlet'}\n category={outlet?.category || 'General'}\n tags={article.tags || []}\n textSize={textSize}\n onPreviousArticle={handlePreviousArticle}\n onNextArticle={handleNextArticle}\n hasPrevious={false}\n hasNext={false}\n onArticleClick={handleArticleClick}\n onOutletClick={handleOutletClick}\n />\n\n {/* Prediction Markets Section */}\n {predictionMarkets.length > 0 && (\n <div className=\"mt-8 space-y-4\">\n <h2 className=\"text-lg font-semibold\">Related Prediction Markets</h2>\n <div className=\"space-y-3\">\n {predictionMarkets.map(market => (\n <PredictionMarketCard\n key={market.id}\n market={market}\n onClick={() => console.log('Market clicked:', market.id)}\n />\n ))}\n </div>\n {!showAllMarkets && predictionMarkets.length >= 3 && (\n <Button\n variant=\"outline\"\n className=\"w-full\"\n onClick={() => setShowAllMarkets(true)}\n data-testid=\"button-load-more-markets\"\n >\n Load More\n </Button>\n )}\n </div>\n )}\n </div>\n </div>\n\n {/* Text Size Controls - shown when text size button clicked */}\n {showTextSizeControls && (\n <div className=\"fixed bottom-20 right-4 z-50 animate-in fade-in slide-in-from-bottom-2 duration-200\">\n <Card className=\"p-2 shadow-lg\">\n <div className=\"flex flex-col gap-1\">\n {/* Plus button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-10 w-10\"\n onClick={() => handleTextSizeChange(Math.min(9, textSize + 1))}\n disabled={textSize === 9}\n data-testid=\"button-text-size-increase\"\n >\n <Plus className=\"h-5 w-5\" />\n </Button>\n \n {/* Minus button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-10 w-10\"\n onClick={() => handleTextSizeChange(Math.max(0, textSize - 1))}\n disabled={textSize === 0}\n data-testid=\"button-text-size-decrease\"\n >\n <Minus className=\"h-5 w-5\" />\n </Button>\n \n {/* Reset button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-10 w-10\"\n onClick={() => handleTextSizeChange(5)}\n data-testid=\"button-text-size-reset\"\n >\n <RotateCcw className=\"h-5 w-5\" />\n </Button>\n </div>\n </Card>\n </div>\n )}\n\n {/* Bottom Navigation Bar with integrated action icons */}\n <BottomTabBar \n variant=\"nav\" \n onTitleClick={handleTitleClick} \n onBackClick={handleBackClick}\n commentCount={commentCount}\n isBookmarked={isBookmarked}\n onCommentClick={handleCommentClick}\n onShareClick={handleShareClick}\n onBookmarkClick={handleBookmarkClick}\n onTextSizeClick={() => setShowTextSizeControls(!showTextSizeControls)}\n />\n \n {/* Search Overlay positioned to slide in from right */}\n <SearchOverlay\n isOpen={isSearchOpen}\n onClose={() => setIsSearchOpen(false)}\n onArticleClick={(id) => setLocation(`/article/${id}`)}\n searchResults={searchResults as any}\n onSearch={handleSearch}\n />\n\n {/* Comments Drawer */}\n <CommentsDrawer\n articleId={articleId}\n isOpen={showComments}\n onClose={() => setShowComments(false)}\n />\n </div>\n );\n}","size_bytes":11396},"client/src/pages/HomePage.tsx":{"content":"import { useState, useEffect } from \"react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport { useLocation } from \"wouter\";\nimport MobileHeader from \"@/components/MobileHeader\";\nimport FeedList from \"@/components/FeedList\";\nimport BottomTabBar from \"@/components/BottomTabBar\";\nimport SearchOverlay from \"@/components/SearchOverlay\";\nimport OutletCard from \"@/components/OutletCard\";\nimport { useSearch } from \"@/hooks/useApi\";\nimport type { MediaOutlet } from \"@shared/schema\";\n\n// Extended type for outlets with article count from API\ntype MediaOutletWithCount = MediaOutlet & {\n articleCount?: number;\n};\n\n// Real data now loaded from API\n\nexport default function HomePage() {\n const [isSearchOpen, setIsSearchOpen] = useState(false);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const [, setLocation] = useLocation();\n \n // Get category from URL params or default to 'people'\n const getInitialCategory = (): 'people' | 'topics' | 'companies' => {\n const params = new URLSearchParams(window.location.search);\n const category = params.get('category') as 'people' | 'topics' | 'companies';\n return ['people', 'topics', 'companies'].includes(category) ? category : 'people';\n };\n \n const [activeFilter, setActiveFilter] = useState<'people' | 'topics' | 'companies'>(getInitialCategory());\n \n // Update URL when category changes\n useEffect(() => {\n const params = new URLSearchParams(window.location.search);\n params.set('category', activeFilter);\n const newUrl = `${window.location.pathname}?${params.toString()}`;\n window.history.replaceState({}, '', newUrl);\n }, [activeFilter]);\n \n // Handle browser back/forward buttons\n useEffect(() => {\n const handlePopState = () => {\n setActiveFilter(getInitialCategory());\n };\n \n window.addEventListener('popstate', handlePopState);\n return () => window.removeEventListener('popstate', handlePopState);\n }, []);\n\n // Search API call\n const { data: searchResults = { articles: [], outlets: [] } } = useSearch(searchQuery);\n\n // Fetch all outlets\n const { data: allOutlets = [], isLoading: outletsLoading } = useQuery<MediaOutletWithCount[]>({\n queryKey: ['/api/outlets'],\n });\n\n // Filter outlets based on activeFilter\n const filteredOutlets = allOutlets.filter(outlet => {\n return outlet.category === activeFilter;\n });\n\n const handleSearch = (query: string) => {\n setSearchQuery(query);\n };\n\n return (\n <div className=\"min-h-screen bg-background\">\n {/* YouTube-style Header */}\n <MobileHeader\n title=\"Sapiens\"\n onSearchClick={() => setIsSearchOpen(true)}\n />\n\n {/* Media Outlets Section */}\n <section className=\"px-3 py-4 pb-20 max-w-full\"> \n {outletsLoading ? (\n <div className=\"text-center py-8\">\n <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2\"></div>\n <p className=\"text-muted-foreground\">Loading media outlets...</p>\n </div>\n ) : (\n <div className=\"flex flex-col gap-2\">\n {filteredOutlets.map((outlet) => (\n <OutletCard\n key={outlet.id}\n id={outlet.id}\n name={outlet.name}\n description={outlet.description}\n category={outlet.category}\n focusSubject={outlet.focusSubject}\n avatar={outlet.avatar || undefined}\n articleCount={outlet.articleCount || 0}\n onClick={(id) => setLocation(`/outlet/${id}`)}\n />\n ))}\n </div>\n )}\n </section>\n\n\n {/* Bottom Tab Bar */}\n <BottomTabBar\n activeTab={activeFilter}\n onTabChange={(tab) => {\n // Push new state to history for proper back/forward navigation\n const params = new URLSearchParams();\n params.set('category', tab);\n const newUrl = `${window.location.pathname}?${params.toString()}`;\n window.history.pushState({}, '', newUrl);\n setActiveFilter(tab);\n }}\n />\n\n {/* Search Overlay */}\n <SearchOverlay\n isOpen={isSearchOpen}\n onClose={() => setIsSearchOpen(false)}\n onArticleClick={(id) => console.log('Navigate to article:', id)}\n searchResults={searchResults as any}\n onSearch={handleSearch}\n />\n </div>\n );\n}","size_bytes":4395},"client/src/pages/OutletPage.tsx":{"content":"import { useState, useEffect, useRef, useCallback } from \"react\";\nimport { useRoute, useLocation } from \"wouter\";\nimport { Loader2, Grid3X3, List } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport OutletProfile from \"@/components/OutletProfile\";\nimport BottomTabBar from \"@/components/BottomTabBar\";\nimport ArticleCard from \"@/components/ArticleCard\";\nimport SearchOverlay from \"@/components/SearchOverlay\";\nimport { useOutlet, useArticlesByOutlet, useSearch } from \"@/hooks/useApi\";\nimport { queryClient } from \"@/lib/queryClient\";\n\n// Real data loaded from API\n\nexport default function OutletPage() {\n const [match, params] = useRoute(\"/outlet/:id\");\n const [, setLocation] = useLocation();\n const [selectedLanguage, setSelectedLanguage] = useState(\"en\");\n const [isSearchOpen, setIsSearchOpen] = useState(false);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const [viewMode, setViewMode] = useState<'card' | 'list'>('card');\n \n // Pull-to-refresh state\n const [isPulling, setIsPulling] = useState(false);\n const [isRefreshing, setIsRefreshing] = useState(false);\n const [pullDistance, setPullDistance] = useState(0);\n const touchStartY = useRef(0);\n const touchCurrentY = useRef(0);\n const containerRef = useRef<HTMLDivElement>(null);\n\n // Fix route parameter extraction - wouter returns params as second element\n const outletId = params?.id || '';\n \n // API calls for real data\n const { data: outlet, isLoading: outletLoading, error: outletError } = useOutlet(outletId);\n const { data: articles = [], isLoading: articlesLoading } = useArticlesByOutlet(outletId);\n const { data: searchResults = { articles: [], outlets: [] } } = useSearch(searchQuery);\n \n // Pull-to-refresh handlers - MUST be before any conditional returns\n const handleRefresh = useCallback(async () => {\n setIsRefreshing(true);\n try {\n // Invalidate and refetch outlet and articles data\n await queryClient.invalidateQueries({ queryKey: ['outlet', outletId] });\n await queryClient.invalidateQueries({ queryKey: ['articles', outletId] });\n // Wait a bit for the refresh to complete\n setTimeout(() => {\n setIsRefreshing(false);\n setIsPulling(false);\n setPullDistance(0);\n }, 1000);\n } catch (error) {\n setIsRefreshing(false);\n setIsPulling(false);\n setPullDistance(0);\n }\n }, [outletId]);\n\n const handleTouchStart = useCallback((e: TouchEvent) => {\n if (containerRef.current && containerRef.current.scrollTop === 0) {\n touchStartY.current = e.touches[0].clientY;\n setIsPulling(true);\n }\n }, []);\n\n const handleTouchMove = useCallback((e: TouchEvent) => {\n if (!isPulling || isRefreshing) return;\n \n touchCurrentY.current = e.touches[0].clientY;\n const distance = touchCurrentY.current - touchStartY.current;\n \n if (distance > 0) {\n e.preventDefault();\n const pullDist = Math.min(distance * 0.5, 80); // Max pull distance of 80px\n setPullDistance(pullDist);\n }\n }, [isPulling, isRefreshing]);\n\n const handleTouchEnd = useCallback(() => {\n if (pullDistance > 60 && !isRefreshing) {\n handleRefresh();\n } else {\n setIsPulling(false);\n setPullDistance(0);\n }\n }, [pullDistance, isRefreshing, handleRefresh]);\n\n // Add touch event listeners\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n\n container.addEventListener('touchstart', handleTouchStart, { passive: false });\n container.addEventListener('touchmove', handleTouchMove, { passive: false });\n container.addEventListener('touchend', handleTouchEnd, { passive: false });\n\n return () => {\n container.removeEventListener('touchstart', handleTouchStart);\n container.removeEventListener('touchmove', handleTouchMove);\n container.removeEventListener('touchend', handleTouchEnd);\n };\n }, [handleTouchStart, handleTouchMove, handleTouchEnd]);\n\n // Type-safe outlet data\n const outletData = outlet as any;\n const articlesData = articles as any[];\n\n\n // Show loading state\n if (outletLoading) {\n return (\n <div className=\"min-h-screen bg-background flex items-center justify-center\">\n <div className=\"text-center space-y-2\">\n <p className=\"text-muted-foreground\">Loading</p>\n </div>\n </div>\n );\n }\n\n // Show not found state\n if (!outlet) {\n return (\n <div className=\"min-h-screen bg-background flex items-center justify-center\">\n <div className=\"text-center space-y-2\">\n <h1 className=\"text-xl font-semibold\">Outlet not found</h1>\n <p className=\"text-muted-foreground\">The requested outlet does not exist.</p>\n </div>\n </div>\n );\n }\n\n const handleSearch = (query: string) => {\n setSearchQuery(query);\n };\n\n const handleTitleClick = () => {\n // Navigate to home with the outlet's category selected\n const category = outletData?.category || 'people';\n setLocation(`/?category=${category}`);\n };\n\n const handleBackClick = () => {\n // Navigate back to home with the outlet's category selected\n const category = outletData?.category || 'people';\n setLocation(`/?category=${category}`);\n };\n\n return (\n <div className=\"min-h-screen bg-background overflow-hidden\">\n {/* Main content with slide transition */}\n <div className={`transition-transform duration-300 ease-out ${isSearchOpen ? '-translate-x-full' : 'translate-x-0'}`}>\n <div \n ref={containerRef}\n className=\"overflow-y-auto h-screen relative\"\n style={{\n transform: `translateY(${isPulling || isRefreshing ? pullDistance : 0}px)`,\n transition: isPulling ? 'none' : 'transform 0.3s ease-out'\n }}\n >\n {/* Pull-to-refresh indicator */}\n {(isPulling || isRefreshing) && (\n <div \n className=\"absolute top-0 left-0 right-0 flex items-center justify-center bg-background z-50\"\n style={{ \n height: `${Math.max(pullDistance, isRefreshing ? 60 : 0)}px`,\n transform: `translateY(-${Math.max(pullDistance, isRefreshing ? 60 : 0)}px)`\n }}\n >\n <div className=\"flex items-center gap-2 text-muted-foreground\">\n <Loader2 className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />\n <span className=\"text-sm\">\n {isRefreshing ? '새로고침 중...' : pullDistance > 60 ? '놓으면 새로고침' : '아래로 당겨서 새로고침'}\n </span>\n </div>\n </div>\n )}\n\n {/* Fixed Profile Header - now at top */}\n <div className=\"sticky top-0 z-40\">\n <OutletProfile \n {...outletData}\n wikiProfile={outletData?.wikiProfile}\n isSticky={true}\n onSearchClick={() => setIsSearchOpen(true)}\n selectedLanguage={selectedLanguage}\n onLanguageChange={setSelectedLanguage}\n onLoginClick={() => console.log('Login clicked')}\n />\n </div>\n\n <div className=\"pb-20\">\n {/* Articles feed */}\n <section className=\"px-4 pt-4 space-y-4\">\n \n {/* View mode toggle */}\n <div className=\"flex items-center justify-between\">\n <h2 className=\"text-lg font-semibold\">Latest Articles</h2>\n <div className=\"flex items-center gap-1\">\n <Button\n variant={viewMode === 'card' ? 'default' : 'ghost'}\n size=\"sm\"\n onClick={() => setViewMode('card')}\n data-testid=\"button-card-view\"\n >\n <Grid3X3 className=\"h-4 w-4\" />\n </Button>\n <Button\n variant={viewMode === 'list' ? 'default' : 'ghost'}\n size=\"sm\"\n onClick={() => setViewMode('list')}\n data-testid=\"button-list-view\"\n >\n <List className=\"h-4 w-4\" />\n </Button>\n </div>\n </div>\n \n {articlesLoading ? (\n <div className=\"text-center py-8\">\n <p className=\"text-muted-foreground\">Loading articles...</p>\n </div>\n ) : (\n <div className={viewMode === 'list' ? 'space-y-2' : 'space-y-4'}>\n {(() => {\n // For Palantir outlet, mix ads with articles like YouTube\n if (outletId === 'palantir') {\n const regularArticles = articlesData.filter((article: any) => article.isScraped !== 2);\n const ads = articlesData.filter((article: any) => article.isScraped === 2);\n const articlesWithAds = [];\n \n let adIndex = 0;\n \n // Insert articles and ads: add an ad after every 3 articles\n for (let i = 0; i < regularArticles.length; i++) {\n articlesWithAds.push({\n ...regularArticles[i],\n isAd: false\n });\n \n // Insert ad after every 3 articles (and we have ads available)\n if ((i + 1) % 3 === 0 && ads.length > 0) {\n articlesWithAds.push({\n ...ads[adIndex % ads.length],\n isAd: true\n });\n adIndex++;\n }\n }\n \n return articlesWithAds.map((item: any, index: number) => (\n <ArticleCard\n key={item.id}\n id={item.id}\n title={item.title}\n summary={item.summary}\n thumbnail={item.thumbnail}\n outletName={outletData?.name || 'Unknown Outlet'}\n category={outletData?.category || 'uncategorized'}\n publishedAt={new Date(item.publishedAt)}\n timeAgo={item.timeAgo}\n onClick={(id) => {\n if (item.isAd) {\n // For ads, open external URL instead of navigating to article\n const newWindow = window.open('https://investing.com', '_blank', 'noopener,noreferrer');\n if (newWindow) newWindow.opener = null;\n } else {\n setLocation(`/article/${id}`);\n }\n }}\n className=\"w-full\"\n isAd={item.isAd}\n tags={item.tags || []}\n index={index}\n variant={viewMode}\n />\n ));\n } else {\n // For other outlets, show articles normally\n return articlesData.map((article: any, index: number) => (\n <ArticleCard\n key={article.id}\n id={article.id}\n title={article.title}\n summary={article.summary}\n thumbnail={article.thumbnail}\n outletName={outletData?.name || 'Unknown Outlet'}\n category={outletData?.category || 'uncategorized'}\n publishedAt={new Date(article.publishedAt)}\n timeAgo={article.timeAgo}\n onClick={(id) => setLocation(`/article/${id}`)}\n className=\"w-full\"\n tags={article.tags || []}\n index={index}\n variant={viewMode}\n />\n ));\n }\n })()}\n </div>\n )}\n \n {articlesData.length === 0 && !articlesLoading && (\n <div className=\"text-center py-8\">\n <p className=\"text-muted-foreground\">No articles available yet.</p>\n </div>\n )}\n </section>\n </div>\n </div>\n </div>\n\n {/* Bottom Navigation Bar - Always fixed at bottom */}\n <BottomTabBar variant=\"nav\" onTitleClick={handleTitleClick} onBackClick={handleBackClick} />\n \n {/* Search Overlay positioned to slide in from right */}\n <SearchOverlay\n isOpen={isSearchOpen}\n onClose={() => setIsSearchOpen(false)}\n onArticleClick={(id) => setLocation(`/article/${id}`)}\n searchResults={searchResults as any}\n onSearch={handleSearch}\n />\n </div>\n );\n}","size_bytes":12623},"client/src/pages/not-found.tsx":{"content":"import { Card, CardContent } from \"@/components/ui/card\";\nimport { AlertCircle } from \"lucide-react\";\n\nexport default function NotFound() {\n return (\n <div className=\"min-h-screen w-full flex items-center justify-center bg-gray-50\">\n <Card className=\"w-full max-w-md mx-4\">\n <CardContent className=\"pt-6\">\n <div className=\"flex mb-4 gap-2\">\n <AlertCircle className=\"h-8 w-8 text-red-500\" />\n <h1 className=\"text-2xl font-bold text-gray-900\">404 Page Not Found</h1>\n </div>\n\n <p className=\"mt-4 text-sm text-gray-600\">\n Did you forget to add the page to the router?\n </p>\n </CardContent>\n </Card>\n </div>\n );\n}\n","size_bytes":711},"client/src/components/examples/ArticleCard.tsx":{"content":"import ArticleCard from '../ArticleCard';\n\nexport default function ArticleCardExample() {\n return (\n <div className=\"p-4 max-w-sm\">\n <ArticleCard\n id=\"1\"\n title=\"Jacob Robert Steeves to Speak at dAI Conference in Seoul: 'The Future of Decentralized AI'\"\n summary=\"Jacob Robert Steeves, co-founder of Bittensor, will be a featured speaker at the dAI Conference in Seoul on September 24, 2025.\"\n thumbnail=\"/placeholder-image.jpg\"\n publishedAt={new Date('2024-09-22T09:30:00')}\n outletName=\"Bittensor Focus\"\n category=\"People\"\n onClick={(id) => console.log('Article clicked:', id)}\n />\n </div>\n );\n}","size_bytes":671},"client/src/components/examples/ArticleDetail.tsx":{"content":"import ArticleDetail from '../ArticleDetail';\n\nexport default function ArticleDetailExample() {\n return (\n <div className=\"p-4\">\n <ArticleDetail\n id=\"1\"\n title=\"Jacob Robert Steeves to Speak at dAI Conference in Seoul: 'The Future of Decentralized AI'\"\n summary=\"Jacob Robert Steeves, co-founder of Bittensor, will be a featured speaker at the dAI Conference in Seoul on September 24, 2025. He is expected to discuss decentralized AI, the TAO token economy, and fairness in the global AI ecosystem.\"\n body=\"SEOUL — Jacob Robert Steeves, co-founder of the blockchain-based decentralized AI project Bittensor, will deliver a keynote at the upcoming dAI Conference in Seoul on September 24, 2025. His talk, entitled 'The Future of Decentralized AI,' will explore how Bittensor's open network challenges today's centralized artificial intelligence structures and expands opportunities for contributors worldwide.\\n\\nSteeves is expected to outline three major themes in his address: The Present and Future of Decentralized AI — including the role of subnets, Yuma Consensus, and new methods of contribution. The TAO Token Economy — how mining, staking, and community governance sustain participation and align incentives. Fairness in the Global AI Ecosystem — offering alternatives to corporate concentration of power and broadening access for independent developers and researchers.\\n\\nConference organizers emphasized the significance of Steeves's participation. 'His insights are crucial for the AI and blockchain communities in Korea and across Asia,' said one organizer. 'Bittensor's Dynamic TAO upgrade, which grants token holders greater influence over the network's future, has already captured strong local interest.'\"\n thumbnail=\"/placeholder-image.jpg\"\n publishedAt={new Date('2024-09-22T09:30:00')}\n outletName=\"Bittensor Focus\"\n category=\"People\"\n tags={[\"Bittensor\", \"AI\", \"Blockchain\", \"Seoul\", \"Conference\"]}\n onPreviousArticle={() => console.log('Previous article')}\n onNextArticle={() => console.log('Next article')}\n hasPrevious={true}\n hasNext={true}\n />\n </div>\n );\n}","size_bytes":2200},"client/src/components/examples/CategoryTabs.tsx":{"content":"import CategoryTabs from '../CategoryTabs';\n\nexport default function CategoryTabsExample() {\n const categories = [\n { id: 'all', name: 'All', count: 15 },\n { id: 'people', name: 'People', count: 6 },\n { id: 'topics', name: 'Topics', count: 4 },\n { id: 'companies', name: 'Companies', count: 5 },\n ];\n\n return (\n <CategoryTabs\n categories={categories}\n activeCategory=\"all\"\n onCategoryChange={(id) => console.log('Category changed to:', id)}\n />\n );\n}","size_bytes":487},"client/src/components/examples/MobileHeader.tsx":{"content":"import MobileHeader from '../MobileHeader';\n\nexport default function MobileHeaderExample() {\n return (\n <MobileHeader\n onSearchClick={() => console.log('Search clicked')}\n selectedLanguage=\"en\"\n onLanguageChange={(lang) => console.log('Language changed to:', lang)}\n onProfileClick={() => console.log('Profile clicked')}\n />\n );\n}","size_bytes":358},"client/src/components/examples/OutletCard.tsx":{"content":"import OutletCard from '../OutletCard';\n\nexport default function OutletCardExample() {\n return (\n <div className=\"p-4 max-w-md\">\n <OutletCard\n id=\"jacob-focus\"\n name=\"Bittensor Focus\"\n description=\"Dedicated coverage of Jacob Robert Steeves and Bittensor's journey in decentralized AI\"\n category=\"people\"\n focusSubject=\"Jacob Robert Steeves\"\n articleCount={8}\n onClick={(id) => console.log('Outlet clicked:', id)}\n />\n </div>\n );\n}","size_bytes":498},"client/src/components/examples/OutletProfile.tsx":{"content":"import OutletProfile from '../OutletProfile';\n\nexport default function OutletProfileExample() {\n return (\n <div className=\"p-4 max-w-md space-y-4\">\n <OutletProfile\n name=\"Bittensor Focus\"\n description=\"Dedicated coverage of Jacob Robert Steeves and Bittensor's journey in decentralized AI\"\n category=\"people\"\n focusSubject=\"Jacob Robert Steeves\"\n bio=\"Co-Founder of Bittensor, a decentralized, blockchain-based AI network pioneering the concept of a 'neural internet.' Holds a Bachelor of Applied Science in Mathematics & Computer Science from Simon Fraser University. Former Engineer at Google, with experience in large-scale computing systems and applied AI.\"\n articleCount={8}\n />\n \n <div className=\"border-t pt-4\">\n <h3 className=\"font-semibold mb-2\">Sticky Header Version:</h3>\n <OutletProfile\n name=\"Bittensor Focus\"\n description=\"Dedicated coverage of Jacob Robert Steeves and Bittensor's journey in decentralized AI\"\n category=\"people\"\n focusSubject=\"Jacob Robert Steeves\"\n bio=\"\"\n articleCount={8}\n isSticky={true}\n />\n </div>\n </div>\n );\n}","size_bytes":1208},"client/src/components/examples/SearchOverlay.tsx":{"content":"import { useState } from 'react';\nimport SearchOverlay from '../SearchOverlay';\nimport { Button } from '@/components/ui/button';\n\nexport default function SearchOverlayExample() {\n const [isOpen, setIsOpen] = useState(false);\n \n const mockResults = [\n {\n id: '1',\n title: 'Jacob Robert Steeves to Speak at dAI Conference in Seoul',\n summary: 'Co-founder of Bittensor will discuss decentralized AI and the TAO token economy.',\n category: 'People',\n outletName: 'Bittensor Focus',\n publishedAt: new Date('2024-09-22T09:30:00'),\n },\n {\n id: '2',\n title: 'Alpha Sigma Capital Publishes Report on Bittensor',\n summary: 'In-depth analysis of the neural internet model and market implications.',\n category: 'Topics',\n outletName: 'Crypto Insights',\n publishedAt: new Date('2024-09-22T11:15:00'),\n },\n ];\n\n return (\n <div className=\"p-4\">\n <Button onClick={() => setIsOpen(true)}>Open Search</Button>\n <SearchOverlay\n isOpen={isOpen}\n onClose={() => setIsOpen(false)}\n onArticleClick={(id) => console.log('Article clicked:', id)}\n searchResults={mockResults}\n onSearch={(query) => console.log('Search query:', query)}\n />\n </div>\n );\n}","size_bytes":1260},"client/src/components/examples/SwipeableCarousel.tsx":{"content":"import SwipeableCarousel from '../SwipeableCarousel';\nimport ArticleCard from '../ArticleCard';\n\nexport default function SwipeableCarouselExample() {\n const items = [\n {\n id: '1',\n content: (\n <ArticleCard\n id=\"1\"\n title=\"Jacob Robert Steeves to Speak at dAI Conference in Seoul\"\n summary=\"Co-founder of Bittensor will discuss decentralized AI and the TAO token economy.\"\n thumbnail=\"/placeholder-image.jpg\"\n publishedAt={new Date('2024-09-22T09:30:00')}\n outletName=\"Bittensor Focus\"\n category=\"People\"\n onClick={(id) => console.log('Article clicked:', id)}\n />\n ),\n },\n {\n id: '2',\n content: (\n <ArticleCard\n id=\"2\"\n title=\"Alpha Sigma Capital Publishes Report on Bittensor\"\n summary=\"In-depth analysis of the neural internet model and market implications.\"\n thumbnail=\"/placeholder-image.jpg\"\n publishedAt={new Date('2024-09-22T11:15:00')}\n outletName=\"Crypto Insights\"\n category=\"Topics\"\n onClick={(id) => console.log('Article clicked:', id)}\n />\n ),\n },\n {\n id: '3',\n content: (\n <ArticleCard\n id=\"3\"\n title=\"TAO Token Prepares for First Halving\"\n summary=\"Supply reduction event scheduled for late 2025 could impact value.\"\n thumbnail=\"/placeholder-image.jpg\"\n publishedAt={new Date('2024-09-22T14:45:00')}\n outletName=\"AI Weekly\"\n category=\"Topics\"\n onClick={(id) => console.log('Article clicked:', id)}\n />\n ),\n },\n ];\n\n return (\n <div className=\"py-4\">\n <SwipeableCarousel items={items} />\n </div>\n );\n}","size_bytes":1748},"client/src/components/ui/accordion.tsx":{"content":"import * as React from \"react\"\nimport * as AccordionPrimitive from \"@radix-ui/react-accordion\"\nimport { ChevronDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Accordion = AccordionPrimitive.Root\n\nconst AccordionItem = React.forwardRef<\n React.ElementRef<typeof AccordionPrimitive.Item>,\n React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>\n>(({ className, ...props }, ref) => (\n <AccordionPrimitive.Item\n ref={ref}\n className={cn(\"border-b\", className)}\n {...props}\n />\n))\nAccordionItem.displayName = \"AccordionItem\"\n\nconst AccordionTrigger = React.forwardRef<\n React.ElementRef<typeof AccordionPrimitive.Trigger>,\n React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n <AccordionPrimitive.Header className=\"flex\">\n <AccordionPrimitive.Trigger\n ref={ref}\n className={cn(\n \"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180\",\n className\n )}\n {...props}\n >\n {children}\n <ChevronDown className=\"h-4 w-4 shrink-0 transition-transform duration-200\" />\n </AccordionPrimitive.Trigger>\n </AccordionPrimitive.Header>\n))\nAccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName\n\nconst AccordionContent = React.forwardRef<\n React.ElementRef<typeof AccordionPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n <AccordionPrimitive.Content\n ref={ref}\n className=\"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\"\n {...props}\n >\n <div className={cn(\"pb-4 pt-0\", className)}>{children}</div>\n </AccordionPrimitive.Content>\n))\n\nAccordionContent.displayName = AccordionPrimitive.Content.displayName\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent }\n","size_bytes":1977},"client/src/components/ui/alert-dialog.tsx":{"content":"import * as React from \"react\"\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\"\n\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \"@/components/ui/button\"\n\nconst AlertDialog = AlertDialogPrimitive.Root\n\nconst AlertDialogTrigger = AlertDialogPrimitive.Trigger\n\nconst AlertDialogPortal = AlertDialogPrimitive.Portal\n\nconst AlertDialogOverlay = React.forwardRef<\n React.ElementRef<typeof AlertDialogPrimitive.Overlay>,\n React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n <AlertDialogPrimitive.Overlay\n className={cn(\n \"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n className\n )}\n {...props}\n ref={ref}\n />\n))\nAlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName\n\nconst AlertDialogContent = React.forwardRef<\n React.ElementRef<typeof AlertDialogPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>\n>(({ className, ...props }, ref) => (\n <AlertDialogPortal>\n <AlertDialogOverlay />\n <AlertDialogPrimitive.Content\n ref={ref}\n className={cn(\n \"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n className\n )}\n {...props}\n />\n </AlertDialogPortal>\n))\nAlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName\n\nconst AlertDialogHeader = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n <div\n className={cn(\n \"flex flex-col space-y-2 text-center sm:text-left\",\n className\n )}\n {...props}\n />\n)\nAlertDialogHeader.displayName = \"AlertDialogHeader\"\n\nconst AlertDialogFooter = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n <div\n className={cn(\n \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n className\n )}\n {...props}\n />\n)\nAlertDialogFooter.displayName = \"AlertDialogFooter\"\n\nconst AlertDialogTitle = React.forwardRef<\n React.ElementRef<typeof AlertDialogPrimitive.Title>,\n React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n <AlertDialogPrimitive.Title\n ref={ref}\n className={cn(\"text-lg font-semibold\", className)}\n {...props}\n />\n))\nAlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName\n\nconst AlertDialogDescription = React.forwardRef<\n React.ElementRef<typeof AlertDialogPrimitive.Description>,\n React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n <AlertDialogPrimitive.Description\n ref={ref}\n className={cn(\"text-sm text-muted-foreground\", className)}\n {...props}\n />\n))\nAlertDialogDescription.displayName =\n AlertDialogPrimitive.Description.displayName\n\nconst AlertDialogAction = React.forwardRef<\n React.ElementRef<typeof AlertDialogPrimitive.Action>,\n React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>\n>(({ className, ...props }, ref) => (\n <AlertDialogPrimitive.Action\n ref={ref}\n className={cn(buttonVariants(), className)}\n {...props}\n />\n))\nAlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName\n\nconst AlertDialogCancel = React.forwardRef<\n React.ElementRef<typeof AlertDialogPrimitive.Cancel>,\n React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>\n>(({ className, ...props }, ref) => (\n <AlertDialogPrimitive.Cancel\n ref={ref}\n className={cn(\n buttonVariants({ variant: \"outline\" }),\n \"mt-2 sm:mt-0\",\n className\n )}\n {...props}\n />\n))\nAlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName\n\nexport {\n AlertDialog,\n AlertDialogPortal,\n AlertDialogOverlay,\n AlertDialogTrigger,\n AlertDialogContent,\n AlertDialogHeader,\n AlertDialogFooter,\n AlertDialogTitle,\n AlertDialogDescription,\n AlertDialogAction,\n AlertDialogCancel,\n}\n","size_bytes":4420},"client/src/components/ui/alert.tsx":{"content":"import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst alertVariants = cva(\n \"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground\",\n {\n variants: {\n variant: {\n default: \"bg-background text-foreground\",\n destructive:\n \"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n }\n)\n\nconst Alert = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>\n>(({ className, variant, ...props }, ref) => (\n <div\n ref={ref}\n role=\"alert\"\n className={cn(alertVariants({ variant }), className)}\n {...props}\n />\n))\nAlert.displayName = \"Alert\"\n\nconst AlertTitle = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n <h5\n ref={ref}\n className={cn(\"mb-1 font-medium leading-none tracking-tight\", className)}\n {...props}\n />\n))\nAlertTitle.displayName = \"AlertTitle\"\n\nconst AlertDescription = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn(\"text-sm [&_p]:leading-relaxed\", className)}\n {...props}\n />\n))\nAlertDescription.displayName = \"AlertDescription\"\n\nexport { Alert, AlertTitle, AlertDescription }\n","size_bytes":1584},"client/src/components/ui/aspect-ratio.tsx":{"content":"import * as AspectRatioPrimitive from \"@radix-ui/react-aspect-ratio\"\n\nconst AspectRatio = AspectRatioPrimitive.Root\n\nexport { AspectRatio }\n","size_bytes":140},"client/src/components/ui/avatar.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport * as AvatarPrimitive from \"@radix-ui/react-avatar\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Avatar = React.forwardRef<\n React.ElementRef<typeof AvatarPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>\n>(({ className, ...props }, ref) => (\n <AvatarPrimitive.Root\n ref={ref}\n className={cn(`\n after:content-[''] after:block after:absolute after:inset-0 after:rounded-full after:pointer-events-none after:border after:border-black/10 dark:after:border-white/10\n relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full`,\n className\n )}\n {...props}\n />\n))\nAvatar.displayName = AvatarPrimitive.Root.displayName\n\nconst AvatarImage = React.forwardRef<\n React.ElementRef<typeof AvatarPrimitive.Image>,\n React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>\n>(({ className, ...props }, ref) => (\n <AvatarPrimitive.Image\n ref={ref}\n className={cn(\"aspect-square h-full w-full object-cover\", className)}\n {...props}\n />\n))\nAvatarImage.displayName = AvatarPrimitive.Image.displayName\n\nconst AvatarFallback = React.forwardRef<\n React.ElementRef<typeof AvatarPrimitive.Fallback>,\n React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>\n>(({ className, ...props }, ref) => (\n <AvatarPrimitive.Fallback\n ref={ref}\n className={cn(\n \"flex h-full w-full items-center justify-center rounded-full bg-muted\",\n className\n )}\n {...props}\n />\n))\nAvatarFallback.displayName = AvatarPrimitive.Fallback.displayName\n\nexport { Avatar, AvatarImage, AvatarFallback }\n","size_bytes":1605},"client/src/components/ui/badge.tsx":{"content":"import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst badgeVariants = cva(\n // Whitespace-nowrap: Badges should never wrap.\n \"whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\" +\n \" hover-elevate \" ,\n {\n variants: {\n variant: {\n default:\n \"border-transparent bg-primary text-primary-foreground shadow-xs\",\n secondary: \"border-transparent bg-secondary text-secondary-foreground\",\n destructive:\n \"border-transparent bg-destructive text-destructive-foreground shadow-xs\",\n\n outline: \" border [border-color:var(--badge-outline)] shadow-xs\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n },\n)\n\nexport interface BadgeProps\n extends React.HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof badgeVariants> {}\n\nfunction Badge({ className, variant, ...props }: BadgeProps) {\n return (\n <div className={cn(badgeVariants({ variant }), className)} {...props} />\n );\n}\n\nexport { Badge, badgeVariants }\n","size_bytes":1202},"client/src/components/ui/breadcrumb.tsx":{"content":"import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { ChevronRight, MoreHorizontal } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Breadcrumb = React.forwardRef<\n HTMLElement,\n React.ComponentPropsWithoutRef<\"nav\"> & {\n separator?: React.ReactNode\n }\n>(({ ...props }, ref) => <nav ref={ref} aria-label=\"breadcrumb\" {...props} />)\nBreadcrumb.displayName = \"Breadcrumb\"\n\nconst BreadcrumbList = React.forwardRef<\n HTMLOListElement,\n React.ComponentPropsWithoutRef<\"ol\">\n>(({ className, ...props }, ref) => (\n <ol\n ref={ref}\n className={cn(\n \"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5\",\n className\n )}\n {...props}\n />\n))\nBreadcrumbList.displayName = \"BreadcrumbList\"\n\nconst BreadcrumbItem = React.forwardRef<\n HTMLLIElement,\n React.ComponentPropsWithoutRef<\"li\">\n>(({ className, ...props }, ref) => (\n <li\n ref={ref}\n className={cn(\"inline-flex items-center gap-1.5\", className)}\n {...props}\n />\n))\nBreadcrumbItem.displayName = \"BreadcrumbItem\"\n\nconst BreadcrumbLink = React.forwardRef<\n HTMLAnchorElement,\n React.ComponentPropsWithoutRef<\"a\"> & {\n asChild?: boolean\n }\n>(({ asChild, className, ...props }, ref) => {\n const Comp = asChild ? Slot : \"a\"\n\n return (\n <Comp\n ref={ref}\n className={cn(\"transition-colors hover:text-foreground\", className)}\n {...props}\n />\n )\n})\nBreadcrumbLink.displayName = \"BreadcrumbLink\"\n\nconst BreadcrumbPage = React.forwardRef<\n HTMLSpanElement,\n React.ComponentPropsWithoutRef<\"span\">\n>(({ className, ...props }, ref) => (\n <span\n ref={ref}\n role=\"link\"\n aria-disabled=\"true\"\n aria-current=\"page\"\n className={cn(\"font-normal text-foreground\", className)}\n {...props}\n />\n))\nBreadcrumbPage.displayName = \"BreadcrumbPage\"\n\nconst BreadcrumbSeparator = ({\n children,\n className,\n ...props\n}: React.ComponentProps<\"li\">) => (\n <li\n role=\"presentation\"\n aria-hidden=\"true\"\n className={cn(\"[&>svg]:w-3.5 [&>svg]:h-3.5\", className)}\n {...props}\n >\n {children ?? <ChevronRight />}\n </li>\n)\nBreadcrumbSeparator.displayName = \"BreadcrumbSeparator\"\n\nconst BreadcrumbEllipsis = ({\n className,\n ...props\n}: React.ComponentProps<\"span\">) => (\n <span\n role=\"presentation\"\n aria-hidden=\"true\"\n className={cn(\"flex h-9 w-9 items-center justify-center\", className)}\n {...props}\n >\n <MoreHorizontal className=\"h-4 w-4\" />\n <span className=\"sr-only\">More</span>\n </span>\n)\nBreadcrumbEllipsis.displayName = \"BreadcrumbElipssis\"\n\nexport {\n Breadcrumb,\n BreadcrumbList,\n BreadcrumbItem,\n BreadcrumbLink,\n BreadcrumbPage,\n BreadcrumbSeparator,\n BreadcrumbEllipsis,\n}\n","size_bytes":2712},"client/src/components/ui/button.tsx":{"content":"import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\" +\n \" hover-elevate active-elevate-2\",\n {\n variants: {\n variant: {\n default:\n \"bg-primary text-primary-foreground border border-primary-border\",\n destructive:\n \"bg-destructive text-destructive-foreground border border-destructive-border\",\n outline:\n // Shows the background color of whatever card / sidebar / accent background it is inside of.\n // Inherits the current text color.\n \" border [border-color:var(--button-outline)] shadow-xs active:shadow-none \",\n secondary: \"border bg-secondary text-secondary-foreground border border-secondary-border \",\n // Add a transparent border so that when someone toggles a border on later, it doesn't shift layout/size.\n ghost: \"border border-transparent\",\n },\n // Heights are set as \"min\" heights, because sometimes Ai will place large amount of content\n // inside buttons. With a min-height they will look appropriate with small amounts of content,\n // but will expand to fit large amounts of content.\n size: {\n default: \"min-h-9 px-4 py-2\",\n sm: \"min-h-8 rounded-md px-3 text-xs\",\n lg: \"min-h-10 rounded-md px-8\",\n icon: \"h-9 w-9\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n },\n)\n\nexport interface ButtonProps\n extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n VariantProps<typeof buttonVariants> {\n asChild?: boolean\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n ({ className, variant, size, asChild = false, ...props }, ref) => {\n const Comp = asChild ? Slot : \"button\"\n return (\n <Comp\n className={cn(buttonVariants({ variant, size, className }))}\n ref={ref}\n {...props}\n />\n )\n },\n)\nButton.displayName = \"Button\"\n\nexport { Button, buttonVariants }\n","size_bytes":2359},"client/src/components/ui/calendar.tsx":{"content":"import * as React from \"react\"\nimport { ChevronLeft, ChevronRight } from \"lucide-react\"\nimport { DayPicker } from \"react-day-picker\"\n\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \"@/components/ui/button\"\n\nexport type CalendarProps = React.ComponentProps<typeof DayPicker>\n\nfunction Calendar({\n className,\n classNames,\n showOutsideDays = true,\n ...props\n}: CalendarProps) {\n return (\n <DayPicker\n showOutsideDays={showOutsideDays}\n className={cn(\"p-3\", className)}\n classNames={{\n months: \"flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0\",\n month: \"space-y-4\",\n caption: \"flex justify-center pt-1 relative items-center\",\n caption_label: \"text-sm font-medium\",\n nav: \"space-x-1 flex items-center\",\n nav_button: cn(\n buttonVariants({ variant: \"outline\" }),\n \"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100\"\n ),\n nav_button_previous: \"absolute left-1\",\n nav_button_next: \"absolute right-1\",\n table: \"w-full border-collapse space-y-1\",\n head_row: \"flex\",\n head_cell:\n \"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]\",\n row: \"flex w-full mt-2\",\n cell: \"h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20\",\n day: cn(\n buttonVariants({ variant: \"ghost\" }),\n \"h-9 w-9 p-0 font-normal aria-selected:opacity-100\"\n ),\n day_range_end: \"day-range-end\",\n day_selected:\n \"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground\",\n day_today: \"bg-accent text-accent-foreground\",\n day_outside:\n \"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground\",\n day_disabled: \"text-muted-foreground opacity-50\",\n day_range_middle:\n \"aria-selected:bg-accent aria-selected:text-accent-foreground\",\n day_hidden: \"invisible\",\n ...classNames,\n }}\n components={{\n IconLeft: ({ className, ...props }) => (\n <ChevronLeft className={cn(\"h-4 w-4\", className)} {...props} />\n ),\n IconRight: ({ className, ...props }) => (\n <ChevronRight className={cn(\"h-4 w-4\", className)} {...props} />\n ),\n }}\n {...props}\n />\n )\n}\nCalendar.displayName = \"Calendar\"\n\nexport { Calendar }\n","size_bytes":2695},"client/src/components/ui/card.tsx":{"content":"import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Card = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn(\n \"shadcn-card rounded-xl border bg-card border-card-border text-card-foreground shadow-sm\",\n className\n )}\n {...props}\n />\n));\nCard.displayName = \"Card\"\n\nconst CardHeader = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn(\"flex flex-col space-y-1.5 p-6\", className)}\n {...props}\n />\n));\nCardHeader.displayName = \"CardHeader\"\n\nconst CardTitle = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn(\n \"text-2xl font-semibold leading-none tracking-tight\",\n className\n )}\n {...props}\n />\n))\nCardTitle.displayName = \"CardTitle\"\n\nconst CardDescription = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn(\"text-sm text-muted-foreground\", className)}\n {...props}\n />\n));\nCardDescription.displayName = \"CardDescription\"\n\nconst CardContent = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n <div ref={ref} className={cn(\"p-6 pt-0\", className)} {...props} />\n))\nCardContent.displayName = \"CardContent\"\n\nconst CardFooter = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn(\"flex items-center p-6 pt-0\", className)}\n {...props}\n />\n))\nCardFooter.displayName = \"CardFooter\"\nexport {\n Card,\n CardHeader,\n CardFooter,\n CardTitle,\n CardDescription,\n CardContent,\n}\n","size_bytes":1904},"client/src/components/ui/carousel.tsx":{"content":"import * as React from \"react\"\nimport useEmblaCarousel, {\n type UseEmblaCarouselType,\n} from \"embla-carousel-react\"\nimport { ArrowLeft, ArrowRight } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\ntype CarouselApi = UseEmblaCarouselType[1]\ntype UseCarouselParameters = Parameters<typeof useEmblaCarousel>\ntype CarouselOptions = UseCarouselParameters[0]\ntype CarouselPlugin = UseCarouselParameters[1]\n\ntype CarouselProps = {\n opts?: CarouselOptions\n plugins?: CarouselPlugin\n orientation?: \"horizontal\" | \"vertical\"\n setApi?: (api: CarouselApi) => void\n}\n\ntype CarouselContextProps = {\n carouselRef: ReturnType<typeof useEmblaCarousel>[0]\n api: ReturnType<typeof useEmblaCarousel>[1]\n scrollPrev: () => void\n scrollNext: () => void\n canScrollPrev: boolean\n canScrollNext: boolean\n} & CarouselProps\n\nconst CarouselContext = React.createContext<CarouselContextProps | null>(null)\n\nfunction useCarousel() {\n const context = React.useContext(CarouselContext)\n\n if (!context) {\n throw new Error(\"useCarousel must be used within a <Carousel />\")\n }\n\n return context\n}\n\nconst Carousel = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement> & CarouselProps\n>(\n (\n {\n orientation = \"horizontal\",\n opts,\n setApi,\n plugins,\n className,\n children,\n ...props\n },\n ref\n ) => {\n const [carouselRef, api] = useEmblaCarousel(\n {\n ...opts,\n axis: orientation === \"horizontal\" ? \"x\" : \"y\",\n },\n plugins\n )\n const [canScrollPrev, setCanScrollPrev] = React.useState(false)\n const [canScrollNext, setCanScrollNext] = React.useState(false)\n\n const onSelect = React.useCallback((api: CarouselApi) => {\n if (!api) {\n return\n }\n\n setCanScrollPrev(api.canScrollPrev())\n setCanScrollNext(api.canScrollNext())\n }, [])\n\n const scrollPrev = React.useCallback(() => {\n api?.scrollPrev()\n }, [api])\n\n const scrollNext = React.useCallback(() => {\n api?.scrollNext()\n }, [api])\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (event.key === \"ArrowLeft\") {\n event.preventDefault()\n scrollPrev()\n } else if (event.key === \"ArrowRight\") {\n event.preventDefault()\n scrollNext()\n }\n },\n [scrollPrev, scrollNext]\n )\n\n React.useEffect(() => {\n if (!api || !setApi) {\n return\n }\n\n setApi(api)\n }, [api, setApi])\n\n React.useEffect(() => {\n if (!api) {\n return\n }\n\n onSelect(api)\n api.on(\"reInit\", onSelect)\n api.on(\"select\", onSelect)\n\n return () => {\n api?.off(\"select\", onSelect)\n }\n }, [api, onSelect])\n\n return (\n <CarouselContext.Provider\n value={{\n carouselRef,\n api: api,\n opts,\n orientation:\n orientation || (opts?.axis === \"y\" ? \"vertical\" : \"horizontal\"),\n scrollPrev,\n scrollNext,\n canScrollPrev,\n canScrollNext,\n }}\n >\n <div\n ref={ref}\n onKeyDownCapture={handleKeyDown}\n className={cn(\"relative\", className)}\n role=\"region\"\n aria-roledescription=\"carousel\"\n {...props}\n >\n {children}\n </div>\n </CarouselContext.Provider>\n )\n }\n)\nCarousel.displayName = \"Carousel\"\n\nconst CarouselContent = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n const { carouselRef, orientation } = useCarousel()\n\n return (\n <div ref={carouselRef} className=\"overflow-hidden\">\n <div\n ref={ref}\n className={cn(\n \"flex\",\n orientation === \"horizontal\" ? \"-ml-4\" : \"-mt-4 flex-col\",\n className\n )}\n {...props}\n />\n </div>\n )\n})\nCarouselContent.displayName = \"CarouselContent\"\n\nconst CarouselItem = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n const { orientation } = useCarousel()\n\n return (\n <div\n ref={ref}\n role=\"group\"\n aria-roledescription=\"slide\"\n className={cn(\n \"min-w-0 shrink-0 grow-0 basis-full\",\n orientation === \"horizontal\" ? \"pl-4\" : \"pt-4\",\n className\n )}\n {...props}\n />\n )\n})\nCarouselItem.displayName = \"CarouselItem\"\n\nconst CarouselPrevious = React.forwardRef<\n HTMLButtonElement,\n React.ComponentProps<typeof Button>\n>(({ className, variant = \"outline\", size = \"icon\", ...props }, ref) => {\n const { orientation, scrollPrev, canScrollPrev } = useCarousel()\n\n return (\n <Button\n ref={ref}\n variant={variant}\n size={size}\n className={cn(\n \"absolute h-8 w-8 rounded-full\",\n orientation === \"horizontal\"\n ? \"-left-12 top-1/2 -translate-y-1/2\"\n : \"-top-12 left-1/2 -translate-x-1/2 rotate-90\",\n className\n )}\n disabled={!canScrollPrev}\n onClick={scrollPrev}\n {...props}\n >\n <ArrowLeft className=\"h-4 w-4\" />\n <span className=\"sr-only\">Previous slide</span>\n </Button>\n )\n})\nCarouselPrevious.displayName = \"CarouselPrevious\"\n\nconst CarouselNext = React.forwardRef<\n HTMLButtonElement,\n React.ComponentProps<typeof Button>\n>(({ className, variant = \"outline\", size = \"icon\", ...props }, ref) => {\n const { orientation, scrollNext, canScrollNext } = useCarousel()\n\n return (\n <Button\n ref={ref}\n variant={variant}\n size={size}\n className={cn(\n \"absolute h-8 w-8 rounded-full\",\n orientation === \"horizontal\"\n ? \"-right-12 top-1/2 -translate-y-1/2\"\n : \"-bottom-12 left-1/2 -translate-x-1/2 rotate-90\",\n className\n )}\n disabled={!canScrollNext}\n onClick={scrollNext}\n {...props}\n >\n <ArrowRight className=\"h-4 w-4\" />\n <span className=\"sr-only\">Next slide</span>\n </Button>\n )\n})\nCarouselNext.displayName = \"CarouselNext\"\n\nexport {\n type CarouselApi,\n Carousel,\n CarouselContent,\n CarouselItem,\n CarouselPrevious,\n CarouselNext,\n}\n","size_bytes":6210},"client/src/components/ui/chart.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport * as RechartsPrimitive from \"recharts\"\n\nimport { cn } from \"@/lib/utils\"\n\n// Format: { THEME_NAME: CSS_SELECTOR }\nconst THEMES = { light: \"\", dark: \".dark\" } as const\n\nexport type ChartConfig = {\n [k in string]: {\n label?: React.ReactNode\n icon?: React.ComponentType\n } & (\n | { color?: string; theme?: never }\n | { color?: never; theme: Record<keyof typeof THEMES, string> }\n )\n}\n\ntype ChartContextProps = {\n config: ChartConfig\n}\n\nconst ChartContext = React.createContext<ChartContextProps | null>(null)\n\nfunction useChart() {\n const context = React.useContext(ChartContext)\n\n if (!context) {\n throw new Error(\"useChart must be used within a <ChartContainer />\")\n }\n\n return context\n}\n\nconst ChartContainer = React.forwardRef<\n HTMLDivElement,\n React.ComponentProps<\"div\"> & {\n config: ChartConfig\n children: React.ComponentProps<\n typeof RechartsPrimitive.ResponsiveContainer\n >[\"children\"]\n }\n>(({ id, className, children, config, ...props }, ref) => {\n const uniqueId = React.useId()\n const chartId = `chart-${id || uniqueId.replace(/:/g, \"\")}`\n\n return (\n <ChartContext.Provider value={{ config }}>\n <div\n data-chart={chartId}\n ref={ref}\n className={cn(\n \"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none\",\n className\n )}\n {...props}\n >\n <ChartStyle id={chartId} config={config} />\n <RechartsPrimitive.ResponsiveContainer>\n {children}\n </RechartsPrimitive.ResponsiveContainer>\n </div>\n </ChartContext.Provider>\n )\n})\nChartContainer.displayName = \"Chart\"\n\nconst ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {\n const colorConfig = Object.entries(config).filter(\n ([, config]) => config.theme || config.color\n )\n\n if (!colorConfig.length) {\n return null\n }\n\n return (\n <style\n dangerouslySetInnerHTML={{\n __html: Object.entries(THEMES)\n .map(\n ([theme, prefix]) => `\n${prefix} [data-chart=${id}] {\n${colorConfig\n .map(([key, itemConfig]) => {\n const color =\n itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||\n itemConfig.color\n return color ? ` --color-${key}: ${color};` : null\n })\n .join(\"\\n\")}\n}\n`\n )\n .join(\"\\n\"),\n }}\n />\n )\n}\n\nconst ChartTooltip = RechartsPrimitive.Tooltip\n\nconst ChartTooltipContent = React.forwardRef<\n HTMLDivElement,\n React.ComponentProps<typeof RechartsPrimitive.Tooltip> &\n React.ComponentProps<\"div\"> & {\n hideLabel?: boolean\n hideIndicator?: boolean\n indicator?: \"line\" | \"dot\" | \"dashed\"\n nameKey?: string\n labelKey?: string\n }\n>(\n (\n {\n active,\n payload,\n className,\n indicator = \"dot\",\n hideLabel = false,\n hideIndicator = false,\n label,\n labelFormatter,\n labelClassName,\n formatter,\n color,\n nameKey,\n labelKey,\n },\n ref\n ) => {\n const { config } = useChart()\n\n const tooltipLabel = React.useMemo(() => {\n if (hideLabel || !payload?.length) {\n return null\n }\n\n const [item] = payload\n const key = `${labelKey || item?.dataKey || item?.name || \"value\"}`\n const itemConfig = getPayloadConfigFromPayload(config, item, key)\n const value =\n !labelKey && typeof label === \"string\"\n ? config[label as keyof typeof config]?.label || label\n : itemConfig?.label\n\n if (labelFormatter) {\n return (\n <div className={cn(\"font-medium\", labelClassName)}>\n {labelFormatter(value, payload)}\n </div>\n )\n }\n\n if (!value) {\n return null\n }\n\n return <div className={cn(\"font-medium\", labelClassName)}>{value}</div>\n }, [\n label,\n labelFormatter,\n payload,\n hideLabel,\n labelClassName,\n config,\n labelKey,\n ])\n\n if (!active || !payload?.length) {\n return null\n }\n\n const nestLabel = payload.length === 1 && indicator !== \"dot\"\n\n return (\n <div\n ref={ref}\n className={cn(\n \"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl\",\n className\n )}\n >\n {!nestLabel ? tooltipLabel : null}\n <div className=\"grid gap-1.5\">\n {payload.map((item, index) => {\n const key = `${nameKey || item.name || item.dataKey || \"value\"}`\n const itemConfig = getPayloadConfigFromPayload(config, item, key)\n const indicatorColor = color || item.payload.fill || item.color\n\n return (\n <div\n key={item.dataKey}\n className={cn(\n \"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground\",\n indicator === \"dot\" && \"items-center\"\n )}\n >\n {formatter && item?.value !== undefined && item.name ? (\n formatter(item.value, item.name, item, index, item.payload)\n ) : (\n <>\n {itemConfig?.icon ? (\n <itemConfig.icon />\n ) : (\n !hideIndicator && (\n <div\n className={cn(\n \"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]\",\n {\n \"h-2.5 w-2.5\": indicator === \"dot\",\n \"w-1\": indicator === \"line\",\n \"w-0 border-[1.5px] border-dashed bg-transparent\":\n indicator === \"dashed\",\n \"my-0.5\": nestLabel && indicator === \"dashed\",\n }\n )}\n style={\n {\n \"--color-bg\": indicatorColor,\n \"--color-border\": indicatorColor,\n } as React.CSSProperties\n }\n />\n )\n )}\n <div\n className={cn(\n \"flex flex-1 justify-between leading-none\",\n nestLabel ? \"items-end\" : \"items-center\"\n )}\n >\n <div className=\"grid gap-1.5\">\n {nestLabel ? tooltipLabel : null}\n <span className=\"text-muted-foreground\">\n {itemConfig?.label || item.name}\n </span>\n </div>\n {item.value && (\n <span className=\"font-mono font-medium tabular-nums text-foreground\">\n {item.value.toLocaleString()}\n </span>\n )}\n </div>\n </>\n )}\n </div>\n )\n })}\n </div>\n </div>\n )\n }\n)\nChartTooltipContent.displayName = \"ChartTooltip\"\n\nconst ChartLegend = RechartsPrimitive.Legend\n\nconst ChartLegendContent = React.forwardRef<\n HTMLDivElement,\n React.ComponentProps<\"div\"> &\n Pick<RechartsPrimitive.LegendProps, \"payload\" | \"verticalAlign\"> & {\n hideIcon?: boolean\n nameKey?: string\n }\n>(\n (\n { className, hideIcon = false, payload, verticalAlign = \"bottom\", nameKey },\n ref\n ) => {\n const { config } = useChart()\n\n if (!payload?.length) {\n return null\n }\n\n return (\n <div\n ref={ref}\n className={cn(\n \"flex items-center justify-center gap-4\",\n verticalAlign === \"top\" ? \"pb-3\" : \"pt-3\",\n className\n )}\n >\n {payload.map((item) => {\n const key = `${nameKey || item.dataKey || \"value\"}`\n const itemConfig = getPayloadConfigFromPayload(config, item, key)\n\n return (\n <div\n key={item.value}\n className={cn(\n \"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground\"\n )}\n >\n {itemConfig?.icon && !hideIcon ? (\n <itemConfig.icon />\n ) : (\n <div\n className=\"h-2 w-2 shrink-0 rounded-[2px]\"\n style={{\n backgroundColor: item.color,\n }}\n />\n )}\n {itemConfig?.label}\n </div>\n )\n })}\n </div>\n )\n }\n)\nChartLegendContent.displayName = \"ChartLegend\"\n\n// Helper to extract item config from a payload.\nfunction getPayloadConfigFromPayload(\n config: ChartConfig,\n payload: unknown,\n key: string\n) {\n if (typeof payload !== \"object\" || payload === null) {\n return undefined\n }\n\n const payloadPayload =\n \"payload\" in payload &&\n typeof payload.payload === \"object\" &&\n payload.payload !== null\n ? payload.payload\n : undefined\n\n let configLabelKey: string = key\n\n if (\n key in payload &&\n typeof payload[key as keyof typeof payload] === \"string\"\n ) {\n configLabelKey = payload[key as keyof typeof payload] as string\n } else if (\n payloadPayload &&\n key in payloadPayload &&\n typeof payloadPayload[key as keyof typeof payloadPayload] === \"string\"\n ) {\n configLabelKey = payloadPayload[\n key as keyof typeof payloadPayload\n ] as string\n }\n\n return configLabelKey in config\n ? config[configLabelKey]\n : config[key as keyof typeof config]\n}\n\nexport {\n ChartContainer,\n ChartTooltip,\n ChartTooltipContent,\n ChartLegend,\n ChartLegendContent,\n ChartStyle,\n}\n","size_bytes":10481},"client/src/components/ui/checkbox.tsx":{"content":"import * as React from \"react\"\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\"\nimport { Check } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Checkbox = React.forwardRef<\n React.ElementRef<typeof CheckboxPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>\n>(({ className, ...props }, ref) => (\n <CheckboxPrimitive.Root\n ref={ref}\n className={cn(\n \"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground\",\n className\n )}\n {...props}\n >\n <CheckboxPrimitive.Indicator\n className={cn(\"flex items-center justify-center text-current\")}\n >\n <Check className=\"h-4 w-4\" />\n </CheckboxPrimitive.Indicator>\n </CheckboxPrimitive.Root>\n))\nCheckbox.displayName = CheckboxPrimitive.Root.displayName\n\nexport { Checkbox }\n","size_bytes":1056},"client/src/components/ui/collapsible.tsx":{"content":"\"use client\"\n\nimport * as CollapsiblePrimitive from \"@radix-ui/react-collapsible\"\n\nconst Collapsible = CollapsiblePrimitive.Root\n\nconst CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger\n\nconst CollapsibleContent = CollapsiblePrimitive.CollapsibleContent\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent }\n","size_bytes":329},"client/src/components/ui/command.tsx":{"content":"import * as React from \"react\"\nimport { type DialogProps } from \"@radix-ui/react-dialog\"\nimport { Command as CommandPrimitive } from \"cmdk\"\nimport { Search } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\"\n\nconst Command = React.forwardRef<\n React.ElementRef<typeof CommandPrimitive>,\n React.ComponentPropsWithoutRef<typeof CommandPrimitive>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive\n ref={ref}\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground\",\n className\n )}\n {...props}\n />\n))\nCommand.displayName = CommandPrimitive.displayName\n\nconst CommandDialog = ({ children, ...props }: DialogProps) => {\n return (\n <Dialog {...props}>\n <DialogContent className=\"overflow-hidden p-0 shadow-lg\">\n <Command className=\"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n {children}\n </Command>\n </DialogContent>\n </Dialog>\n )\n}\n\nconst CommandInput = React.forwardRef<\n React.ElementRef<typeof CommandPrimitive.Input>,\n React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>\n>(({ className, ...props }, ref) => (\n <div className=\"flex items-center border-b px-3\" cmdk-input-wrapper=\"\">\n <Search className=\"mr-2 h-4 w-4 shrink-0 opacity-50\" />\n <CommandPrimitive.Input\n ref={ref}\n className={cn(\n \"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\",\n className\n )}\n {...props}\n />\n </div>\n))\n\nCommandInput.displayName = CommandPrimitive.Input.displayName\n\nconst CommandList = React.forwardRef<\n React.ElementRef<typeof CommandPrimitive.List>,\n React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.List\n ref={ref}\n className={cn(\"max-h-[300px] overflow-y-auto overflow-x-hidden\", className)}\n {...props}\n />\n))\n\nCommandList.displayName = CommandPrimitive.List.displayName\n\nconst CommandEmpty = React.forwardRef<\n React.ElementRef<typeof CommandPrimitive.Empty>,\n React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>\n>((props, ref) => (\n <CommandPrimitive.Empty\n ref={ref}\n className=\"py-6 text-center text-sm\"\n {...props}\n />\n))\n\nCommandEmpty.displayName = CommandPrimitive.Empty.displayName\n\nconst CommandGroup = React.forwardRef<\n React.ElementRef<typeof CommandPrimitive.Group>,\n React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Group\n ref={ref}\n className={cn(\n \"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground\",\n className\n )}\n {...props}\n />\n))\n\nCommandGroup.displayName = CommandPrimitive.Group.displayName\n\nconst CommandSeparator = React.forwardRef<\n React.ElementRef<typeof CommandPrimitive.Separator>,\n React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Separator\n ref={ref}\n className={cn(\"-mx-1 h-px bg-border\", className)}\n {...props}\n />\n))\nCommandSeparator.displayName = CommandPrimitive.Separator.displayName\n\nconst CommandItem = React.forwardRef<\n React.ElementRef<typeof CommandPrimitive.Item>,\n React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>\n>(({ className, ...props }, ref) => (\n <CommandPrimitive.Item\n ref={ref}\n className={cn(\n \"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n className\n )}\n {...props}\n />\n))\n\nCommandItem.displayName = CommandPrimitive.Item.displayName\n\nconst CommandShortcut = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n return (\n <span\n className={cn(\n \"ml-auto text-xs tracking-widest text-muted-foreground\",\n className\n )}\n {...props}\n />\n )\n}\nCommandShortcut.displayName = \"CommandShortcut\"\n\nexport {\n Command,\n CommandDialog,\n CommandInput,\n CommandList,\n CommandEmpty,\n CommandGroup,\n CommandItem,\n CommandShortcut,\n CommandSeparator,\n}\n","size_bytes":4885},"client/src/components/ui/context-menu.tsx":{"content":"import * as React from \"react\"\nimport * as ContextMenuPrimitive from \"@radix-ui/react-context-menu\"\nimport { Check, ChevronRight, Circle } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ContextMenu = ContextMenuPrimitive.Root\n\nconst ContextMenuTrigger = ContextMenuPrimitive.Trigger\n\nconst ContextMenuGroup = ContextMenuPrimitive.Group\n\nconst ContextMenuPortal = ContextMenuPrimitive.Portal\n\nconst ContextMenuSub = ContextMenuPrimitive.Sub\n\nconst ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup\n\nconst ContextMenuSubTrigger = React.forwardRef<\n React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,\n React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {\n inset?: boolean\n }\n>(({ className, inset, children, ...props }, ref) => (\n <ContextMenuPrimitive.SubTrigger\n ref={ref}\n className={cn(\n \"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground\",\n inset && \"pl-8\",\n className\n )}\n {...props}\n >\n {children}\n <ChevronRight className=\"ml-auto h-4 w-4\" />\n </ContextMenuPrimitive.SubTrigger>\n))\nContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName\n\nconst ContextMenuSubContent = React.forwardRef<\n React.ElementRef<typeof ContextMenuPrimitive.SubContent>,\n React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n <ContextMenuPrimitive.SubContent\n ref={ref}\n className={cn(\n \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]\",\n className\n )}\n {...props}\n />\n))\nContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName\n\nconst ContextMenuContent = React.forwardRef<\n React.ElementRef<typeof ContextMenuPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>\n>(({ className, ...props }, ref) => (\n <ContextMenuPrimitive.Portal>\n <ContextMenuPrimitive.Content\n ref={ref}\n className={cn(\n \"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]\",\n className\n )}\n {...props}\n />\n </ContextMenuPrimitive.Portal>\n))\nContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName\n\nconst ContextMenuItem = React.forwardRef<\n React.ElementRef<typeof ContextMenuPrimitive.Item>,\n React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {\n inset?: boolean\n }\n>(({ className, inset, ...props }, ref) => (\n <ContextMenuPrimitive.Item\n ref={ref}\n className={cn(\n \"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n inset && \"pl-8\",\n className\n )}\n {...props}\n />\n))\nContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName\n\nconst ContextMenuCheckboxItem = React.forwardRef<\n React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,\n React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n <ContextMenuPrimitive.CheckboxItem\n ref={ref}\n className={cn(\n \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n className\n )}\n checked={checked}\n {...props}\n >\n <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <ContextMenuPrimitive.ItemIndicator>\n <Check className=\"h-4 w-4\" />\n </ContextMenuPrimitive.ItemIndicator>\n </span>\n {children}\n </ContextMenuPrimitive.CheckboxItem>\n))\nContextMenuCheckboxItem.displayName =\n ContextMenuPrimitive.CheckboxItem.displayName\n\nconst ContextMenuRadioItem = React.forwardRef<\n React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,\n React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n <ContextMenuPrimitive.RadioItem\n ref={ref}\n className={cn(\n \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n className\n )}\n {...props}\n >\n <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <ContextMenuPrimitive.ItemIndicator>\n <Circle className=\"h-2 w-2 fill-current\" />\n </ContextMenuPrimitive.ItemIndicator>\n </span>\n {children}\n </ContextMenuPrimitive.RadioItem>\n))\nContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName\n\nconst ContextMenuLabel = React.forwardRef<\n React.ElementRef<typeof ContextMenuPrimitive.Label>,\n React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {\n inset?: boolean\n }\n>(({ className, inset, ...props }, ref) => (\n <ContextMenuPrimitive.Label\n ref={ref}\n className={cn(\n \"px-2 py-1.5 text-sm font-semibold text-foreground\",\n inset && \"pl-8\",\n className\n )}\n {...props}\n />\n))\nContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName\n\nconst ContextMenuSeparator = React.forwardRef<\n React.ElementRef<typeof ContextMenuPrimitive.Separator>,\n React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n <ContextMenuPrimitive.Separator\n ref={ref}\n className={cn(\"-mx-1 my-1 h-px bg-border\", className)}\n {...props}\n />\n))\nContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName\n\nconst ContextMenuShortcut = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n return (\n <span\n className={cn(\n \"ml-auto text-xs tracking-widest text-muted-foreground\",\n className\n )}\n {...props}\n />\n )\n}\nContextMenuShortcut.displayName = \"ContextMenuShortcut\"\n\nexport {\n ContextMenu,\n ContextMenuTrigger,\n ContextMenuContent,\n ContextMenuItem,\n ContextMenuCheckboxItem,\n ContextMenuRadioItem,\n ContextMenuLabel,\n ContextMenuSeparator,\n ContextMenuShortcut,\n ContextMenuGroup,\n ContextMenuPortal,\n ContextMenuSub,\n ContextMenuSubContent,\n ContextMenuSubTrigger,\n ContextMenuRadioGroup,\n}\n","size_bytes":7428},"client/src/components/ui/dialog.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Dialog = DialogPrimitive.Root\n\nconst DialogTrigger = DialogPrimitive.Trigger\n\nconst DialogPortal = DialogPrimitive.Portal\n\nconst DialogClose = DialogPrimitive.Close\n\nconst DialogOverlay = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Overlay>,\n React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n <DialogPrimitive.Overlay\n ref={ref}\n className={cn(\n \"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n className\n )}\n {...props}\n />\n))\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName\n\nconst DialogContent = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n <DialogPortal>\n <DialogOverlay />\n <DialogPrimitive.Content\n ref={ref}\n className={cn(\n \"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n className\n )}\n {...props}\n >\n {children}\n <DialogPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\">\n <X className=\"h-4 w-4\" />\n <span className=\"sr-only\">Close</span>\n </DialogPrimitive.Close>\n </DialogPrimitive.Content>\n </DialogPortal>\n))\nDialogContent.displayName = DialogPrimitive.Content.displayName\n\nconst DialogHeader = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n <div\n className={cn(\n \"flex flex-col space-y-1.5 text-center sm:text-left\",\n className\n )}\n {...props}\n />\n)\nDialogHeader.displayName = \"DialogHeader\"\n\nconst DialogFooter = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n <div\n className={cn(\n \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n className\n )}\n {...props}\n />\n)\nDialogFooter.displayName = \"DialogFooter\"\n\nconst DialogTitle = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Title>,\n React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n <DialogPrimitive.Title\n ref={ref}\n className={cn(\n \"text-lg font-semibold leading-none tracking-tight\",\n className\n )}\n {...props}\n />\n))\nDialogTitle.displayName = DialogPrimitive.Title.displayName\n\nconst DialogDescription = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Description>,\n React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n <DialogPrimitive.Description\n ref={ref}\n className={cn(\"text-sm text-muted-foreground\", className)}\n {...props}\n />\n))\nDialogDescription.displayName = DialogPrimitive.Description.displayName\n\nexport {\n Dialog,\n DialogPortal,\n DialogOverlay,\n DialogClose,\n DialogTrigger,\n DialogContent,\n DialogHeader,\n DialogFooter,\n DialogTitle,\n DialogDescription,\n}\n","size_bytes":3848},"client/src/components/ui/drawer.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Drawer as DrawerPrimitive } from \"vaul\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Drawer = ({\n shouldScaleBackground = true,\n ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (\n <DrawerPrimitive.Root\n shouldScaleBackground={shouldScaleBackground}\n {...props}\n />\n)\nDrawer.displayName = \"Drawer\"\n\nconst DrawerTrigger = DrawerPrimitive.Trigger\n\nconst DrawerPortal = DrawerPrimitive.Portal\n\nconst DrawerClose = DrawerPrimitive.Close\n\nconst DrawerOverlay = React.forwardRef<\n React.ElementRef<typeof DrawerPrimitive.Overlay>,\n React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n <DrawerPrimitive.Overlay\n ref={ref}\n className={cn(\"fixed inset-0 z-50 bg-black/80\", className)}\n {...props}\n />\n))\nDrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName\n\nconst DrawerContent = React.forwardRef<\n React.ElementRef<typeof DrawerPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n <DrawerPortal>\n <DrawerOverlay />\n <DrawerPrimitive.Content\n ref={ref}\n className={cn(\n \"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background\",\n className\n )}\n {...props}\n >\n <div className=\"mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted\" />\n {children}\n </DrawerPrimitive.Content>\n </DrawerPortal>\n))\nDrawerContent.displayName = \"DrawerContent\"\n\nconst DrawerHeader = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n <div\n className={cn(\"grid gap-1.5 p-4 text-center sm:text-left\", className)}\n {...props}\n />\n)\nDrawerHeader.displayName = \"DrawerHeader\"\n\nconst DrawerFooter = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n <div\n className={cn(\"mt-auto flex flex-col gap-2 p-4\", className)}\n {...props}\n />\n)\nDrawerFooter.displayName = \"DrawerFooter\"\n\nconst DrawerTitle = React.forwardRef<\n React.ElementRef<typeof DrawerPrimitive.Title>,\n React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>\n>(({ className, ...props }, ref) => (\n <DrawerPrimitive.Title\n ref={ref}\n className={cn(\n \"text-lg font-semibold leading-none tracking-tight\",\n className\n )}\n {...props}\n />\n))\nDrawerTitle.displayName = DrawerPrimitive.Title.displayName\n\nconst DrawerDescription = React.forwardRef<\n React.ElementRef<typeof DrawerPrimitive.Description>,\n React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>\n>(({ className, ...props }, ref) => (\n <DrawerPrimitive.Description\n ref={ref}\n className={cn(\"text-sm text-muted-foreground\", className)}\n {...props}\n />\n))\nDrawerDescription.displayName = DrawerPrimitive.Description.displayName\n\nexport {\n Drawer,\n DrawerPortal,\n DrawerOverlay,\n DrawerTrigger,\n DrawerClose,\n DrawerContent,\n DrawerHeader,\n DrawerFooter,\n DrawerTitle,\n DrawerDescription,\n}\n","size_bytes":3021},"client/src/components/ui/dropdown-menu.tsx":{"content":"import * as React from \"react\"\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\"\nimport { Check, ChevronRight, Circle } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst DropdownMenu = DropdownMenuPrimitive.Root\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n inset?: boolean\n }\n>(({ className, inset, children, ...props }, ref) => (\n <DropdownMenuPrimitive.SubTrigger\n ref={ref}\n className={cn(\n \"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n inset && \"pl-8\",\n className\n )}\n {...props}\n >\n {children}\n <ChevronRight className=\"ml-auto\" />\n </DropdownMenuPrimitive.SubTrigger>\n))\nDropdownMenuSubTrigger.displayName =\n DropdownMenuPrimitive.SubTrigger.displayName\n\nconst DropdownMenuSubContent = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n <DropdownMenuPrimitive.SubContent\n ref={ref}\n className={cn(\n \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]\",\n className\n )}\n {...props}\n />\n))\nDropdownMenuSubContent.displayName =\n DropdownMenuPrimitive.SubContent.displayName\n\nconst DropdownMenuContent = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n <DropdownMenuPrimitive.Portal>\n <DropdownMenuPrimitive.Content\n ref={ref}\n sideOffset={sideOffset}\n className={cn(\n \"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]\",\n className\n )}\n {...props}\n />\n </DropdownMenuPrimitive.Portal>\n))\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName\n\nconst DropdownMenuItem = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n inset?: boolean\n }\n>(({ className, inset, ...props }, ref) => (\n <DropdownMenuPrimitive.Item\n ref={ref}\n className={cn(\n \"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n inset && \"pl-8\",\n className\n )}\n {...props}\n />\n))\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName\n\nconst DropdownMenuCheckboxItem = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n <DropdownMenuPrimitive.CheckboxItem\n ref={ref}\n className={cn(\n \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n className\n )}\n checked={checked}\n {...props}\n >\n <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <DropdownMenuPrimitive.ItemIndicator>\n <Check className=\"h-4 w-4\" />\n </DropdownMenuPrimitive.ItemIndicator>\n </span>\n {children}\n </DropdownMenuPrimitive.CheckboxItem>\n))\nDropdownMenuCheckboxItem.displayName =\n DropdownMenuPrimitive.CheckboxItem.displayName\n\nconst DropdownMenuRadioItem = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n <DropdownMenuPrimitive.RadioItem\n ref={ref}\n className={cn(\n \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n className\n )}\n {...props}\n >\n <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <DropdownMenuPrimitive.ItemIndicator>\n <Circle className=\"h-2 w-2 fill-current\" />\n </DropdownMenuPrimitive.ItemIndicator>\n </span>\n {children}\n </DropdownMenuPrimitive.RadioItem>\n))\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName\n\nconst DropdownMenuLabel = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n inset?: boolean\n }\n>(({ className, inset, ...props }, ref) => (\n <DropdownMenuPrimitive.Label\n ref={ref}\n className={cn(\n \"px-2 py-1.5 text-sm font-semibold\",\n inset && \"pl-8\",\n className\n )}\n {...props}\n />\n))\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName\n\nconst DropdownMenuSeparator = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n <DropdownMenuPrimitive.Separator\n ref={ref}\n className={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n {...props}\n />\n))\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName\n\nconst DropdownMenuShortcut = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n return (\n <span\n className={cn(\"ml-auto text-xs tracking-widest opacity-60\", className)}\n {...props}\n />\n )\n}\nDropdownMenuShortcut.displayName = \"DropdownMenuShortcut\"\n\nexport {\n DropdownMenu,\n DropdownMenuTrigger,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuCheckboxItem,\n DropdownMenuRadioItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuShortcut,\n DropdownMenuGroup,\n DropdownMenuPortal,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuRadioGroup,\n}\n","size_bytes":7609},"client/src/components/ui/form.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport {\n Controller,\n FormProvider,\n useFormContext,\n type ControllerProps,\n type FieldPath,\n type FieldValues,\n} from \"react-hook-form\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Label } from \"@/components/ui/label\"\n\nconst Form = FormProvider\n\ntype FormFieldContextValue<\n TFieldValues extends FieldValues = FieldValues,\n TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n> = {\n name: TName\n}\n\nconst FormFieldContext = React.createContext<FormFieldContextValue>(\n {} as FormFieldContextValue\n)\n\nconst FormField = <\n TFieldValues extends FieldValues = FieldValues,\n TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n>({\n ...props\n}: ControllerProps<TFieldValues, TName>) => {\n return (\n <FormFieldContext.Provider value={{ name: props.name }}>\n <Controller {...props} />\n </FormFieldContext.Provider>\n )\n}\n\nconst useFormField = () => {\n const fieldContext = React.useContext(FormFieldContext)\n const itemContext = React.useContext(FormItemContext)\n const { getFieldState, formState } = useFormContext()\n\n const fieldState = getFieldState(fieldContext.name, formState)\n\n if (!fieldContext) {\n throw new Error(\"useFormField should be used within <FormField>\")\n }\n\n const { id } = itemContext\n\n return {\n id,\n name: fieldContext.name,\n formItemId: `${id}-form-item`,\n formDescriptionId: `${id}-form-item-description`,\n formMessageId: `${id}-form-item-message`,\n ...fieldState,\n }\n}\n\ntype FormItemContextValue = {\n id: string\n}\n\nconst FormItemContext = React.createContext<FormItemContextValue>(\n {} as FormItemContextValue\n)\n\nconst FormItem = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n const id = React.useId()\n\n return (\n <FormItemContext.Provider value={{ id }}>\n <div ref={ref} className={cn(\"space-y-2\", className)} {...props} />\n </FormItemContext.Provider>\n )\n})\nFormItem.displayName = \"FormItem\"\n\nconst FormLabel = React.forwardRef<\n React.ElementRef<typeof LabelPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>\n>(({ className, ...props }, ref) => {\n const { error, formItemId } = useFormField()\n\n return (\n <Label\n ref={ref}\n className={cn(error && \"text-destructive\", className)}\n htmlFor={formItemId}\n {...props}\n />\n )\n})\nFormLabel.displayName = \"FormLabel\"\n\nconst FormControl = React.forwardRef<\n React.ElementRef<typeof Slot>,\n React.ComponentPropsWithoutRef<typeof Slot>\n>(({ ...props }, ref) => {\n const { error, formItemId, formDescriptionId, formMessageId } = useFormField()\n\n return (\n <Slot\n ref={ref}\n id={formItemId}\n aria-describedby={\n !error\n ? `${formDescriptionId}`\n : `${formDescriptionId} ${formMessageId}`\n }\n aria-invalid={!!error}\n {...props}\n />\n )\n})\nFormControl.displayName = \"FormControl\"\n\nconst FormDescription = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => {\n const { formDescriptionId } = useFormField()\n\n return (\n <p\n ref={ref}\n id={formDescriptionId}\n className={cn(\"text-sm text-muted-foreground\", className)}\n {...props}\n />\n )\n})\nFormDescription.displayName = \"FormDescription\"\n\nconst FormMessage = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, ...props }, ref) => {\n const { error, formMessageId } = useFormField()\n const body = error ? String(error?.message ?? \"\") : children\n\n if (!body) {\n return null\n }\n\n return (\n <p\n ref={ref}\n id={formMessageId}\n className={cn(\"text-sm font-medium text-destructive\", className)}\n {...props}\n >\n {body}\n </p>\n )\n})\nFormMessage.displayName = \"FormMessage\"\n\nexport {\n useFormField,\n Form,\n FormItem,\n FormLabel,\n FormControl,\n FormDescription,\n FormMessage,\n FormField,\n}\n","size_bytes":4120},"client/src/components/ui/hover-card.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport * as HoverCardPrimitive from \"@radix-ui/react-hover-card\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst HoverCard = HoverCardPrimitive.Root\n\nconst HoverCardTrigger = HoverCardPrimitive.Trigger\n\nconst HoverCardContent = React.forwardRef<\n React.ElementRef<typeof HoverCardPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>\n>(({ className, align = \"center\", sideOffset = 4, ...props }, ref) => (\n <HoverCardPrimitive.Content\n ref={ref}\n align={align}\n sideOffset={sideOffset}\n className={cn(\n \"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]\",\n className\n )}\n {...props}\n />\n))\nHoverCardContent.displayName = HoverCardPrimitive.Content.displayName\n\nexport { HoverCard, HoverCardTrigger, HoverCardContent }\n","size_bytes":1251},"client/src/components/ui/input-otp.tsx":{"content":"import * as React from \"react\"\nimport { OTPInput, OTPInputContext } from \"input-otp\"\nimport { Dot } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst InputOTP = React.forwardRef<\n React.ElementRef<typeof OTPInput>,\n React.ComponentPropsWithoutRef<typeof OTPInput>\n>(({ className, containerClassName, ...props }, ref) => (\n <OTPInput\n ref={ref}\n containerClassName={cn(\n \"flex items-center gap-2 has-[:disabled]:opacity-50\",\n containerClassName\n )}\n className={cn(\"disabled:cursor-not-allowed\", className)}\n {...props}\n />\n))\nInputOTP.displayName = \"InputOTP\"\n\nconst InputOTPGroup = React.forwardRef<\n React.ElementRef<\"div\">,\n React.ComponentPropsWithoutRef<\"div\">\n>(({ className, ...props }, ref) => (\n <div ref={ref} className={cn(\"flex items-center\", className)} {...props} />\n))\nInputOTPGroup.displayName = \"InputOTPGroup\"\n\nconst InputOTPSlot = React.forwardRef<\n React.ElementRef<\"div\">,\n React.ComponentPropsWithoutRef<\"div\"> & { index: number }\n>(({ index, className, ...props }, ref) => {\n const inputOTPContext = React.useContext(OTPInputContext)\n const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]\n\n return (\n <div\n ref={ref}\n className={cn(\n \"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md\",\n isActive && \"z-10 ring-2 ring-ring ring-offset-background\",\n className\n )}\n {...props}\n >\n {char}\n {hasFakeCaret && (\n <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n <div className=\"h-4 w-px animate-caret-blink bg-foreground duration-1000\" />\n </div>\n )}\n </div>\n )\n})\nInputOTPSlot.displayName = \"InputOTPSlot\"\n\nconst InputOTPSeparator = React.forwardRef<\n React.ElementRef<\"div\">,\n React.ComponentPropsWithoutRef<\"div\">\n>(({ ...props }, ref) => (\n <div ref={ref} role=\"separator\" {...props}>\n <Dot />\n </div>\n))\nInputOTPSeparator.displayName = \"InputOTPSeparator\"\n\nexport { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }\n","size_bytes":2154},"client/src/components/ui/input.tsx":{"content":"import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Input = React.forwardRef<HTMLInputElement, React.ComponentProps<\"input\">>(\n ({ className, type, ...props }, ref) => {\n // h-9 to match icon buttons and default buttons.\n return (\n <input\n type={type}\n className={cn(\n \"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground 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 md:text-sm\",\n className\n )}\n ref={ref}\n {...props}\n />\n )\n }\n)\nInput.displayName = \"Input\"\n\nexport { Input }\n","size_bytes":844},"client/src/components/ui/label.tsx":{"content":"import * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst labelVariants = cva(\n \"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n)\n\nconst Label = React.forwardRef<\n React.ElementRef<typeof LabelPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &\n VariantProps<typeof labelVariants>\n>(({ className, ...props }, ref) => (\n <LabelPrimitive.Root\n ref={ref}\n className={cn(labelVariants(), className)}\n {...props}\n />\n))\nLabel.displayName = LabelPrimitive.Root.displayName\n\nexport { Label }\n","size_bytes":710},"client/src/components/ui/menubar.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport * as MenubarPrimitive from \"@radix-ui/react-menubar\"\nimport { Check, ChevronRight, Circle } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction MenubarMenu({\n ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {\n return <MenubarPrimitive.Menu {...props} />\n}\n\nfunction MenubarGroup({\n ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Group>) {\n return <MenubarPrimitive.Group {...props} />\n}\n\nfunction MenubarPortal({\n ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {\n return <MenubarPrimitive.Portal {...props} />\n}\n\nfunction MenubarRadioGroup({\n ...props\n}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {\n return <MenubarPrimitive.RadioGroup {...props} />\n}\n\nfunction MenubarSub({\n ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {\n return <MenubarPrimitive.Sub data-slot=\"menubar-sub\" {...props} />\n}\n\nconst Menubar = React.forwardRef<\n React.ElementRef<typeof MenubarPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>\n>(({ className, ...props }, ref) => (\n <MenubarPrimitive.Root\n ref={ref}\n className={cn(\n \"flex h-10 items-center space-x-1 rounded-md border bg-background p-1\",\n className\n )}\n {...props}\n />\n))\nMenubar.displayName = MenubarPrimitive.Root.displayName\n\nconst MenubarTrigger = React.forwardRef<\n React.ElementRef<typeof MenubarPrimitive.Trigger>,\n React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n <MenubarPrimitive.Trigger\n ref={ref}\n className={cn(\n \"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground\",\n className\n )}\n {...props}\n />\n))\nMenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName\n\nconst MenubarSubTrigger = React.forwardRef<\n React.ElementRef<typeof MenubarPrimitive.SubTrigger>,\n React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {\n inset?: boolean\n }\n>(({ className, inset, children, ...props }, ref) => (\n <MenubarPrimitive.SubTrigger\n ref={ref}\n className={cn(\n \"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground\",\n inset && \"pl-8\",\n className\n )}\n {...props}\n >\n {children}\n <ChevronRight className=\"ml-auto h-4 w-4\" />\n </MenubarPrimitive.SubTrigger>\n))\nMenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName\n\nconst MenubarSubContent = React.forwardRef<\n React.ElementRef<typeof MenubarPrimitive.SubContent>,\n React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n <MenubarPrimitive.SubContent\n ref={ref}\n className={cn(\n \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]\",\n className\n )}\n {...props}\n />\n))\nMenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName\n\nconst MenubarContent = React.forwardRef<\n React.ElementRef<typeof MenubarPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>\n>(\n (\n { className, align = \"start\", alignOffset = -4, sideOffset = 8, ...props },\n ref\n ) => (\n <MenubarPrimitive.Portal>\n <MenubarPrimitive.Content\n ref={ref}\n align={align}\n alignOffset={alignOffset}\n sideOffset={sideOffset}\n className={cn(\n \"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]\",\n className\n )}\n {...props}\n />\n </MenubarPrimitive.Portal>\n )\n)\nMenubarContent.displayName = MenubarPrimitive.Content.displayName\n\nconst MenubarItem = React.forwardRef<\n React.ElementRef<typeof MenubarPrimitive.Item>,\n React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {\n inset?: boolean\n }\n>(({ className, inset, ...props }, ref) => (\n <MenubarPrimitive.Item\n ref={ref}\n className={cn(\n \"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n inset && \"pl-8\",\n className\n )}\n {...props}\n />\n))\nMenubarItem.displayName = MenubarPrimitive.Item.displayName\n\nconst MenubarCheckboxItem = React.forwardRef<\n React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,\n React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n <MenubarPrimitive.CheckboxItem\n ref={ref}\n className={cn(\n \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n className\n )}\n checked={checked}\n {...props}\n >\n <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <MenubarPrimitive.ItemIndicator>\n <Check className=\"h-4 w-4\" />\n </MenubarPrimitive.ItemIndicator>\n </span>\n {children}\n </MenubarPrimitive.CheckboxItem>\n))\nMenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName\n\nconst MenubarRadioItem = React.forwardRef<\n React.ElementRef<typeof MenubarPrimitive.RadioItem>,\n React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n <MenubarPrimitive.RadioItem\n ref={ref}\n className={cn(\n \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n className\n )}\n {...props}\n >\n <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <MenubarPrimitive.ItemIndicator>\n <Circle className=\"h-2 w-2 fill-current\" />\n </MenubarPrimitive.ItemIndicator>\n </span>\n {children}\n </MenubarPrimitive.RadioItem>\n))\nMenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName\n\nconst MenubarLabel = React.forwardRef<\n React.ElementRef<typeof MenubarPrimitive.Label>,\n React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {\n inset?: boolean\n }\n>(({ className, inset, ...props }, ref) => (\n <MenubarPrimitive.Label\n ref={ref}\n className={cn(\n \"px-2 py-1.5 text-sm font-semibold\",\n inset && \"pl-8\",\n className\n )}\n {...props}\n />\n))\nMenubarLabel.displayName = MenubarPrimitive.Label.displayName\n\nconst MenubarSeparator = React.forwardRef<\n React.ElementRef<typeof MenubarPrimitive.Separator>,\n React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n <MenubarPrimitive.Separator\n ref={ref}\n className={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n {...props}\n />\n))\nMenubarSeparator.displayName = MenubarPrimitive.Separator.displayName\n\nconst MenubarShortcut = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n return (\n <span\n className={cn(\n \"ml-auto text-xs tracking-widest text-muted-foreground\",\n className\n )}\n {...props}\n />\n )\n}\nMenubarShortcut.displayname = \"MenubarShortcut\"\n\nexport {\n Menubar,\n MenubarMenu,\n MenubarTrigger,\n MenubarContent,\n MenubarItem,\n MenubarSeparator,\n MenubarLabel,\n MenubarCheckboxItem,\n MenubarRadioGroup,\n MenubarRadioItem,\n MenubarPortal,\n MenubarSubContent,\n MenubarSubTrigger,\n MenubarGroup,\n MenubarSub,\n MenubarShortcut,\n}\n","size_bytes":8605},"client/src/components/ui/navigation-menu.tsx":{"content":"import * as React from \"react\"\nimport * as NavigationMenuPrimitive from \"@radix-ui/react-navigation-menu\"\nimport { cva } from \"class-variance-authority\"\nimport { ChevronDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst NavigationMenu = React.forwardRef<\n React.ElementRef<typeof NavigationMenuPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>\n>(({ className, children, ...props }, ref) => (\n <NavigationMenuPrimitive.Root\n ref={ref}\n className={cn(\n \"relative z-10 flex max-w-max flex-1 items-center justify-center\",\n className\n )}\n {...props}\n >\n {children}\n <NavigationMenuViewport />\n </NavigationMenuPrimitive.Root>\n))\nNavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName\n\nconst NavigationMenuList = React.forwardRef<\n React.ElementRef<typeof NavigationMenuPrimitive.List>,\n React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>\n>(({ className, ...props }, ref) => (\n <NavigationMenuPrimitive.List\n ref={ref}\n className={cn(\n \"group flex flex-1 list-none items-center justify-center space-x-1\",\n className\n )}\n {...props}\n />\n))\nNavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName\n\nconst NavigationMenuItem = NavigationMenuPrimitive.Item\n\nconst navigationMenuTriggerStyle = cva(\n \"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent\"\n)\n\nconst NavigationMenuTrigger = React.forwardRef<\n React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,\n React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n <NavigationMenuPrimitive.Trigger\n ref={ref}\n className={cn(navigationMenuTriggerStyle(), \"group\", className)}\n {...props}\n >\n {children}{\" \"}\n <ChevronDown\n className=\"relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180\"\n aria-hidden=\"true\"\n />\n </NavigationMenuPrimitive.Trigger>\n))\nNavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName\n\nconst NavigationMenuContent = React.forwardRef<\n React.ElementRef<typeof NavigationMenuPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>\n>(({ className, ...props }, ref) => (\n <NavigationMenuPrimitive.Content\n ref={ref}\n className={cn(\n \"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto \",\n className\n )}\n {...props}\n />\n))\nNavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName\n\nconst NavigationMenuLink = NavigationMenuPrimitive.Link\n\nconst NavigationMenuViewport = React.forwardRef<\n React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,\n React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>\n>(({ className, ...props }, ref) => (\n <div className={cn(\"absolute left-0 top-full flex justify-center\")}>\n <NavigationMenuPrimitive.Viewport\n className={cn(\n \"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]\",\n className\n )}\n ref={ref}\n {...props}\n />\n </div>\n))\nNavigationMenuViewport.displayName =\n NavigationMenuPrimitive.Viewport.displayName\n\nconst NavigationMenuIndicator = React.forwardRef<\n React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,\n React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>\n>(({ className, ...props }, ref) => (\n <NavigationMenuPrimitive.Indicator\n ref={ref}\n className={cn(\n \"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in\",\n className\n )}\n {...props}\n >\n <div className=\"relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md\" />\n </NavigationMenuPrimitive.Indicator>\n))\nNavigationMenuIndicator.displayName =\n NavigationMenuPrimitive.Indicator.displayName\n\nexport {\n navigationMenuTriggerStyle,\n NavigationMenu,\n NavigationMenuList,\n NavigationMenuItem,\n NavigationMenuContent,\n NavigationMenuTrigger,\n NavigationMenuLink,\n NavigationMenuIndicator,\n NavigationMenuViewport,\n}\n","size_bytes":5128},"client/src/components/ui/pagination.tsx":{"content":"import * as React from \"react\"\nimport { ChevronLeft, ChevronRight, MoreHorizontal } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { ButtonProps, buttonVariants } from \"@/components/ui/button\"\n\nconst Pagination = ({ className, ...props }: React.ComponentProps<\"nav\">) => (\n <nav\n role=\"navigation\"\n aria-label=\"pagination\"\n className={cn(\"mx-auto flex w-full justify-center\", className)}\n {...props}\n />\n)\nPagination.displayName = \"Pagination\"\n\nconst PaginationContent = React.forwardRef<\n HTMLUListElement,\n React.ComponentProps<\"ul\">\n>(({ className, ...props }, ref) => (\n <ul\n ref={ref}\n className={cn(\"flex flex-row items-center gap-1\", className)}\n {...props}\n />\n))\nPaginationContent.displayName = \"PaginationContent\"\n\nconst PaginationItem = React.forwardRef<\n HTMLLIElement,\n React.ComponentProps<\"li\">\n>(({ className, ...props }, ref) => (\n <li ref={ref} className={cn(\"\", className)} {...props} />\n))\nPaginationItem.displayName = \"PaginationItem\"\n\ntype PaginationLinkProps = {\n isActive?: boolean\n} & Pick<ButtonProps, \"size\"> &\n React.ComponentProps<\"a\">\n\nconst PaginationLink = ({\n className,\n isActive,\n size = \"icon\",\n ...props\n}: PaginationLinkProps) => (\n <a\n aria-current={isActive ? \"page\" : undefined}\n className={cn(\n buttonVariants({\n variant: isActive ? \"outline\" : \"ghost\",\n size,\n }),\n className\n )}\n {...props}\n />\n)\nPaginationLink.displayName = \"PaginationLink\"\n\nconst PaginationPrevious = ({\n className,\n ...props\n}: React.ComponentProps<typeof PaginationLink>) => (\n <PaginationLink\n aria-label=\"Go to previous page\"\n size=\"default\"\n className={cn(\"gap-1 pl-2.5\", className)}\n {...props}\n >\n <ChevronLeft className=\"h-4 w-4\" />\n <span>Previous</span>\n </PaginationLink>\n)\nPaginationPrevious.displayName = \"PaginationPrevious\"\n\nconst PaginationNext = ({\n className,\n ...props\n}: React.ComponentProps<typeof PaginationLink>) => (\n <PaginationLink\n aria-label=\"Go to next page\"\n size=\"default\"\n className={cn(\"gap-1 pr-2.5\", className)}\n {...props}\n >\n <span>Next</span>\n <ChevronRight className=\"h-4 w-4\" />\n </PaginationLink>\n)\nPaginationNext.displayName = \"PaginationNext\"\n\nconst PaginationEllipsis = ({\n className,\n ...props\n}: React.ComponentProps<\"span\">) => (\n <span\n aria-hidden\n className={cn(\"flex h-9 w-9 items-center justify-center\", className)}\n {...props}\n >\n <MoreHorizontal className=\"h-4 w-4\" />\n <span className=\"sr-only\">More pages</span>\n </span>\n)\nPaginationEllipsis.displayName = \"PaginationEllipsis\"\n\nexport {\n Pagination,\n PaginationContent,\n PaginationEllipsis,\n PaginationItem,\n PaginationLink,\n PaginationNext,\n PaginationPrevious,\n}\n","size_bytes":2751},"client/src/components/ui/popover.tsx":{"content":"import * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Popover = PopoverPrimitive.Root\n\nconst PopoverTrigger = PopoverPrimitive.Trigger\n\nconst PopoverContent = React.forwardRef<\n React.ElementRef<typeof PopoverPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = \"center\", sideOffset = 4, ...props }, ref) => (\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n ref={ref}\n align={align}\n sideOffset={sideOffset}\n className={cn(\n \"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]\",\n className\n )}\n {...props}\n />\n </PopoverPrimitive.Portal>\n))\nPopoverContent.displayName = PopoverPrimitive.Content.displayName\n\nexport { Popover, PopoverTrigger, PopoverContent }\n","size_bytes":1280},"client/src/components/ui/progress.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Progress = React.forwardRef<\n React.ElementRef<typeof ProgressPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>\n>(({ className, value, ...props }, ref) => (\n <ProgressPrimitive.Root\n ref={ref}\n className={cn(\n \"relative h-4 w-full overflow-hidden rounded-full bg-secondary\",\n className\n )}\n {...props}\n >\n <ProgressPrimitive.Indicator\n className=\"h-full w-full flex-1 bg-primary transition-all\"\n style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n />\n </ProgressPrimitive.Root>\n))\nProgress.displayName = ProgressPrimitive.Root.displayName\n\nexport { Progress }\n","size_bytes":791},"client/src/components/ui/radio-group.tsx":{"content":"import * as React from \"react\"\nimport * as RadioGroupPrimitive from \"@radix-ui/react-radio-group\"\nimport { Circle } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst RadioGroup = React.forwardRef<\n React.ElementRef<typeof RadioGroupPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>\n>(({ className, ...props }, ref) => {\n return (\n <RadioGroupPrimitive.Root\n className={cn(\"grid gap-2\", className)}\n {...props}\n ref={ref}\n />\n )\n})\nRadioGroup.displayName = RadioGroupPrimitive.Root.displayName\n\nconst RadioGroupItem = React.forwardRef<\n React.ElementRef<typeof RadioGroupPrimitive.Item>,\n React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>\n>(({ className, ...props }, ref) => {\n return (\n <RadioGroupPrimitive.Item\n ref={ref}\n className={cn(\n \"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\",\n className\n )}\n {...props}\n >\n <RadioGroupPrimitive.Indicator className=\"flex items-center justify-center\">\n <Circle className=\"h-2.5 w-2.5 fill-current text-current\" />\n </RadioGroupPrimitive.Indicator>\n </RadioGroupPrimitive.Item>\n )\n})\nRadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName\n\nexport { RadioGroup, RadioGroupItem }\n","size_bytes":1467},"client/src/components/ui/resizable.tsx":{"content":"\"use client\"\n\nimport { GripVertical } from \"lucide-react\"\nimport * as ResizablePrimitive from \"react-resizable-panels\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ResizablePanelGroup = ({\n className,\n ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (\n <ResizablePrimitive.PanelGroup\n className={cn(\n \"flex h-full w-full data-[panel-group-direction=vertical]:flex-col\",\n className\n )}\n {...props}\n />\n)\n\nconst ResizablePanel = ResizablePrimitive.Panel\n\nconst ResizableHandle = ({\n withHandle,\n className,\n ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {\n withHandle?: boolean\n}) => (\n <ResizablePrimitive.PanelResizeHandle\n className={cn(\n \"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90\",\n className\n )}\n {...props}\n >\n {withHandle && (\n <div className=\"z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border\">\n <GripVertical className=\"h-2.5 w-2.5\" />\n </div>\n )}\n </ResizablePrimitive.PanelResizeHandle>\n)\n\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle }\n","size_bytes":1723},"client/src/components/ui/scroll-area.tsx":{"content":"import * as React from \"react\"\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ScrollArea = React.forwardRef<\n React.ElementRef<typeof ScrollAreaPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>\n>(({ className, children, ...props }, ref) => (\n <ScrollAreaPrimitive.Root\n ref={ref}\n className={cn(\"relative overflow-hidden\", className)}\n {...props}\n >\n <ScrollAreaPrimitive.Viewport className=\"h-full w-full rounded-[inherit]\">\n {children}\n </ScrollAreaPrimitive.Viewport>\n <ScrollBar />\n <ScrollAreaPrimitive.Corner />\n </ScrollAreaPrimitive.Root>\n))\nScrollArea.displayName = ScrollAreaPrimitive.Root.displayName\n\nconst ScrollBar = React.forwardRef<\n React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,\n React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>\n>(({ className, orientation = \"vertical\", ...props }, ref) => (\n <ScrollAreaPrimitive.ScrollAreaScrollbar\n ref={ref}\n orientation={orientation}\n className={cn(\n \"flex touch-none select-none transition-colors\",\n orientation === \"vertical\" &&\n \"h-full w-2.5 border-l border-l-transparent p-[1px]\",\n orientation === \"horizontal\" &&\n \"h-2.5 flex-col border-t border-t-transparent p-[1px]\",\n className\n )}\n {...props}\n >\n <ScrollAreaPrimitive.ScrollAreaThumb className=\"relative flex-1 rounded-full bg-border\" />\n </ScrollAreaPrimitive.ScrollAreaScrollbar>\n))\nScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName\n\nexport { ScrollArea, ScrollBar }\n","size_bytes":1642},"client/src/components/ui/select.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport * as SelectPrimitive from \"@radix-ui/react-select\"\nimport { Check, ChevronDown, ChevronUp } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Select = SelectPrimitive.Root\n\nconst SelectGroup = SelectPrimitive.Group\n\nconst SelectValue = SelectPrimitive.Value\n\nconst SelectTrigger = React.forwardRef<\n React.ElementRef<typeof SelectPrimitive.Trigger>,\n React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n <SelectPrimitive.Trigger\n ref={ref}\n className={cn(\n \"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1\",\n className\n )}\n {...props}\n >\n {children}\n <SelectPrimitive.Icon asChild>\n <ChevronDown className=\"h-4 w-4 opacity-50\" />\n </SelectPrimitive.Icon>\n </SelectPrimitive.Trigger>\n))\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName\n\nconst SelectScrollUpButton = React.forwardRef<\n React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,\n React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>\n>(({ className, ...props }, ref) => (\n <SelectPrimitive.ScrollUpButton\n ref={ref}\n className={cn(\n \"flex cursor-default items-center justify-center py-1\",\n className\n )}\n {...props}\n >\n <ChevronUp className=\"h-4 w-4\" />\n </SelectPrimitive.ScrollUpButton>\n))\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName\n\nconst SelectScrollDownButton = React.forwardRef<\n React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,\n React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>\n>(({ className, ...props }, ref) => (\n <SelectPrimitive.ScrollDownButton\n ref={ref}\n className={cn(\n \"flex cursor-default items-center justify-center py-1\",\n className\n )}\n {...props}\n >\n <ChevronDown className=\"h-4 w-4\" />\n </SelectPrimitive.ScrollDownButton>\n))\nSelectScrollDownButton.displayName =\n SelectPrimitive.ScrollDownButton.displayName\n\nconst SelectContent = React.forwardRef<\n React.ElementRef<typeof SelectPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>\n>(({ className, children, position = \"popper\", ...props }, ref) => (\n <SelectPrimitive.Portal>\n <SelectPrimitive.Content\n ref={ref}\n className={cn(\n \"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]\",\n position === \"popper\" &&\n \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n className\n )}\n position={position}\n {...props}\n >\n <SelectScrollUpButton />\n <SelectPrimitive.Viewport\n className={cn(\n \"p-1\",\n position === \"popper\" &&\n \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]\"\n )}\n >\n {children}\n </SelectPrimitive.Viewport>\n <SelectScrollDownButton />\n </SelectPrimitive.Content>\n </SelectPrimitive.Portal>\n))\nSelectContent.displayName = SelectPrimitive.Content.displayName\n\nconst SelectLabel = React.forwardRef<\n React.ElementRef<typeof SelectPrimitive.Label>,\n React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>\n>(({ className, ...props }, ref) => (\n <SelectPrimitive.Label\n ref={ref}\n className={cn(\"py-1.5 pl-8 pr-2 text-sm font-semibold\", className)}\n {...props}\n />\n))\nSelectLabel.displayName = SelectPrimitive.Label.displayName\n\nconst SelectItem = React.forwardRef<\n React.ElementRef<typeof SelectPrimitive.Item>,\n React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>\n>(({ className, children, ...props }, ref) => (\n <SelectPrimitive.Item\n ref={ref}\n className={cn(\n \"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n className\n )}\n {...props}\n >\n <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <SelectPrimitive.ItemIndicator>\n <Check className=\"h-4 w-4\" />\n </SelectPrimitive.ItemIndicator>\n </span>\n\n <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n </SelectPrimitive.Item>\n))\nSelectItem.displayName = SelectPrimitive.Item.displayName\n\nconst SelectSeparator = React.forwardRef<\n React.ElementRef<typeof SelectPrimitive.Separator>,\n React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n <SelectPrimitive.Separator\n ref={ref}\n className={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n {...props}\n />\n))\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName\n\nexport {\n Select,\n SelectGroup,\n SelectValue,\n SelectTrigger,\n SelectContent,\n SelectLabel,\n SelectItem,\n SelectSeparator,\n SelectScrollUpButton,\n SelectScrollDownButton,\n}\n","size_bytes":5741},"client/src/components/ui/separator.tsx":{"content":"import * as React from \"react\"\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Separator = React.forwardRef<\n React.ElementRef<typeof SeparatorPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>\n>(\n (\n { className, orientation = \"horizontal\", decorative = true, ...props },\n ref\n ) => (\n <SeparatorPrimitive.Root\n ref={ref}\n decorative={decorative}\n orientation={orientation}\n className={cn(\n \"shrink-0 bg-border\",\n orientation === \"horizontal\" ? \"h-[1px] w-full\" : \"h-full w-[1px]\",\n className\n )}\n {...props}\n />\n )\n)\nSeparator.displayName = SeparatorPrimitive.Root.displayName\n\nexport { Separator }\n","size_bytes":756},"client/src/components/ui/sheet.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport * as SheetPrimitive from \"@radix-ui/react-dialog\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Sheet = SheetPrimitive.Root\n\nconst SheetTrigger = SheetPrimitive.Trigger\n\nconst SheetClose = SheetPrimitive.Close\n\nconst SheetPortal = SheetPrimitive.Portal\n\nconst SheetOverlay = React.forwardRef<\n React.ElementRef<typeof SheetPrimitive.Overlay>,\n React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n <SheetPrimitive.Overlay\n className={cn(\n \"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n className\n )}\n {...props}\n ref={ref}\n />\n))\nSheetOverlay.displayName = SheetPrimitive.Overlay.displayName\n\nconst sheetVariants = cva(\n \"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500\",\n {\n variants: {\n side: {\n top: \"inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n bottom:\n \"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n left: \"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm\",\n right:\n \"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm\",\n },\n },\n defaultVariants: {\n side: \"right\",\n },\n }\n)\n\ninterface SheetContentProps\n extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,\n VariantProps<typeof sheetVariants> {}\n\nconst SheetContent = React.forwardRef<\n React.ElementRef<typeof SheetPrimitive.Content>,\n SheetContentProps\n>(({ side = \"right\", className, children, ...props }, ref) => (\n <SheetPortal>\n <SheetOverlay />\n <SheetPrimitive.Content\n ref={ref}\n className={cn(sheetVariants({ side }), className)}\n {...props}\n >\n {children}\n <SheetPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary\">\n <X className=\"h-4 w-4\" />\n <span className=\"sr-only\">Close</span>\n </SheetPrimitive.Close>\n </SheetPrimitive.Content>\n </SheetPortal>\n))\nSheetContent.displayName = SheetPrimitive.Content.displayName\n\nconst SheetHeader = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n <div\n className={cn(\n \"flex flex-col space-y-2 text-center sm:text-left\",\n className\n )}\n {...props}\n />\n)\nSheetHeader.displayName = \"SheetHeader\"\n\nconst SheetFooter = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n <div\n className={cn(\n \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n className\n )}\n {...props}\n />\n)\nSheetFooter.displayName = \"SheetFooter\"\n\nconst SheetTitle = React.forwardRef<\n React.ElementRef<typeof SheetPrimitive.Title>,\n React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>\n>(({ className, ...props }, ref) => (\n <SheetPrimitive.Title\n ref={ref}\n className={cn(\"text-lg font-semibold text-foreground\", className)}\n {...props}\n />\n))\nSheetTitle.displayName = SheetPrimitive.Title.displayName\n\nconst SheetDescription = React.forwardRef<\n React.ElementRef<typeof SheetPrimitive.Description>,\n React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>\n>(({ className, ...props }, ref) => (\n <SheetPrimitive.Description\n ref={ref}\n className={cn(\"text-sm text-muted-foreground\", className)}\n {...props}\n />\n))\nSheetDescription.displayName = SheetPrimitive.Description.displayName\n\nexport {\n Sheet,\n SheetPortal,\n SheetOverlay,\n SheetTrigger,\n SheetClose,\n SheetContent,\n SheetHeader,\n SheetFooter,\n SheetTitle,\n SheetDescription,\n}\n","size_bytes":4281},"client/src/components/ui/sidebar.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, VariantProps } from \"class-variance-authority\"\nimport { PanelLeftIcon } from \"lucide-react\"\n\nimport { useIsMobile } from \"@/hooks/use-mobile\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { Separator } from \"@/components/ui/separator\"\nimport {\n Sheet,\n SheetContent,\n SheetDescription,\n SheetHeader,\n SheetTitle,\n} from \"@/components/ui/sheet\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\nconst SIDEBAR_COOKIE_NAME = \"sidebar_state\"\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7\nconst SIDEBAR_WIDTH = \"16rem\"\nconst SIDEBAR_WIDTH_MOBILE = \"18rem\"\nconst SIDEBAR_WIDTH_ICON = \"3rem\"\nconst SIDEBAR_KEYBOARD_SHORTCUT = \"b\"\n\ntype SidebarContextProps = {\n state: \"expanded\" | \"collapsed\"\n open: boolean\n setOpen: (open: boolean) => void\n openMobile: boolean\n setOpenMobile: (open: boolean) => void\n isMobile: boolean\n toggleSidebar: () => void\n}\n\nconst SidebarContext = React.createContext<SidebarContextProps | null>(null)\n\nfunction useSidebar() {\n const context = React.useContext(SidebarContext)\n if (!context) {\n throw new Error(\"useSidebar must be used within a SidebarProvider.\")\n }\n\n return context\n}\n\nfunction SidebarProvider({\n defaultOpen = true,\n open: openProp,\n onOpenChange: setOpenProp,\n className,\n style,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n defaultOpen?: boolean\n open?: boolean\n onOpenChange?: (open: boolean) => void\n}) {\n const isMobile = useIsMobile()\n const [openMobile, setOpenMobile] = React.useState(false)\n\n // This is the internal state of the sidebar.\n // We use openProp and setOpenProp for control from outside the component.\n const [_open, _setOpen] = React.useState(defaultOpen)\n const open = openProp ?? _open\n const setOpen = React.useCallback(\n (value: boolean | ((value: boolean) => boolean)) => {\n const openState = typeof value === \"function\" ? value(open) : value\n if (setOpenProp) {\n setOpenProp(openState)\n } else {\n _setOpen(openState)\n }\n\n // This sets the cookie to keep the sidebar state.\n document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`\n },\n [setOpenProp, open]\n )\n\n // Helper to toggle the sidebar.\n const toggleSidebar = React.useCallback(() => {\n return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)\n }, [isMobile, setOpen, setOpenMobile])\n\n // Adds a keyboard shortcut to toggle the sidebar.\n React.useEffect(() => {\n const handleKeyDown = (event: KeyboardEvent) => {\n if (\n event.key === SIDEBAR_KEYBOARD_SHORTCUT &&\n (event.metaKey || event.ctrlKey)\n ) {\n event.preventDefault()\n toggleSidebar()\n }\n }\n\n window.addEventListener(\"keydown\", handleKeyDown)\n return () => window.removeEventListener(\"keydown\", handleKeyDown)\n }, [toggleSidebar])\n\n // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n // This makes it easier to style the sidebar with Tailwind classes.\n const state = open ? \"expanded\" : \"collapsed\"\n\n const contextValue = React.useMemo<SidebarContextProps>(\n () => ({\n state,\n open,\n setOpen,\n isMobile,\n openMobile,\n setOpenMobile,\n toggleSidebar,\n }),\n [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]\n )\n\n return (\n <SidebarContext.Provider value={contextValue}>\n <TooltipProvider delayDuration={0}>\n <div\n data-slot=\"sidebar-wrapper\"\n style={\n {\n \"--sidebar-width\": SIDEBAR_WIDTH,\n \"--sidebar-width-icon\": SIDEBAR_WIDTH_ICON,\n ...style,\n } as React.CSSProperties\n }\n className={cn(\n \"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full\",\n className\n )}\n {...props}\n >\n {children}\n </div>\n </TooltipProvider>\n </SidebarContext.Provider>\n )\n}\n\nfunction Sidebar({\n side = \"left\",\n variant = \"sidebar\",\n collapsible = \"offcanvas\",\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n side?: \"left\" | \"right\"\n variant?: \"sidebar\" | \"floating\" | \"inset\"\n collapsible?: \"offcanvas\" | \"icon\" | \"none\"\n}) {\n const { isMobile, state, openMobile, setOpenMobile } = useSidebar()\n\n if (collapsible === \"none\") {\n return (\n <div\n data-slot=\"sidebar\"\n className={cn(\n \"bg-sidebar text-sidebar-foreground flex h-full w-[var(--sidebar-width)] flex-col\",\n className\n )}\n {...props}\n >\n {children}\n </div>\n )\n }\n\n if (isMobile) {\n return (\n <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>\n <SheetContent\n data-sidebar=\"sidebar\"\n data-slot=\"sidebar\"\n data-mobile=\"true\"\n className=\"bg-sidebar text-sidebar-foreground w-[var(--sidebar-width)] p-0 [&>button]:hidden\"\n style={\n {\n \"--sidebar-width\": SIDEBAR_WIDTH_MOBILE,\n } as React.CSSProperties\n }\n side={side}\n >\n <SheetHeader className=\"sr-only\">\n <SheetTitle>Sidebar</SheetTitle>\n <SheetDescription>Displays the mobile sidebar.</SheetDescription>\n </SheetHeader>\n <div className=\"flex h-full w-full flex-col\">{children}</div>\n </SheetContent>\n </Sheet>\n )\n }\n\n return (\n <div\n className=\"group peer text-sidebar-foreground hidden md:block\"\n data-state={state}\n data-collapsible={state === \"collapsed\" ? collapsible : \"\"}\n data-variant={variant}\n data-side={side}\n data-slot=\"sidebar\"\n >\n {/* This is what handles the sidebar gap on desktop */}\n <div\n data-slot=\"sidebar-gap\"\n className={cn(\n \"relative w-[var(--sidebar-width)] bg-transparent transition-[width] duration-200 ease-linear\",\n \"group-data-[collapsible=offcanvas]:w-0\",\n \"group-data-[side=right]:rotate-180\",\n variant === \"floating\" || variant === \"inset\"\n ? \"group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+var(--spacing-4))]\"\n : \"group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)]\"\n )}\n />\n <div\n data-slot=\"sidebar-container\"\n className={cn(\n \"fixed inset-y-0 z-10 hidden h-svh w-[var(--sidebar-width)] transition-[left,right,width] duration-200 ease-linear md:flex\",\n side === \"left\"\n ? \"left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]\"\n : \"right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]\",\n // Adjust the padding for floating and inset variants.\n variant === \"floating\" || variant === \"inset\"\n ? \"p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+var(--spacing-4)+2px)]\"\n : \"group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)] group-data-[side=left]:border-r group-data-[side=right]:border-l\",\n className\n )}\n {...props}\n >\n <div\n data-sidebar=\"sidebar\"\n data-slot=\"sidebar-inner\"\n className=\"bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm\"\n >\n {children}\n </div>\n </div>\n </div>\n )\n}\n\nfunction SidebarTrigger({\n className,\n onClick,\n ...props\n}: React.ComponentProps<typeof Button>) {\n const { toggleSidebar } = useSidebar()\n\n return (\n <Button\n data-sidebar=\"trigger\"\n data-slot=\"sidebar-trigger\"\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\"h-7 w-7\", className)}\n onClick={(event) => {\n onClick?.(event)\n toggleSidebar()\n }}\n {...props}\n >\n <PanelLeftIcon />\n <span className=\"sr-only\">Toggle Sidebar</span>\n </Button>\n )\n}\n\nfunction SidebarRail({ className, ...props }: React.ComponentProps<\"button\">) {\n const { toggleSidebar } = useSidebar()\n\n // Note: Tailwind v3.4 doesn't support \"in-\" selectors. So the rail won't work perfectly.\n return (\n <button\n data-sidebar=\"rail\"\n data-slot=\"sidebar-rail\"\n aria-label=\"Toggle Sidebar\"\n tabIndex={-1}\n onClick={toggleSidebar}\n title=\"Toggle Sidebar\"\n className={cn(\n \"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex\",\n \"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize\",\n \"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize\",\n \"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full\",\n \"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2\",\n \"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction SidebarInset({ className, ...props }: React.ComponentProps<\"main\">) {\n return (\n <main\n data-slot=\"sidebar-inset\"\n className={cn(\n \"bg-background relative flex w-full flex-1 flex-col\",\n \"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction SidebarInput({\n className,\n ...props\n}: React.ComponentProps<typeof Input>) {\n return (\n <Input\n data-slot=\"sidebar-input\"\n data-sidebar=\"input\"\n className={cn(\"bg-background h-8 w-full shadow-none\", className)}\n {...props}\n />\n )\n}\n\nfunction SidebarHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"sidebar-header\"\n data-sidebar=\"header\"\n className={cn(\"flex flex-col gap-2 p-2\", className)}\n {...props}\n />\n )\n}\n\nfunction SidebarFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"sidebar-footer\"\n data-sidebar=\"footer\"\n className={cn(\"flex flex-col gap-2 p-2\", className)}\n {...props}\n />\n )\n}\n\nfunction SidebarSeparator({\n className,\n ...props\n}: React.ComponentProps<typeof Separator>) {\n return (\n <Separator\n data-slot=\"sidebar-separator\"\n data-sidebar=\"separator\"\n className={cn(\"bg-sidebar-border mx-2 w-auto\", className)}\n {...props}\n />\n )\n}\n\nfunction SidebarContent({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"sidebar-content\"\n data-sidebar=\"content\"\n className={cn(\n \"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction SidebarGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"sidebar-group\"\n data-sidebar=\"group\"\n className={cn(\"relative flex w-full min-w-0 flex-col p-2\", className)}\n {...props}\n />\n )\n}\n\nfunction SidebarGroupLabel({\n className,\n asChild = false,\n ...props\n}: React.ComponentProps<\"div\"> & { asChild?: boolean }) {\n const Comp = asChild ? Slot : \"div\"\n\n return (\n <Comp\n data-slot=\"sidebar-group-label\"\n data-sidebar=\"group-label\"\n className={cn(\n \"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0\",\n \"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction SidebarGroupAction({\n className,\n asChild = false,\n ...props\n}: React.ComponentProps<\"button\"> & { asChild?: boolean }) {\n const Comp = asChild ? Slot : \"button\"\n\n return (\n <Comp\n data-slot=\"sidebar-group-action\"\n data-sidebar=\"group-action\"\n className={cn(\n \"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n // Increases the hit area of the button on mobile.\n \"after:absolute after:-inset-2 md:after:hidden\",\n \"group-data-[collapsible=icon]:hidden\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction SidebarGroupContent({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"sidebar-group-content\"\n data-sidebar=\"group-content\"\n className={cn(\"w-full text-sm\", className)}\n {...props}\n />\n )\n}\n\nfunction SidebarMenu({ className, ...props }: React.ComponentProps<\"ul\">) {\n return (\n <ul\n data-slot=\"sidebar-menu\"\n data-sidebar=\"menu\"\n className={cn(\"flex w-full min-w-0 flex-col gap-1\", className)}\n {...props}\n />\n )\n}\n\nfunction SidebarMenuItem({ className, ...props }: React.ComponentProps<\"li\">) {\n return (\n <li\n data-slot=\"sidebar-menu-item\"\n data-sidebar=\"menu-item\"\n className={cn(\"group/menu-item relative\", className)}\n {...props}\n />\n )\n}\n\nconst sidebarMenuButtonVariants = cva(\n \"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:w-8! group-data-[collapsible=icon]:h-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n {\n variants: {\n variant: {\n default: \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n outline:\n \"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]\",\n },\n size: {\n default: \"h-8 text-sm\",\n sm: \"h-7 text-xs\",\n lg: \"h-12 text-sm group-data-[collapsible=icon]:p-0!\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n)\n\nfunction SidebarMenuButton({\n asChild = false,\n isActive = false,\n variant = \"default\",\n size = \"default\",\n tooltip,\n className,\n ...props\n}: React.ComponentProps<\"button\"> & {\n asChild?: boolean\n isActive?: boolean\n tooltip?: string | React.ComponentProps<typeof TooltipContent>\n} & VariantProps<typeof sidebarMenuButtonVariants>) {\n const Comp = asChild ? Slot : \"button\"\n const { isMobile, state } = useSidebar()\n\n const button = (\n <Comp\n data-slot=\"sidebar-menu-button\"\n data-sidebar=\"menu-button\"\n data-size={size}\n data-active={isActive}\n className={cn(sidebarMenuButtonVariants({ variant, size }), className)}\n {...props}\n />\n )\n\n if (!tooltip) {\n return button\n }\n\n if (typeof tooltip === \"string\") {\n tooltip = {\n children: tooltip,\n }\n }\n\n return (\n <Tooltip>\n <TooltipTrigger asChild>{button}</TooltipTrigger>\n <TooltipContent\n side=\"right\"\n align=\"center\"\n hidden={state !== \"collapsed\" || isMobile}\n {...tooltip}\n />\n </Tooltip>\n )\n}\n\nfunction SidebarMenuAction({\n className,\n asChild = false,\n showOnHover = false,\n ...props\n}: React.ComponentProps<\"button\"> & {\n asChild?: boolean\n showOnHover?: boolean\n}) {\n const Comp = asChild ? Slot : \"button\"\n\n return (\n <Comp\n data-slot=\"sidebar-menu-action\"\n data-sidebar=\"menu-action\"\n className={cn(\n \"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n // Increases the hit area of the button on mobile.\n \"after:absolute after:-inset-2 md:after:hidden\",\n \"peer-data-[size=sm]/menu-button:top-1\",\n \"peer-data-[size=default]/menu-button:top-1.5\",\n \"peer-data-[size=lg]/menu-button:top-2.5\",\n \"group-data-[collapsible=icon]:hidden\",\n showOnHover &&\n \"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction SidebarMenuBadge({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"sidebar-menu-badge\"\n data-sidebar=\"menu-badge\"\n className={cn(\n \"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none\",\n \"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground\",\n \"peer-data-[size=sm]/menu-button:top-1\",\n \"peer-data-[size=default]/menu-button:top-1.5\",\n \"peer-data-[size=lg]/menu-button:top-2.5\",\n \"group-data-[collapsible=icon]:hidden\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction SidebarMenuSkeleton({\n className,\n showIcon = false,\n ...props\n}: React.ComponentProps<\"div\"> & {\n showIcon?: boolean\n}) {\n // Random width between 50 to 90%.\n const width = React.useMemo(() => {\n return `${Math.floor(Math.random() * 40) + 50}%`\n }, [])\n\n return (\n <div\n data-slot=\"sidebar-menu-skeleton\"\n data-sidebar=\"menu-skeleton\"\n className={cn(\"flex h-8 items-center gap-2 rounded-md px-2\", className)}\n {...props}\n >\n {showIcon && (\n <Skeleton\n className=\"size-4 rounded-md\"\n data-sidebar=\"menu-skeleton-icon\"\n />\n )}\n <Skeleton\n className=\"h-4 max-w-[var(--skeleton-width)] flex-1\"\n data-sidebar=\"menu-skeleton-text\"\n style={\n {\n \"--skeleton-width\": width,\n } as React.CSSProperties\n }\n />\n </div>\n )\n}\n\nfunction SidebarMenuSub({ className, ...props }: React.ComponentProps<\"ul\">) {\n return (\n <ul\n data-slot=\"sidebar-menu-sub\"\n data-sidebar=\"menu-sub\"\n className={cn(\n \"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5\",\n \"group-data-[collapsible=icon]:hidden\",\n className\n )}\n {...props}\n />\n )\n}\n\nfunction SidebarMenuSubItem({\n className,\n ...props\n}: React.ComponentProps<\"li\">) {\n return (\n <li\n data-slot=\"sidebar-menu-sub-item\"\n data-sidebar=\"menu-sub-item\"\n className={cn(\"group/menu-sub-item relative\", className)}\n {...props}\n />\n )\n}\n\nfunction SidebarMenuSubButton({\n asChild = false,\n size = \"md\",\n isActive = false,\n className,\n ...props\n}: React.ComponentProps<\"a\"> & {\n asChild?: boolean\n size?: \"sm\" | \"md\"\n isActive?: boolean\n}) {\n const Comp = asChild ? Slot : \"a\"\n\n return (\n <Comp\n data-slot=\"sidebar-menu-sub-button\"\n data-sidebar=\"menu-sub-button\"\n data-size={size}\n data-active={isActive}\n className={cn(\n \"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline outline-2 outline-transparent outline-offset-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n \"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground\",\n size === \"sm\" && \"text-xs\",\n size === \"md\" && \"text-sm\",\n \"group-data-[collapsible=icon]:hidden\",\n className\n )}\n {...props}\n />\n )\n}\n\nexport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupAction,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarHeader,\n SidebarInput,\n SidebarInset,\n SidebarMenu,\n SidebarMenuAction,\n SidebarMenuBadge,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarMenuSkeleton,\n SidebarMenuSub,\n SidebarMenuSubButton,\n SidebarMenuSubItem,\n SidebarProvider,\n SidebarRail,\n SidebarSeparator,\n SidebarTrigger,\n useSidebar,\n}\n","size_bytes":21846},"client/src/components/ui/skeleton.tsx":{"content":"import { cn } from \"@/lib/utils\"\n\nfunction Skeleton({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) {\n return (\n <div\n className={cn(\"animate-pulse rounded-md bg-muted\", className)}\n {...props}\n />\n )\n}\n\nexport { Skeleton }\n","size_bytes":261},"client/src/components/ui/slider.tsx":{"content":"import * as React from \"react\"\nimport * as SliderPrimitive from \"@radix-ui/react-slider\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Slider = React.forwardRef<\n React.ElementRef<typeof SliderPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>\n>(({ className, ...props }, ref) => (\n <SliderPrimitive.Root\n ref={ref}\n className={cn(\n \"relative flex w-full touch-none select-none items-center\",\n className\n )}\n {...props}\n >\n <SliderPrimitive.Track className=\"relative h-2 w-full grow overflow-hidden rounded-full bg-secondary\">\n <SliderPrimitive.Range className=\"absolute h-full bg-primary\" />\n </SliderPrimitive.Track>\n <SliderPrimitive.Thumb className=\"block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\" />\n </SliderPrimitive.Root>\n))\nSlider.displayName = SliderPrimitive.Root.displayName\n\nexport { Slider }\n","size_bytes":1077},"client/src/components/ui/switch.tsx":{"content":"import * as React from \"react\"\nimport * as SwitchPrimitives from \"@radix-ui/react-switch\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Switch = React.forwardRef<\n React.ElementRef<typeof SwitchPrimitives.Root>,\n React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>\n>(({ className, ...props }, ref) => (\n <SwitchPrimitives.Root\n className={cn(\n \"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input\",\n className\n )}\n {...props}\n ref={ref}\n >\n <SwitchPrimitives.Thumb\n className={cn(\n \"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0\"\n )}\n />\n </SwitchPrimitives.Root>\n))\nSwitch.displayName = SwitchPrimitives.Root.displayName\n\nexport { Switch }\n","size_bytes":1139},"client/src/components/ui/table.tsx":{"content":"import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Table = React.forwardRef<\n HTMLTableElement,\n React.HTMLAttributes<HTMLTableElement>\n>(({ className, ...props }, ref) => (\n <div className=\"relative w-full overflow-auto\">\n <table\n ref={ref}\n className={cn(\"w-full caption-bottom text-sm\", className)}\n {...props}\n />\n </div>\n))\nTable.displayName = \"Table\"\n\nconst TableHeader = React.forwardRef<\n HTMLTableSectionElement,\n React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n <thead ref={ref} className={cn(\"[&_tr]:border-b\", className)} {...props} />\n))\nTableHeader.displayName = \"TableHeader\"\n\nconst TableBody = React.forwardRef<\n HTMLTableSectionElement,\n React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n <tbody\n ref={ref}\n className={cn(\"[&_tr:last-child]:border-0\", className)}\n {...props}\n />\n))\nTableBody.displayName = \"TableBody\"\n\nconst TableFooter = React.forwardRef<\n HTMLTableSectionElement,\n React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n <tfoot\n ref={ref}\n className={cn(\n \"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0\",\n className\n )}\n {...props}\n />\n))\nTableFooter.displayName = \"TableFooter\"\n\nconst TableRow = React.forwardRef<\n HTMLTableRowElement,\n React.HTMLAttributes<HTMLTableRowElement>\n>(({ className, ...props }, ref) => (\n <tr\n ref={ref}\n className={cn(\n \"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted\",\n className\n )}\n {...props}\n />\n))\nTableRow.displayName = \"TableRow\"\n\nconst TableHead = React.forwardRef<\n HTMLTableCellElement,\n React.ThHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n <th\n ref={ref}\n className={cn(\n \"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0\",\n className\n )}\n {...props}\n />\n))\nTableHead.displayName = \"TableHead\"\n\nconst TableCell = React.forwardRef<\n HTMLTableCellElement,\n React.TdHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n <td\n ref={ref}\n className={cn(\"p-4 align-middle [&:has([role=checkbox])]:pr-0\", className)}\n {...props}\n />\n))\nTableCell.displayName = \"TableCell\"\n\nconst TableCaption = React.forwardRef<\n HTMLTableCaptionElement,\n React.HTMLAttributes<HTMLTableCaptionElement>\n>(({ className, ...props }, ref) => (\n <caption\n ref={ref}\n className={cn(\"mt-4 text-sm text-muted-foreground\", className)}\n {...props}\n />\n))\nTableCaption.displayName = \"TableCaption\"\n\nexport {\n Table,\n TableHeader,\n TableBody,\n TableFooter,\n TableHead,\n TableRow,\n TableCell,\n TableCaption,\n}\n","size_bytes":2765},"client/src/components/ui/tabs.tsx":{"content":"import * as React from \"react\"\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Tabs = TabsPrimitive.Root\n\nconst TabsList = React.forwardRef<\n React.ElementRef<typeof TabsPrimitive.List>,\n React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n <TabsPrimitive.List\n ref={ref}\n className={cn(\n \"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground\",\n className\n )}\n {...props}\n />\n))\nTabsList.displayName = TabsPrimitive.List.displayName\n\nconst TabsTrigger = React.forwardRef<\n React.ElementRef<typeof TabsPrimitive.Trigger>,\n React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n <TabsPrimitive.Trigger\n ref={ref}\n className={cn(\n \"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 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 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm\",\n className\n )}\n {...props}\n />\n))\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName\n\nconst TabsContent = React.forwardRef<\n React.ElementRef<typeof TabsPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n <TabsPrimitive.Content\n ref={ref}\n className={cn(\n \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n className\n )}\n {...props}\n />\n))\nTabsContent.displayName = TabsPrimitive.Content.displayName\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent }\n","size_bytes":1883},"client/src/components/ui/textarea.tsx":{"content":"import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Textarea = React.forwardRef<\n HTMLTextAreaElement,\n React.ComponentProps<\"textarea\">\n>(({ className, ...props }, ref) => {\n return (\n <textarea\n className={cn(\n \"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background 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 md:text-sm\",\n className\n )}\n ref={ref}\n {...props}\n />\n )\n})\nTextarea.displayName = \"Textarea\"\n\nexport { Textarea }\n","size_bytes":689},"client/src/components/ui/toast.tsx":{"content":"import * as React from \"react\"\nimport * as ToastPrimitives from \"@radix-ui/react-toast\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ToastProvider = ToastPrimitives.Provider\n\nconst ToastViewport = React.forwardRef<\n React.ElementRef<typeof ToastPrimitives.Viewport>,\n React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>\n>(({ className, ...props }, ref) => (\n <ToastPrimitives.Viewport\n ref={ref}\n className={cn(\n \"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]\",\n className\n )}\n {...props}\n />\n))\nToastViewport.displayName = ToastPrimitives.Viewport.displayName\n\nconst toastVariants = cva(\n \"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full\",\n {\n variants: {\n variant: {\n default: \"border bg-background text-foreground\",\n destructive:\n \"destructive group border-destructive bg-destructive text-destructive-foreground\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n }\n)\n\nconst Toast = React.forwardRef<\n React.ElementRef<typeof ToastPrimitives.Root>,\n React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &\n VariantProps<typeof toastVariants>\n>(({ className, variant, ...props }, ref) => {\n return (\n <ToastPrimitives.Root\n ref={ref}\n className={cn(toastVariants({ variant }), className)}\n {...props}\n />\n )\n})\nToast.displayName = ToastPrimitives.Root.displayName\n\nconst ToastAction = React.forwardRef<\n React.ElementRef<typeof ToastPrimitives.Action>,\n React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>\n>(({ className, ...props }, ref) => (\n <ToastPrimitives.Action\n ref={ref}\n className={cn(\n \"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive\",\n className\n )}\n {...props}\n />\n))\nToastAction.displayName = ToastPrimitives.Action.displayName\n\nconst ToastClose = React.forwardRef<\n React.ElementRef<typeof ToastPrimitives.Close>,\n React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>\n>(({ className, ...props }, ref) => (\n <ToastPrimitives.Close\n ref={ref}\n className={cn(\n \"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600\",\n className\n )}\n toast-close=\"\"\n {...props}\n >\n <X className=\"h-4 w-4\" />\n </ToastPrimitives.Close>\n))\nToastClose.displayName = ToastPrimitives.Close.displayName\n\nconst ToastTitle = React.forwardRef<\n React.ElementRef<typeof ToastPrimitives.Title>,\n React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>\n>(({ className, ...props }, ref) => (\n <ToastPrimitives.Title\n ref={ref}\n className={cn(\"text-sm font-semibold\", className)}\n {...props}\n />\n))\nToastTitle.displayName = ToastPrimitives.Title.displayName\n\nconst ToastDescription = React.forwardRef<\n React.ElementRef<typeof ToastPrimitives.Description>,\n React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>\n>(({ className, ...props }, ref) => (\n <ToastPrimitives.Description\n ref={ref}\n className={cn(\"text-sm opacity-90\", className)}\n {...props}\n />\n))\nToastDescription.displayName = ToastPrimitives.Description.displayName\n\ntype ToastProps = React.ComponentPropsWithoutRef<typeof Toast>\n\ntype ToastActionElement = React.ReactElement<typeof ToastAction>\n\nexport {\n type ToastProps,\n type ToastActionElement,\n ToastProvider,\n ToastViewport,\n Toast,\n ToastTitle,\n ToastDescription,\n ToastClose,\n ToastAction,\n}\n","size_bytes":4845},"client/src/components/ui/toaster.tsx":{"content":"import { useToast } from \"@/hooks/use-toast\"\nimport {\n Toast,\n ToastClose,\n ToastDescription,\n ToastProvider,\n ToastTitle,\n ToastViewport,\n} from \"@/components/ui/toast\"\n\nexport function Toaster() {\n const { toasts } = useToast()\n\n return (\n <ToastProvider>\n {toasts.map(function ({ id, title, description, action, ...props }) {\n return (\n <Toast key={id} {...props}>\n <div className=\"grid gap-1\">\n {title && <ToastTitle>{title}</ToastTitle>}\n {description && (\n <ToastDescription>{description}</ToastDescription>\n )}\n </div>\n {action}\n <ToastClose />\n </Toast>\n )\n })}\n <ToastViewport />\n </ToastProvider>\n )\n}\n","size_bytes":772},"client/src/components/ui/toggle-group.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport * as ToggleGroupPrimitive from \"@radix-ui/react-toggle-group\"\nimport { type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { toggleVariants } from \"@/components/ui/toggle\"\n\nconst ToggleGroupContext = React.createContext<\n VariantProps<typeof toggleVariants>\n>({\n size: \"default\",\n variant: \"default\",\n})\n\nconst ToggleGroup = React.forwardRef<\n React.ElementRef<typeof ToggleGroupPrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &\n VariantProps<typeof toggleVariants>\n>(({ className, variant, size, children, ...props }, ref) => (\n <ToggleGroupPrimitive.Root\n ref={ref}\n className={cn(\"flex items-center justify-center gap-1\", className)}\n {...props}\n >\n <ToggleGroupContext.Provider value={{ variant, size }}>\n {children}\n </ToggleGroupContext.Provider>\n </ToggleGroupPrimitive.Root>\n))\n\nToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName\n\nconst ToggleGroupItem = React.forwardRef<\n React.ElementRef<typeof ToggleGroupPrimitive.Item>,\n React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &\n VariantProps<typeof toggleVariants>\n>(({ className, children, variant, size, ...props }, ref) => {\n const context = React.useContext(ToggleGroupContext)\n\n return (\n <ToggleGroupPrimitive.Item\n ref={ref}\n className={cn(\n toggleVariants({\n variant: context.variant || variant,\n size: context.size || size,\n }),\n className\n )}\n {...props}\n >\n {children}\n </ToggleGroupPrimitive.Item>\n )\n})\n\nToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName\n\nexport { ToggleGroup, ToggleGroupItem }\n","size_bytes":1753},"client/src/components/ui/toggle.tsx":{"content":"import * as React from \"react\"\nimport * as TogglePrimitive from \"@radix-ui/react-toggle\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst toggleVariants = cva(\n \"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2\",\n {\n variants: {\n variant: {\n default: \"bg-transparent\",\n outline:\n \"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground\",\n },\n size: {\n default: \"h-10 px-3 min-w-10\",\n sm: \"h-9 px-2.5 min-w-9\",\n lg: \"h-11 px-5 min-w-11\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n)\n\nconst Toggle = React.forwardRef<\n React.ElementRef<typeof TogglePrimitive.Root>,\n React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &\n VariantProps<typeof toggleVariants>\n>(({ className, variant, size, ...props }, ref) => (\n <TogglePrimitive.Root\n ref={ref}\n className={cn(toggleVariants({ variant, size, className }))}\n {...props}\n />\n))\n\nToggle.displayName = TogglePrimitive.Root.displayName\n\nexport { Toggle, toggleVariants }\n","size_bytes":1527},"client/src/components/ui/tooltip.tsx":{"content":"\"use client\"\n\nimport * as React from \"react\"\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst TooltipProvider = TooltipPrimitive.Provider\n\nconst Tooltip = TooltipPrimitive.Root\n\nconst TooltipTrigger = TooltipPrimitive.Trigger\n\nconst TooltipContent = React.forwardRef<\n React.ElementRef<typeof TooltipPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n <TooltipPrimitive.Content\n ref={ref}\n sideOffset={sideOffset}\n className={cn(\n \"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]\",\n className\n )}\n {...props}\n />\n))\nTooltipContent.displayName = TooltipPrimitive.Content.displayName\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n","size_bytes":1209},"client/src/components/BottomTabBar.tsx":{"content":"import { Users, TrendingUp, Building2, ChevronLeft, ChevronRight, MessageCircle, Share2, Bookmark } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { useLocation } from \"wouter\";\nimport { useState, useEffect } from \"react\";\nimport TextSizeIcon from \"./TextSizeIcon\";\n\n// Sapiens logo from attached assets\nconst sapiensLogo = \"/api/assets/sapiens-logo.png\";\n\ninterface BottomTabBarProps {\n activeTab?: 'people' | 'topics' | 'companies';\n onTabChange?: (tab: 'people' | 'topics' | 'companies') => void;\n className?: string;\n variant?: 'tabs' | 'nav';\n onTitleClick?: () => void; // For nav variant Sapiens title click\n onBackClick?: () => void; // Custom back button handler\n // Article action props for nav variant\n commentCount?: number;\n isBookmarked?: boolean;\n onCommentClick?: () => void;\n onShareClick?: () => void;\n onBookmarkClick?: () => void;\n onTextSizeClick?: () => void;\n}\n\nconst tabs = [\n { id: 'people' as const, label: 'People', icon: Users },\n { id: 'topics' as const, label: 'Topics', icon: TrendingUp },\n { id: 'companies' as const, label: 'Companies', icon: Building2 },\n];\n\nexport default function BottomTabBar({\n activeTab,\n onTabChange,\n className = \"\",\n variant = 'tabs',\n onTitleClick,\n onBackClick,\n commentCount,\n isBookmarked,\n onCommentClick,\n onShareClick,\n onBookmarkClick,\n onTextSizeClick,\n}: BottomTabBarProps) {\n const [, setLocation] = useLocation();\n const [canGoForward, setCanGoForward] = useState(false);\n \n useEffect(() => {\n const handlePopState = () => {\n // After a popstate (back button), we can potentially go forward\n setCanGoForward(true);\n };\n \n const handleBeforeUnload = () => {\n // Reset forward state when navigating away\n setCanGoForward(false);\n };\n \n window.addEventListener('popstate', handlePopState);\n window.addEventListener('beforeunload', handleBeforeUnload);\n \n return () => {\n window.removeEventListener('popstate', handlePopState);\n window.removeEventListener('beforeunload', handleBeforeUnload);\n };\n }, []);\n \n if (variant === 'nav') {\n // Navigation mode with back/forward buttons and action icons\n const hasActionIcons = onCommentClick || onShareClick || onBookmarkClick || onTextSizeClick;\n \n return (\n <div className={`fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur border-t border-border z-40 ${className}`}>\n <div className=\"flex items-center justify-between px-2 py-2\">\n {/* Back button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={onBackClick || (() => window.history.back())}\n data-testid=\"button-nav-back\"\n className=\"h-9 w-9 opacity-100\"\n >\n <ChevronLeft className=\"h-5 w-5\" />\n </Button>\n \n {/* Center content - Action icons OR Sapiens logo */}\n {hasActionIcons ? (\n <div className=\"flex items-center gap-1\">\n {/* Comments Button */}\n {onCommentClick && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"relative h-9 w-9\"\n onClick={onCommentClick}\n data-testid=\"button-comments\"\n >\n <MessageCircle className=\"h-5 w-5\" />\n {commentCount !== undefined && commentCount > 0 && (\n <span className=\"absolute -top-1 -right-1 bg-primary text-primary-foreground text-xs rounded-full h-4 min-w-[16px] flex items-center justify-center px-1\" data-testid=\"text-comment-count\">\n {commentCount}\n </span>\n )}\n </Button>\n )}\n\n {/* Share Button */}\n {onShareClick && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-9 w-9\"\n onClick={onShareClick}\n data-testid=\"button-share\"\n >\n <Share2 className=\"h-5 w-5\" />\n </Button>\n )}\n\n {/* Bookmark Button */}\n {onBookmarkClick && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-9 w-9\"\n onClick={onBookmarkClick}\n data-testid=\"button-bookmark\"\n >\n <Bookmark\n className={`h-5 w-5 ${isBookmarked ? \"fill-current\" : \"\"}`}\n />\n </Button>\n )}\n\n {/* Text Size Button */}\n {onTextSizeClick && (\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-9 w-9\"\n onClick={onTextSizeClick}\n data-testid=\"button-text-size\"\n >\n <TextSizeIcon className=\"h-5 w-5\" />\n </Button>\n )}\n </div>\n ) : (\n <button\n onClick={onTitleClick || (() => setLocation('/'))}\n className=\"hover:opacity-80 transition-opacity cursor-pointer\"\n data-testid=\"button-nav-logo\"\n >\n <div className=\"h-4 flex items-center\">\n <img \n src={sapiensLogo} \n alt=\"SAPIENS\" \n className=\"h-full w-auto object-contain\"\n />\n </div>\n </button>\n )}\n \n {/* Forward button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={() => window.history.forward()}\n data-testid=\"button-nav-forward\"\n className={`h-9 w-9 ${canGoForward ? 'opacity-100' : 'opacity-40'}`}\n disabled={!canGoForward}\n >\n <ChevronRight className=\"h-5 w-5\" />\n </Button>\n </div>\n </div>\n );\n }\n\n // Default tabs mode\n return (\n <div className={`fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur border-t border-border z-50 ${className}`}>\n <div className=\"flex items-center justify-around px-2 py-1\">\n {tabs.map((tab) => {\n const Icon = tab.icon;\n const isActive = activeTab === tab.id;\n \n return (\n <Button\n key={tab.id}\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => onTabChange?.(tab.id)}\n className={`flex flex-col items-center gap-1 h-12 px-3 ${\n isActive \n ? 'text-primary' \n : 'text-muted-foreground hover:text-foreground'\n }`}\n data-testid={`tab-${tab.id}`}\n >\n <Icon className={`h-5 w-5 ${isActive ? 'fill-current' : ''}`} />\n <span className=\"text-xs font-medium\">{tab.label}</span>\n </Button>\n );\n })}\n </div>\n </div>\n );\n}","size_bytes":7076},"client/src/components/FeedItem.tsx":{"content":"import { MoreVertical, Play, Pause, ImageIcon, Loader2 } from \"lucide-react\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Button } from \"@/components/ui/button\";\nimport { formatTimeAgo } from \"@/lib/utils\";\nimport { useTTS } from \"@/hooks/useTTS\";\nimport AvatarPreviewDialog from \"@/components/AvatarPreviewDialog\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { apiRequest } from \"@/lib/queryClient\";\nimport { useState } from \"react\";\nimport { useToast } from \"@/hooks/use-toast\";\n\ninterface FeedItemProps {\n id: string;\n title: string;\n summary: string;\n thumbnail: string;\n publishedAt: Date;\n timeAgo?: string; // Server-provided time ago text\n outletName: string;\n outletAvatar?: string;\n viewCount: number;\n category: string;\n onClick: (id: string) => void;\n onOutletClick: (outletId: string) => void;\n onPlayClick: (id: string) => void;\n outletId: string;\n className?: string;\n}\n\nfunction formatViewCount(count: number): string {\n if (count < 1000) return count.toString();\n if (count < 1000000) return `${Math.floor(count / 100) / 10}K`;\n return `${Math.floor(count / 100000) / 10}M`;\n}\n\nexport default function FeedItem({\n id,\n title,\n summary,\n thumbnail,\n publishedAt,\n timeAgo,\n outletName,\n outletAvatar,\n viewCount,\n category,\n onClick,\n onOutletClick,\n onPlayClick,\n outletId,\n className = \"\",\n}: FeedItemProps) {\n const { isPlaying, isPaused, toggle } = useTTS();\n const [imageError, setImageError] = useState(false);\n const queryClient = useQueryClient();\n const { toast } = useToast();\n \n const getInitials = (name: string) => {\n return name\n .split(' ')\n .map(word => word[0])\n .join('')\n .toUpperCase()\n .slice(0, 2);\n };\n\n const needsThumbnail = () => {\n return !thumbnail || \n thumbnail === '/api/assets/default-article.png' ||\n thumbnail.trim() === '' ||\n imageError;\n };\n\n // Mutation to generate thumbnail for this article\n const generateThumbnailMutation = useMutation({\n mutationFn: async () => {\n const response = await apiRequest('POST', `/api/articles/${id}/generate-thumbnail`, {});\n return response.json();\n },\n onSuccess: (data) => {\n // Refetch the feed to update the thumbnail\n queryClient.invalidateQueries({ queryKey: ['/api/feed'] });\n queryClient.invalidateQueries({ queryKey: ['/api/articles'] });\n queryClient.invalidateQueries({ queryKey: ['/api/articles', id] });\n setImageError(false);\n \n toast({\n title: \"Thumbnail Generated\",\n description: \"AI thumbnail generated successfully for this article.\",\n });\n },\n onError: (error) => {\n console.error('Error generating thumbnail:', error);\n toast({\n title: \"Generation Failed\",\n description: error instanceof Error ? error.message : \"Failed to generate thumbnail. Please try again.\",\n variant: \"destructive\",\n });\n },\n });\n\n const handlePlayAudio = (e: React.MouseEvent) => {\n e.stopPropagation(); // Prevent thumbnail click\n onPlayClick(id);\n };\n\n const handleGenerateThumbnail = (e: React.MouseEvent) => {\n e.stopPropagation(); // Prevent thumbnail click\n generateThumbnailMutation.mutate();\n };\n\n return (\n <div className={`bg-background pb-4 ${className}`}>\n {/* Thumbnail */}\n <div \n className=\"aspect-video relative overflow-hidden rounded-lg mb-3 cursor-pointer group\"\n onClick={() => onClick(id)}\n data-testid={`thumbnail-${id}`}\n >\n <img\n src={thumbnail}\n alt={title}\n className=\"w-full h-full object-cover\"\n onError={() => setImageError(true)}\n />\n \n {/* Generate Thumbnail Button for missing thumbnails */}\n {needsThumbnail() && (\n <div className=\"absolute top-2 right-2\">\n <Button\n variant=\"secondary\"\n size=\"icon\"\n className=\"h-8 w-8 backdrop-blur-sm opacity-80 hover:opacity-100\"\n onClick={handleGenerateThumbnail}\n disabled={generateThumbnailMutation.isPending}\n data-testid={`button-generate-thumbnail-${id}`}\n >\n {generateThumbnailMutation.isPending ? (\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n ) : (\n <ImageIcon className=\"h-4 w-4\" />\n )}\n </Button>\n </div>\n )}\n \n {/* Play/Pause Button */}\n <div className=\"absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity\">\n <Button\n variant=\"secondary\"\n size=\"icon\"\n onClick={handlePlayAudio}\n className=\"h-12 w-12 rounded-full backdrop-blur-sm\"\n data-testid={`button-play-${id}`}\n >\n {isPlaying && !isPaused ? (\n <Pause className=\"h-6 w-6\" />\n ) : (\n <Play className=\"h-6 w-6\" />\n )}\n </Button>\n </div>\n </div>\n \n {/* Content below thumbnail */}\n <div className=\"flex gap-3\">\n {/* Outlet Avatar */}\n <div className=\"flex-shrink-0\">\n <AvatarPreviewDialog\n avatarSrc={outletAvatar}\n avatarAlt={outletName}\n fallbackText={getInitials(outletName)}\n title={`${outletName} 프로필 사진`}\n >\n <div \n className=\"cursor-pointer\"\n onClick={(e) => {\n e.stopPropagation();\n onOutletClick(outletId);\n }}\n data-testid={`avatar-${id}`}\n >\n <Avatar className=\"h-9 w-9 hover:ring-2 hover:ring-primary/20 transition-all\">\n <AvatarImage src={outletAvatar} alt={outletName} />\n <AvatarFallback className=\"text-xs font-medium\">\n {getInitials(outletName)}\n </AvatarFallback>\n </Avatar>\n </div>\n </AvatarPreviewDialog>\n </div>\n \n {/* Title and metadata */}\n <div className=\"flex-1 min-w-0\">\n <h3 \n className=\"font-medium text-sm leading-tight line-clamp-2 mb-1 cursor-pointer hover:text-primary/80 transition-colors\"\n onClick={() => onClick(id)}\n data-testid={`title-${id}`}\n >\n {title}\n </h3>\n \n <div className=\"flex items-center text-xs text-muted-foreground space-x-1\">\n <span \n className=\"cursor-pointer hover:text-foreground transition-colors\"\n onClick={() => onOutletClick(outletId)}\n data-testid={`outlet-${id}`}\n >\n {outletName}\n </span>\n <span>•</span>\n <span data-testid={`views-${id}`}>\n {formatViewCount(viewCount)} views\n </span>\n <span>•</span>\n <span data-testid={`time-${id}`}>\n {timeAgo || formatTimeAgo(publishedAt)}\n </span>\n </div>\n </div>\n \n {/* More options button */}\n <div className=\"flex-shrink-0\">\n <Button \n variant=\"ghost\" \n size=\"icon\"\n className=\"h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity\"\n data-testid={`more-${id}`}\n >\n <MoreVertical className=\"h-4 w-4\" />\n </Button>\n </div>\n </div>\n </div>\n );\n}","size_bytes":7585},"client/src/components/FeedList.tsx":{"content":"import { useState, useEffect, useRef } from \"react\";\nimport { useLocation } from \"wouter\";\nimport FeedItem from \"./FeedItem\";\nimport ArticlePopup from \"./ArticlePopup\";\nimport { useFeed, useIncrementView } from \"@/hooks/useApi\";\n\ninterface FeedListProps {\n filter: 'all' | 'people' | 'topics' | 'companies';\n className?: string;\n}\n\nexport default function FeedList({ \n filter, \n className = \"\" \n}: FeedListProps) {\n const [, setLocation] = useLocation();\n const { \n data, \n isLoading, \n fetchNextPage, \n hasNextPage, \n isFetchingNextPage \n } = useFeed(filter);\n const incrementViewMutation = useIncrementView(filter);\n \n // Intersection observer for infinite scroll\n const loadMoreRef = useRef<HTMLDivElement>(null);\n \n useEffect(() => {\n if (!loadMoreRef.current || !hasNextPage || isFetchingNextPage) return;\n \n const observer = new IntersectionObserver(\n (entries) => {\n if (entries[0].isIntersecting) {\n fetchNextPage();\n }\n },\n { threshold: 0.1 }\n );\n \n observer.observe(loadMoreRef.current);\n \n return () => observer.disconnect();\n }, [hasNextPage, isFetchingNextPage, fetchNextPage]);\n \n const handleArticleClick = (articleId: string) => {\n // Increment view count\n incrementViewMutation.mutate(articleId);\n // Navigate to article page\n setLocation(`/article/${articleId}`);\n };\n \n const handleOutletClick = (outletId: string) => {\n setLocation(`/outlet/${outletId}`);\n };\n\n const [selectedArticleId, setSelectedArticleId] = useState<string | null>(null);\n\n const handlePlayClick = (articleId: string) => {\n setSelectedArticleId(articleId);\n };\n\n const handleClosePopup = () => {\n setSelectedArticleId(null);\n };\n \n if (isLoading) {\n return (\n <div className={`space-y-4 p-4 ${className}`}>\n {Array.from({ length: 5 }).map((_, index) => (\n <div key={index} className=\"animate-pulse\">\n <div className=\"aspect-video bg-muted rounded-lg mb-3\" />\n <div className=\"flex gap-3\">\n <div className=\"w-9 h-9 bg-muted rounded-full flex-shrink-0\" />\n <div className=\"flex-1 space-y-2\">\n <div className=\"h-4 bg-muted rounded w-3/4\" />\n <div className=\"h-3 bg-muted rounded w-1/2\" />\n </div>\n </div>\n </div>\n ))}\n </div>\n );\n }\n \n if (!data?.pages?.length) {\n return (\n <div className={`flex items-center justify-center py-12 text-center ${className}`}>\n <div className=\"space-y-2\">\n <p className=\"text-muted-foreground\">No articles available</p>\n <p className=\"text-sm text-muted-foreground\">Check back later for new content</p>\n </div>\n </div>\n );\n }\n \n return (\n <div className={`space-y-6 ${className}`}>\n {data.pages.map((page: any, pageIndex: number) => (\n <div key={pageIndex} className=\"space-y-6\">\n {page?.items?.map((article: any) => (\n <FeedItem\n key={article.id}\n id={article.id}\n title={article.title}\n summary={article.summary}\n thumbnail={article.thumbnail}\n publishedAt={new Date(article.publishedAt)}\n outletName={article.outletName || \"Unknown\"}\n outletAvatar={article.outletAvatar}\n viewCount={article.viewCount || 0}\n category={article.category || \"general\"}\n outletId={article.outletId}\n onClick={handleArticleClick}\n onOutletClick={handleOutletClick}\n onPlayClick={handlePlayClick}\n />\n ))}\n </div>\n ))}\n \n {/* Infinite scroll trigger */}\n {hasNextPage && (\n <div \n ref={loadMoreRef} \n className=\"flex justify-center py-8\"\n >\n {isFetchingNextPage ? (\n <div className=\"flex items-center gap-2 text-muted-foreground\">\n <div className=\"w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin\" />\n <span className=\"text-sm\">Loading more articles...</span>\n </div>\n ) : (\n <div className=\"w-1 h-1\" /> // Small trigger element\n )}\n </div>\n )}\n \n {/* Article Popup */}\n <ArticlePopup \n articleId={selectedArticleId || ''}\n isOpen={!!selectedArticleId}\n onClose={handleClosePopup}\n />\n </div>\n );\n}","size_bytes":4496},"client/src/components/FilterChips.tsx":{"content":"import { Button } from \"@/components/ui/button\";\n\ninterface FilterChipsProps {\n activeFilter: 'all' | 'people' | 'topics' | 'companies';\n onFilterChange: (filter: 'all' | 'people' | 'topics' | 'companies') => void;\n className?: string;\n}\n\nconst filterOptions = [\n { value: 'all' as const, label: 'All' },\n { value: 'people' as const, label: 'People' },\n { value: 'topics' as const, label: 'Topics' },\n { value: 'companies' as const, label: 'Companies' },\n];\n\nexport default function FilterChips({\n activeFilter,\n onFilterChange,\n className = \"\",\n}: FilterChipsProps) {\n return (\n <div className={`flex gap-2 overflow-x-auto scrollbar-hide px-4 py-2 ${className}`}>\n {filterOptions.map((filter) => (\n <Button\n key={filter.value}\n variant={activeFilter === filter.value ? \"default\" : \"secondary\"}\n size=\"sm\"\n onClick={() => onFilterChange(filter.value)}\n className=\"whitespace-nowrap flex-shrink-0 h-8\"\n data-testid={`filter-${filter.value}`}\n >\n {filter.label}\n </Button>\n ))}\n </div>\n );\n}","size_bytes":1101},"client/src/hooks/useTTS.ts":{"content":"import { useState, useCallback, useRef, useEffect } from 'react';\n\ninterface TTSOptions {\n voice?: 'nova' | 'alloy' | 'echo' | 'fable' | 'onyx' | 'shimmer';\n speed?: number;\n volume?: number;\n}\n\nexport function useTTS(options: TTSOptions = {}) {\n const [isPlaying, setIsPlaying] = useState(false);\n const [isPaused, setIsPaused] = useState(false);\n const [isLoading, setIsLoading] = useState(false);\n const audioRef = useRef<HTMLAudioElement | null>(null);\n const currentArticleId = useRef<string | null>(null);\n const audioUrlRef = useRef<string | null>(null);\n const abortControllerRef = useRef<AbortController | null>(null);\n \n const {\n voice = 'nova', // Professional female voice - great for news\n speed = 1.0,\n volume = 1.0\n } = options;\n\n const cleanupAudio = useCallback(() => {\n // Clean up current audio\n if (audioRef.current) {\n audioRef.current.pause();\n audioRef.current = null;\n }\n \n // Cancel legacy speech synthesis\n if ('speechSynthesis' in window) {\n window.speechSynthesis.cancel();\n }\n \n // Revoke previous blob URL to prevent memory leaks\n if (audioUrlRef.current) {\n URL.revokeObjectURL(audioUrlRef.current);\n audioUrlRef.current = null;\n }\n \n // Cancel any ongoing request\n if (abortControllerRef.current) {\n abortControllerRef.current.abort();\n abortControllerRef.current = null;\n }\n }, []);\n\n const playArticleAudio = useCallback(async (articleId: string) => {\n try {\n setIsLoading(true);\n \n // Cancel any ongoing requests and cleanup\n cleanupAudio();\n \n // Create new abort controller for this request\n const abortController = new AbortController();\n abortControllerRef.current = abortController;\n \n // Generate speech using OpenAI TTS API\n const response = await fetch(`/api/articles/${articleId}/speech`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n voice,\n speed\n }),\n signal: abortController.signal,\n });\n \n if (!response.ok) {\n throw new Error('Failed to generate speech');\n }\n \n // Check if request was aborted\n if (abortController.signal.aborted) {\n return;\n }\n\n // Get audio blob\n const audioBlob = await response.blob();\n const audioUrl = URL.createObjectURL(audioBlob);\n audioUrlRef.current = audioUrl;\n \n // Create audio element\n const audio = new Audio(audioUrl);\n audio.volume = volume;\n audioRef.current = audio;\n currentArticleId.current = articleId;\n \n // Set up event listeners for professional audio control\n const handleLoadStart = () => setIsLoading(true);\n const handleCanPlayThrough = () => setIsLoading(false);\n const handlePlay = () => {\n setIsPlaying(true);\n setIsPaused(false);\n setIsLoading(false);\n };\n const handlePause = () => setIsPaused(true);\n const handleEnded = () => {\n setIsPlaying(false);\n setIsPaused(false);\n cleanupAudio();\n };\n const handleError = () => {\n setIsPlaying(false);\n setIsPaused(false);\n setIsLoading(false);\n console.error('Audio playback error');\n cleanupAudio();\n };\n \n audio.addEventListener('loadstart', handleLoadStart);\n audio.addEventListener('canplaythrough', handleCanPlayThrough);\n audio.addEventListener('play', handlePlay);\n audio.addEventListener('pause', handlePause);\n audio.addEventListener('ended', handleEnded);\n audio.addEventListener('error', handleError);\n \n // Start playback\n await audio.play();\n \n } catch (error) {\n if (error.name === 'AbortError') {\n console.log('Audio generation was cancelled');\n } else {\n console.error('Error playing article audio:', error);\n }\n setIsPlaying(false);\n setIsPaused(false);\n setIsLoading(false);\n cleanupAudio();\n }\n }, [voice, speed, volume, cleanupAudio]);\n\n const speak = useCallback((text: string) => {\n // For backward compatibility - this would be for article IDs now\n console.warn('useTTS.speak() is deprecated. Use playArticleAudio() with article ID instead.');\n }, []);\n\n const stop = useCallback(() => {\n setIsPlaying(false);\n setIsPaused(false);\n cleanupAudio();\n }, [cleanupAudio]);\n\n const pause = useCallback(() => {\n if (audioRef.current && !audioRef.current.paused) {\n audioRef.current.pause();\n setIsPaused(true);\n }\n }, []);\n\n const resume = useCallback(() => {\n if (audioRef.current && audioRef.current.paused && isPlaying) {\n audioRef.current.play();\n setIsPaused(false);\n }\n }, [isPlaying]);\n\n // Legacy function for backward compatibility with text-based speech\n const speakText = useCallback((text: string) => {\n if (!('speechSynthesis' in window)) {\n console.warn('Speech synthesis not supported');\n return;\n }\n\n // Stop any current speech\n cleanupAudio();\n window.speechSynthesis.cancel();\n \n const utterance = new SpeechSynthesisUtterance(text);\n utterance.rate = speed;\n utterance.volume = volume;\n \n utterance.onstart = () => {\n setIsPlaying(true);\n setIsPaused(false);\n };\n \n utterance.onend = () => {\n setIsPlaying(false);\n setIsPaused(false);\n };\n \n utterance.onerror = () => {\n setIsPlaying(false);\n setIsPaused(false);\n };\n \n window.speechSynthesis.speak(utterance);\n }, [speed, volume, cleanupAudio]);\n\n const toggle = useCallback((textOrId: string) => {\n if (isLoading) return; // Don't allow toggle while loading\n \n // Check if this looks like an article ID (short UUID-like string) or text (longer content)\n const isArticleId = textOrId.length < 100 && !textOrId.includes(' ');\n \n if (isArticleId) {\n // New OpenAI TTS API for full articles\n if (currentArticleId.current === textOrId && isPlaying) {\n if (isPaused) {\n resume();\n } else {\n pause();\n }\n } else {\n // Play new article or start playing\n playArticleAudio(textOrId);\n }\n } else {\n // Legacy text-to-speech for shorter content like feed items\n if (isPlaying) {\n if (isPaused && 'speechSynthesis' in window) {\n window.speechSynthesis.resume();\n setIsPaused(false);\n } else {\n if (audioRef.current) {\n pause();\n } else if ('speechSynthesis' in window) {\n window.speechSynthesis.pause();\n setIsPaused(true);\n }\n }\n } else {\n speakText(textOrId);\n }\n }\n }, [isPlaying, isPaused, isLoading, playArticleAudio, pause, resume, speakText]);\n\n // Clean up on unmount\n useEffect(() => {\n return () => {\n cleanupAudio();\n };\n }, [cleanupAudio]);\n\n return {\n isPlaying,\n isPaused,\n isLoading,\n speak,\n stop,\n pause,\n resume,\n toggle,\n playArticleAudio,\n isSupported: true // OpenAI TTS is always supported via API\n };\n}","size_bytes":7205},"server/outletParser.ts":{"content":"import { readFileSync } from 'fs';\nimport { join } from 'path';\n\nexport interface OutletLinks {\n name: string;\n category: 'people' | 'topics' | 'companies';\n focusSubject: string;\n urls: string[];\n}\n\nexport interface ParsedOutlets {\n people: OutletLinks[];\n topics: OutletLinks[];\n companies: OutletLinks[];\n total: number;\n}\n\nexport class OutletParser {\n \n // Parse the attached file and return structured outlet data\n static parseOutletFile(filePath: string): ParsedOutlets {\n try {\n console.log(`Parsing outlet file: ${filePath}`);\n \n const content = readFileSync(filePath, 'utf-8');\n const lines = content.split('\\n').map(line => line.trim()).filter(line => line);\n \n const parsed: ParsedOutlets = {\n people: [],\n topics: [],\n companies: [],\n total: 0,\n };\n\n let currentCategory: 'people' | 'topics' | 'companies' | null = null;\n let currentOutlet: OutletLinks | null = null;\n \n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n \n // Skip empty lines\n if (!line) continue;\n \n // Detect section headers FIRST (before skipping other # lines)\n if (line.includes('## People')) {\n currentCategory = 'people';\n continue;\n } else if (line.includes('## Topics')) {\n currentCategory = 'topics'; \n continue;\n } else if (line.includes('## Companies') || line.startsWith('📋 Companies')) {\n currentCategory = 'companies';\n continue;\n }\n \n // Skip other markdown headers (after section detection)\n if (line.startsWith('#') && !line.startsWith('###')) continue;\n \n // Parse outlet headers like \"### 1. Ala Shaabana - Bittensor 공동창립자\"\n if (line.startsWith('###') && currentCategory) {\n // Save previous outlet\n if (currentOutlet && currentOutlet.urls.length > 0) {\n parsed[currentOutlet.category].push(currentOutlet);\n parsed.total++;\n }\n \n // Extract outlet name (remove ### and number)\n const nameMatch = line.match(/###\\s*\\d+\\.\\s*(.+?)(?:\\s*\\([^)]*\\))?$/);\n if (nameMatch) {\n const rawName = nameMatch[1].trim();\n const cleanedName = this.cleanOutletName(rawName);\n \n currentOutlet = {\n name: cleanedName,\n category: currentCategory,\n focusSubject: this.generateFocusSubject(cleanedName),\n urls: []\n };\n }\n continue;\n }\n \n // Parse numbered URLs like \"1. https://example.com\"\n const urlMatch = line.match(/^\\d+\\.\\s*(https?:\\/\\/.+)$/);\n if (urlMatch && currentOutlet) {\n currentOutlet.urls.push(urlMatch[1]);\n continue;\n }\n \n // Parse direct URLs for companies section\n if (line.startsWith('http://') || line.startsWith('https://')) {\n if (currentOutlet) {\n currentOutlet.urls.push(line);\n }\n continue;\n }\n \n // Parse company entries like \"1. Ava Labs (Avalanche 플랫폼)\"\n if (currentCategory === 'companies' && /^\\d+\\.\\s*[A-Za-z]/.test(line) && !line.startsWith('http')) {\n // Save previous outlet\n if (currentOutlet && currentOutlet.urls.length > 0) {\n parsed[currentOutlet.category].push(currentOutlet);\n parsed.total++;\n }\n \n const companyMatch = line.match(/^\\d+\\.\\s*(.+?)(?:\\s*\\([^)]*\\))?$/);\n if (companyMatch) {\n const rawName = companyMatch[1].trim();\n const cleanedName = this.cleanOutletName(rawName);\n \n currentOutlet = {\n name: cleanedName,\n category: 'companies',\n focusSubject: this.generateFocusSubject(cleanedName),\n urls: []\n };\n }\n }\n }\n \n // Don't forget the last outlet\n if (currentOutlet && currentOutlet.urls.length > 0) {\n parsed[currentOutlet.category].push(currentOutlet);\n parsed.total++;\n }\n \n console.log(`Successfully parsed ${parsed.total} outlets:`);\n console.log(`- People: ${parsed.people.length}`);\n console.log(`- Topics: ${parsed.topics.length}`); \n console.log(`- Companies: ${parsed.companies.length}`);\n \n return parsed;\n \n } catch (error: any) {\n console.error('Error parsing outlet file:', error.message);\n throw new Error(`Failed to parse outlet file: ${error.message}`);\n }\n }\n \n // Clean outlet names by removing Korean descriptions and normalizing\n private static cleanOutletName(rawName: string): string {\n // Remove Korean parenthetical descriptions like \"(연방준비제도 의장)\" or \"(OSS Capital 창립자)\"\n let cleaned = rawName.replace(/\\s*\\([^)]*\\)/g, '').trim();\n \n // Handle special cases\n const specialCases: { [key: string]: string } = {\n 'CBDC': 'Central Bank Digital Currency',\n 'CFTC': 'Commodity Futures Trading Commission',\n 'SEC': 'Securities and Exchange Commission',\n 'DAT': 'Digital Asset Treasury',\n 'DeFi': 'Decentralized Finance',\n 'DEX': 'Decentralized Exchange',\n 'NFT': 'Non-Fungible Token',\n 'RWA': 'Real World Assets',\n 'SWF': 'Sovereign Wealth Fund',\n };\n \n return specialCases[cleaned] || cleaned;\n }\n \n // Generate focus subject for database compatibility\n private static generateFocusSubject(rawName: string): string {\n let subject = rawName.replace(/\\s*\\([^)]*\\)/g, '').trim();\n \n // Convert to lowercase and replace spaces with dashes for ID compatibility\n return subject.toLowerCase()\n .replace(/[^a-z0-9\\s-]/g, '') // Remove special characters\n .replace(/\\s+/g, '-') // Replace spaces with dashes\n .replace(/--+/g, '-') // Replace multiple dashes with single dash\n .replace(/^-+|-+$/g, ''); // Remove leading/trailing dashes\n }\n \n // Categorize outlet based on name\n private static categorizeOutlet(name: string): 'people' | 'topics' | 'companies' {\n const cleanName = name.toLowerCase().trim();\n \n // People (individual names)\n const people = [\n 'ala shaabana', 'alex karp', 'arthur hayes', 'donald trump jr', 'eric trump',\n 'jacob robert steeves', 'jared kushner', 'j.d. vance', 'jensen huang', \n 'jerome powell', 'joseph jacks', 'robert myers', 'yat siu'\n ];\n \n // Companies\n const companies = [\n 'xtao', 'yuma', 'taox', 'oblong', 'ava labs', 'boston dynamics', \n 'blackrock', 'chainlink', 'circle', 'cme group', 'manifold labs'\n ];\n \n // Check for exact matches first\n if (people.some(person => cleanName.includes(person) || person.includes(cleanName))) {\n return 'people';\n }\n \n if (companies.some(company => cleanName.includes(company) || company.includes(cleanName))) {\n return 'companies';\n }\n \n // Everything else goes to topics\n return 'topics';\n }\n \n // Get specific outlet data by name\n static getOutletByName(parsed: ParsedOutlets, name: string): OutletLinks | null {\n const allOutlets = [...parsed.people, ...parsed.topics, ...parsed.companies];\n return allOutlets.find(outlet => \n outlet.name.toLowerCase() === name.toLowerCase() ||\n outlet.focusSubject === name\n ) || null;\n }\n \n // Get all URLs from parsed data\n static getAllUrls(parsed: ParsedOutlets): string[] {\n const allOutlets = [...parsed.people, ...parsed.topics, ...parsed.companies];\n return allOutlets.flatMap(outlet => outlet.urls);\n }\n \n // Get URLs by category\n static getUrlsByCategory(parsed: ParsedOutlets, category: 'people' | 'topics' | 'companies'): string[] {\n return parsed[category].flatMap(outlet => outlet.urls);\n }\n \n // Convert parsed data to our existing outlet format\n static convertToOutletFormat(parsed: ParsedOutlets): Array<{\n id: string;\n name: string;\n description: string;\n category: string;\n focusSubject: string;\n avatar?: string;\n profileImage?: string;\n bio: string;\n fullBio?: string[];\n urls: string[];\n }> {\n const allOutlets = [...parsed.people, ...parsed.topics, ...parsed.companies];\n \n return allOutlets.map(outlet => ({\n id: outlet.focusSubject,\n name: outlet.name,\n description: this.generateDescription(outlet),\n category: outlet.category,\n focusSubject: outlet.focusSubject,\n avatar: this.getDefaultAvatar(outlet.category),\n profileImage: this.getDefaultProfileImage(outlet.category),\n bio: this.generateBio(outlet),\n fullBio: this.generateFullBio(outlet),\n urls: outlet.urls,\n }));\n }\n \n private static generateDescription(outlet: OutletLinks): string {\n const descriptions = {\n people: `Latest news and analysis about ${outlet.name}`,\n topics: `Comprehensive coverage of ${outlet.name} developments and trends`,\n companies: `${outlet.name} news, updates, and market analysis`,\n };\n \n return descriptions[outlet.category];\n }\n \n private static getDefaultAvatar(category: string): string {\n const avatars = {\n people: '/api/assets/default-person.jpg',\n topics: '/api/assets/default-topic.jpg',\n companies: '/api/assets/default-company.jpg',\n };\n \n return avatars[category as keyof typeof avatars] || avatars.topics;\n }\n \n private static getDefaultProfileImage(category: string): string {\n return this.getDefaultAvatar(category);\n }\n \n private static generateBio(outlet: OutletLinks): string {\n const bios = {\n people: `${outlet.name} is a prominent figure in technology and business, making headlines with strategic decisions and market insights.`,\n topics: `Stay informed about the latest developments in ${outlet.name} with comprehensive coverage and expert analysis.`,\n companies: `${outlet.name} continues to shape the industry with innovative solutions and strategic partnerships.`,\n };\n \n return bios[outlet.category];\n }\n \n private static generateFullBio(outlet: OutletLinks): string[] {\n const fullBios = {\n people: [\n `${outlet.name} is a key figure in the technology and business landscape.`,\n `Known for strategic leadership and innovative thinking in their field.`,\n `Continues to influence industry trends and developments globally.`\n ],\n topics: [\n `${outlet.name} represents a critical area of technological advancement.`,\n `Dynamic sector with ongoing market trends, regulatory updates, and innovations.`,\n `Comprehensive resource requiring expert analysis from leading industry professionals.`\n ],\n companies: [\n `${outlet.name} is a significant player in the technology industry.`,\n `Known for innovative products and strategic market positioning.`,\n `Continues to drive industry growth and technological advancement.`\n ]\n };\n \n return fullBios[outlet.category];\n }\n}\n\n// Utility function to parse the specific file\nexport function parseAttachedOutletFile(): ParsedOutlets {\n const filePath = join(process.cwd(), 'attached_assets', 'Pasted-Ala-Shaabana-https-www-rootdata-com-news-323625-https-ffnews-com-newsarticle-funding-xtao-tsx-v-1758557992922_1758557992922.txt');\n return OutletParser.parseOutletFile(filePath);\n}\n\nexport default OutletParser;","size_bytes":11460},"server/scraper.ts":{"content":"import axios from 'axios';\nimport * as cheerio from 'cheerio';\nimport { createWriteStream, existsSync, mkdirSync } from 'fs';\nimport { join } from 'path';\nimport sharp from 'sharp';\n\ninterface ScrapedArticle {\n url: string;\n title: string;\n summary: string;\n body: string;\n imageUrl?: string;\n publishedAt: Date;\n author?: string;\n tags: string[];\n}\n\ninterface ScrapedImage {\n url: string;\n filename: string;\n alt?: string;\n width?: number;\n height?: number;\n}\n\nexport class WebScraper {\n private static readonly USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36';\n private static readonly TIMEOUT = 30000; // 30 seconds\n private static readonly MAX_RETRIES = 3;\n \n constructor() {\n // Create assets directory if it doesn't exist\n const assetsDir = join(process.cwd(), 'attached_assets', 'scraped');\n if (!existsSync(assetsDir)) {\n mkdirSync(assetsDir, { recursive: true });\n }\n }\n\n // Main scraping method with retry logic\n async scrapeArticle(url: string, retryCount = 0): Promise<ScrapedArticle | null> {\n try {\n console.log(`Scraping ${url} (attempt ${retryCount + 1})`);\n \n const response = await axios.get(url, {\n headers: {\n 'User-Agent': WebScraper.USER_AGENT,\n 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',\n 'Accept-Language': 'en-US,en;q=0.5',\n 'Accept-Encoding': 'gzip, deflate, br',\n 'Connection': 'keep-alive',\n 'Upgrade-Insecure-Requests': '1',\n },\n timeout: WebScraper.TIMEOUT,\n maxRedirects: 5,\n });\n\n const $ = cheerio.load(response.data);\n \n // Extract article data using multiple selectors for different news sites\n const article = await this.extractArticleData($, url);\n \n if (!article) {\n console.warn(`Failed to extract article data from ${url}`);\n return null;\n }\n\n console.log(`Successfully scraped: ${article.title}`);\n return article;\n \n } catch (error: any) {\n console.error(`Error scraping ${url}:`, error.message);\n \n if (retryCount < WebScraper.MAX_RETRIES) {\n console.log(`Retrying ${url} in 2 seconds...`);\n await new Promise(resolve => setTimeout(resolve, 2000));\n return this.scrapeArticle(url, retryCount + 1);\n }\n \n return null;\n }\n }\n\n // Extract article data with fallback selectors for different news sites\n private async extractArticleData($: cheerio.CheerioAPI, url: string): Promise<ScrapedArticle | null> {\n const title = this.extractTitle($);\n const summary = this.extractSummary($);\n const body = this.extractBody($);\n const imageUrl = this.extractMainImage($, url);\n const publishedAt = this.extractPublishedDate($);\n const author = this.extractAuthor($);\n const tags = this.extractTags($);\n\n if (!title || !body) {\n console.warn(`Missing essential data for ${url}. Title: ${!!title}, Body: ${!!body}`);\n return null;\n }\n\n return {\n url,\n title,\n summary: summary || (await this.generateSummaryFromBody(body, title)),\n body,\n imageUrl,\n publishedAt: publishedAt || new Date(),\n author,\n tags,\n };\n }\n\n private extractTitle($: cheerio.CheerioAPI): string {\n // Try multiple common title selectors\n const selectors = [\n 'h1.article-title',\n 'h1.entry-title',\n 'h1[class*=\"headline\"]',\n 'h1[class*=\"title\"]',\n '.article-header h1',\n '.post-title',\n 'h1',\n 'title',\n '[property=\"og:title\"]',\n ];\n\n for (const selector of selectors) {\n const element = $(selector);\n if (element.length && element.text().trim()) {\n return element.first().text().trim();\n }\n }\n\n return '';\n }\n\n private extractSummary($: cheerio.CheerioAPI): string {\n const selectors = [\n '.article-summary',\n '.entry-summary',\n '.article-excerpt',\n '.post-excerpt',\n '[class*=\"summary\"]',\n '[class*=\"excerpt\"]',\n '[property=\"og:description\"]',\n '[name=\"description\"]',\n ];\n\n for (const selector of selectors) {\n const element = $(selector);\n if (element.length) {\n const text = selector.includes('property') || selector.includes('name') \n ? element.attr('content') \n : element.text();\n if (text && text.trim()) {\n return text.trim();\n }\n }\n }\n\n return '';\n }\n\n private extractBody($: cheerio.CheerioAPI): string {\n const selectors = [\n '.article-content',\n '.entry-content',\n '.post-content',\n '.article-body',\n '[class*=\"content\"]',\n '.story-body',\n '.article p',\n '.post p',\n ];\n\n for (const selector of selectors) {\n const elements = $(selector);\n if (elements.length) {\n // Get text from all paragraphs and join them\n let bodyText = '';\n elements.each((_, el) => {\n const text = $(el).text().trim();\n if (text && text.length > 50) { // Skip short elements (ads, captions, etc.)\n bodyText += text + '\\n\\n';\n }\n });\n \n if (bodyText.length > 200) { // Ensure we have substantial content\n return bodyText.trim();\n }\n }\n }\n\n return '';\n }\n\n private extractMainImage($: cheerio.CheerioAPI, baseUrl: string): string | undefined {\n const selectors = [\n '.article-image img',\n '.featured-image img',\n '[class*=\"hero\"] img',\n '.post-thumbnail img',\n '[property=\"og:image\"]',\n 'meta[property=\"og:image\"]',\n '.article img:first',\n 'img[class*=\"featured\"]',\n ];\n\n for (const selector of selectors) {\n const element = $(selector);\n if (element.length) {\n let src = selector.includes('property') || selector.includes('meta')\n ? element.attr('content')\n : element.attr('src');\n \n if (src) {\n // Convert relative URLs to absolute\n if (src.startsWith('//')) {\n src = 'https:' + src;\n } else if (src.startsWith('/')) {\n const urlObj = new URL(baseUrl);\n src = `${urlObj.origin}${src}`;\n }\n \n if (src.startsWith('http')) {\n return src;\n }\n }\n }\n }\n\n return undefined;\n }\n\n private extractPublishedDate($: cheerio.CheerioAPI): Date | null {\n const selectors = [\n '[property=\"article:published_time\"]',\n '[name=\"publish-date\"]',\n '.publish-date',\n '.article-date',\n '.entry-date',\n 'time[datetime]',\n '[class*=\"date\"]',\n ];\n\n for (const selector of selectors) {\n const element = $(selector);\n if (element.length) {\n let dateStr = element.attr('content') || element.attr('datetime') || element.text();\n \n if (dateStr) {\n const date = new Date(dateStr);\n if (!isNaN(date.getTime())) {\n return date;\n }\n }\n }\n }\n\n return null;\n }\n\n private extractAuthor($: cheerio.CheerioAPI): string | undefined {\n const selectors = [\n '[rel=\"author\"]',\n '.author-name',\n '.byline',\n '[class*=\"author\"]',\n '[property=\"article:author\"]',\n ];\n\n for (const selector of selectors) {\n const element = $(selector);\n if (element.length) {\n const author = selector.includes('property') \n ? element.attr('content') \n : element.text();\n if (author && author.trim()) {\n return author.trim();\n }\n }\n }\n\n return undefined;\n }\n\n private extractTags($: cheerio.CheerioAPI): string[] {\n const tags = new Set<string>();\n\n // Extract from various tag selectors\n const selectors = [\n '.tags a',\n '.tag-list a',\n '[class*=\"tag\"] a',\n '.categories a',\n '[rel=\"tag\"]',\n ];\n\n for (const selector of selectors) {\n $(selector).each((_, el) => {\n const tag = $(el).text().trim();\n if (tag) {\n tags.add(tag);\n }\n });\n }\n\n return Array.from(tags);\n }\n\n private async generateSummaryFromBody(body: string, title?: string): Promise<string> {\n try {\n // Try AI-powered summary generation first\n const response = await axios.post('http://localhost:5000/api/generate-summary', {\n content: body,\n title: title || ''\n }, {\n timeout: 10000, // 10 second timeout\n headers: {\n 'Content-Type': 'application/json'\n }\n });\n \n if (response.data?.summary) {\n console.log('✅ AI-generated summary created');\n return response.data.summary;\n }\n } catch (error) {\n console.warn('⚠️ AI summary generation failed, using fallback method:', error instanceof Error ? error.message : 'Unknown error');\n }\n \n // Fallback to basic summary generation (improved version)\n const cleanedBody = body.replace(/^This article was originally published at .+?\\.\\n\\n/i, '').trim();\n const sentences = cleanedBody.split(/[.!?]+/);\n let summary = '';\n \n for (const sentence of sentences) {\n const trimmed = sentence.trim();\n if (trimmed.length < 10) continue; // Skip very short sentences\n if (summary.length + trimmed.length > 150) break;\n summary += (summary ? '. ' : '') + trimmed;\n }\n \n return (summary + (summary ? '.' : 'Content not available.')).substring(0, 150);\n }\n\n // Download and process images\n async downloadImage(imageUrl: string, filename: string): Promise<ScrapedImage | null> {\n try {\n console.log(`Downloading image: ${imageUrl}`);\n \n const response = await axios.get(imageUrl, {\n responseType: 'stream',\n headers: {\n 'User-Agent': WebScraper.USER_AGENT,\n },\n timeout: WebScraper.TIMEOUT,\n });\n\n const assetsDir = join(process.cwd(), 'attached_assets', 'scraped');\n const imagePath = join(assetsDir, filename);\n \n const writer = createWriteStream(imagePath);\n response.data.pipe(writer);\n\n await new Promise((resolve, reject) => {\n writer.on('finish', resolve);\n writer.on('error', reject);\n });\n\n // Get image metadata\n const metadata = await sharp(imagePath).metadata();\n\n console.log(`Successfully downloaded: ${filename} (${metadata.width}x${metadata.height})`);\n\n return {\n url: imageUrl,\n filename,\n width: metadata.width,\n height: metadata.height,\n };\n\n } catch (error: any) {\n console.error(`Error downloading image ${imageUrl}:`, error.message);\n return null;\n }\n }\n\n // Create thumbnail from downloaded image\n async createThumbnail(imagePath: string, thumbnailPath: string, size = 300): Promise<boolean> {\n try {\n await sharp(imagePath)\n .resize(size, size, {\n fit: 'cover',\n position: 'center',\n })\n .jpeg({ quality: 80 })\n .toFile(thumbnailPath);\n\n return true;\n } catch (error: any) {\n console.error(`Error creating thumbnail:`, error.message);\n return false;\n }\n }\n\n // Batch scraping with concurrency control - returns both successes and failures\n async scrapeMultipleArticles(urls: string[], maxConcurrency = 5): Promise<{\n successes: ScrapedArticle[];\n failures: Array<{url: string; error: string}>;\n }> {\n const successes: ScrapedArticle[] = [];\n const failures: Array<{url: string; error: string}> = [];\n const chunks = this.chunkArray(urls, maxConcurrency);\n\n for (const chunk of chunks) {\n const promises = chunk.map(url => \n this.scrapeArticle(url)\n .then(result => ({ url, result, error: null }))\n .catch(error => ({ url, result: null, error: error.message || 'Unknown error' }))\n );\n \n const chunkResults = await Promise.all(promises);\n \n // Separate successes and failures\n for (const { url, result, error } of chunkResults) {\n if (result) {\n successes.push(result);\n } else {\n failures.push({ url, error: error || 'Failed to scrape' });\n }\n }\n \n // Small delay between batches to be respectful to servers\n await new Promise(resolve => setTimeout(resolve, 1000));\n }\n\n return { successes, failures };\n }\n\n private chunkArray<T>(array: T[], chunkSize: number): T[][] {\n const chunks: T[][] = [];\n for (let i = 0; i < array.length; i += chunkSize) {\n chunks.push(array.slice(i, i + chunkSize));\n }\n return chunks;\n }\n}","size_bytes":12653},"client/src/components/CommentSection.tsx":{"content":"import { useState } from \"react\";\nimport { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Input } from \"@/components/ui/input\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { ThumbsUp, ThumbsDown, ChevronDown, ChevronUp, ArrowUpDown } from \"lucide-react\";\nimport { formatTimeAgo } from \"@/lib/utils\";\nimport { apiRequest } from \"@/lib/queryClient\";\nimport AvatarPreviewDialog from \"@/components/AvatarPreviewDialog\";\n\ninterface Comment {\n id: string;\n content: string;\n nickname: string;\n avatar?: string;\n createdAt: string;\n likesCount: number;\n dislikesCount: number;\n repliesCount: number;\n parentId?: string;\n}\n\ninterface CommentSectionProps {\n articleId: string;\n}\n\nfunction getUserIdentifier() {\n let userId = localStorage.getItem('commentUserId');\n if (!userId) {\n userId = 'user_' + Math.random().toString(36).substring(2, 15);\n localStorage.setItem('commentUserId', userId);\n }\n return userId;\n}\n\nfunction CommentForm({ \n articleId, \n parentId, \n onSuccess, \n placeholder = \"Add comment...\",\n buttonText = \"Comment\"\n}: {\n articleId: string;\n parentId?: string;\n onSuccess?: () => void;\n placeholder?: string;\n buttonText?: string;\n}) {\n const [content, setContent] = useState(\"\");\n const [nickname, setNickname] = useState(() => \n localStorage.getItem('commentNickname') || \"\"\n );\n const [showForm, setShowForm] = useState(false);\n const [isFocused, setIsFocused] = useState(false);\n const queryClient = useQueryClient();\n\n const createComment = useMutation({\n mutationFn: async (data: { content: string; nickname: string; parentId?: string }) => {\n const response = await apiRequest('POST', `/api/articles/${articleId}/comments`, data);\n return response.json();\n },\n onSuccess: () => {\n setContent(\"\");\n setShowForm(false);\n setIsFocused(false);\n localStorage.setItem('commentNickname', nickname);\n queryClient.invalidateQueries({ queryKey: ['/api/articles', articleId, 'comments'] });\n if (parentId) {\n queryClient.invalidateQueries({ queryKey: ['/api/comments', parentId, 'replies'] });\n }\n onSuccess?.();\n },\n });\n\n const handleSubmit = (e: React.FormEvent) => {\n e.preventDefault();\n if (!content.trim() || !nickname.trim()) return;\n \n createComment.mutate({\n content: content.trim(),\n nickname: nickname.trim(),\n parentId\n });\n };\n\n if (!showForm && parentId) {\n return (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setShowForm(true)}\n className=\"text-sm font-normal p-0 h-auto hover:bg-transparent text-muted-foreground hover:text-foreground\"\n data-testid=\"button-reply\"\n >\n Reply\n </Button>\n );\n }\n\n if (!showForm && !parentId) {\n return (\n <div className=\"mb-6\">\n <div \n className=\"flex items-start gap-3\"\n onClick={() => setShowForm(true)}\n data-testid=\"button-add-comment\"\n >\n <Avatar className=\"w-10 h-10 flex-shrink-0\">\n <AvatarFallback>👤</AvatarFallback>\n </Avatar>\n <div className=\"flex-1\">\n <div className=\"border-b-2 border-muted hover:border-foreground transition-colors cursor-text py-2\">\n <span className=\"text-muted-foreground text-sm\">{placeholder}</span>\n </div>\n </div>\n </div>\n </div>\n );\n }\n\n return (\n <div className=\"mb-6\">\n <form onSubmit={handleSubmit} className=\"space-y-3\">\n <div className=\"flex items-start gap-3\">\n <Avatar className=\"w-10 h-10 flex-shrink-0\">\n <AvatarFallback>👤</AvatarFallback>\n </Avatar>\n <div className=\"flex-1 space-y-3\">\n {!localStorage.getItem('commentNickname') && (\n <Input\n placeholder=\"Enter your nickname\"\n value={nickname}\n onChange={(e) => setNickname(e.target.value)}\n className=\"border-0 border-b-2 border-muted rounded-none bg-transparent px-0 focus-visible:ring-0 focus-visible:border-foreground\"\n data-testid=\"input-nickname\"\n required\n />\n )}\n <Textarea\n placeholder={placeholder}\n value={content}\n onChange={(e) => setContent(e.target.value)}\n onFocus={() => setIsFocused(true)}\n className=\"min-h-[60px] border-0 border-b-2 border-muted rounded-none bg-transparent px-0 resize-none focus-visible:ring-0 focus-visible:border-foreground\"\n data-testid=\"textarea-comment\"\n required\n />\n {(isFocused || content) && (\n <div className=\"flex justify-end gap-2 pt-2\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => {\n setShowForm(false);\n setContent(\"\");\n setIsFocused(false);\n }}\n data-testid=\"button-cancel-comment\"\n className=\"h-9 px-4 rounded-full\"\n >\n Cancel\n </Button>\n <Button\n type=\"submit\"\n size=\"sm\"\n disabled={!content.trim() || !nickname.trim() || createComment.isPending}\n data-testid=\"button-submit-comment\"\n className=\"h-9 px-4 rounded-full\"\n >\n {createComment.isPending ? \"Posting...\" : buttonText}\n </Button>\n </div>\n )}\n </div>\n </div>\n </form>\n </div>\n );\n}\n\nfunction CommentItem({ comment, articleId, level = 0 }: { \n comment: Comment; \n articleId: string; \n level?: number;\n}) {\n const [showReplies, setShowReplies] = useState(false);\n const [showReplyForm, setShowReplyForm] = useState(false);\n const [userReaction, setUserReaction] = useState<'like' | 'dislike' | null>(null);\n const queryClient = useQueryClient();\n const userIdentifier = getUserIdentifier();\n\n const { data: reactionData } = useQuery({\n queryKey: ['/api/comments', comment.id, 'reactions', userIdentifier],\n queryFn: async () => {\n const response = await apiRequest('GET', `/api/comments/${comment.id}/reactions/${userIdentifier}`);\n return response.json();\n },\n });\n\n const { data: repliesData } = useQuery({\n queryKey: ['/api/comments', comment.id, 'replies'],\n queryFn: async () => {\n const response = await apiRequest('GET', `/api/comments/${comment.id}/replies`);\n return response.json();\n },\n enabled: showReplies && comment.repliesCount > 0,\n });\n\n const toggleReaction = useMutation({\n mutationFn: async (reactionType: 'like' | 'dislike') => {\n const response = await apiRequest('POST', `/api/comments/${comment.id}/reactions`, {\n reactionType, \n userIdentifier\n });\n return response.json();\n },\n onSuccess: () => {\n queryClient.invalidateQueries({ queryKey: ['/api/articles', articleId, 'comments'] });\n queryClient.invalidateQueries({ queryKey: ['/api/comments', comment.id, 'reactions', userIdentifier] });\n },\n });\n\n const currentReaction = reactionData?.reactionType || userReaction;\n\n const handleReaction = (reactionType: 'like' | 'dislike') => {\n setUserReaction(currentReaction === reactionType ? null : reactionType);\n toggleReaction.mutate(reactionType);\n };\n\n return (\n <div className={`${level > 0 ? 'ml-10 pl-6 border-l border-muted' : ''} py-3`}>\n <div className=\"flex gap-3\">\n {comment.avatar ? (\n <AvatarPreviewDialog\n avatarSrc={comment.avatar}\n avatarAlt={comment.nickname}\n fallbackText={comment.nickname.charAt(0).toUpperCase()}\n title={`${comment.nickname} profile picture`}\n >\n <Avatar className=\"w-10 h-10 flex-shrink-0 cursor-pointer hover:ring-2 hover:ring-primary/20 transition-all\">\n <AvatarImage src={comment.avatar} alt={comment.nickname} />\n <AvatarFallback>{comment.nickname.charAt(0).toUpperCase()}</AvatarFallback>\n </Avatar>\n </AvatarPreviewDialog>\n ) : (\n <Avatar className=\"w-10 h-10 flex-shrink-0\">\n <AvatarFallback>👤</AvatarFallback>\n </Avatar>\n )}\n \n <div className=\"flex-1 space-y-2\">\n <div className=\"flex items-center gap-2 text-sm\">\n <span className=\"font-semibold\" data-testid={`text-comment-author-${comment.id}`}>\n {comment.nickname}\n </span>\n <span className=\"text-muted-foreground text-xs\" data-testid={`text-comment-time-${comment.id}`}>\n {formatTimeAgo(comment.createdAt)}\n </span>\n </div>\n \n <div className=\"text-sm leading-relaxed whitespace-pre-wrap\" data-testid={`text-comment-content-${comment.id}`}>\n {comment.content}\n </div>\n \n <div className=\"flex items-center gap-4 pt-1\">\n <div className=\"flex items-center gap-1\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className={`h-8 px-3 rounded-full text-xs font-medium hover:bg-muted transition-colors ${\n currentReaction === 'like' \n ? 'bg-blue-50 text-blue-600 hover:bg-blue-100' \n : 'text-muted-foreground hover:text-foreground'\n }`}\n onClick={() => handleReaction('like')}\n data-testid={`button-like-${comment.id}`}\n >\n <ThumbsUp className=\"h-3 w-3 mr-1\" />\n {comment.likesCount || 0}\n </Button>\n \n <Button\n variant=\"ghost\"\n size=\"sm\"\n className={`h-8 px-3 rounded-full text-xs font-medium hover:bg-muted transition-colors ${\n currentReaction === 'dislike' \n ? 'bg-red-50 text-red-600 hover:bg-red-100' \n : 'text-muted-foreground hover:text-foreground'\n }`}\n onClick={() => handleReaction('dislike')}\n data-testid={`button-dislike-${comment.id}`}\n >\n <ThumbsDown className=\"h-3 w-3 mr-1\" />\n {comment.dislikesCount || 0}\n </Button>\n </div>\n \n {level < 2 && (\n <CommentForm\n articleId={articleId}\n parentId={comment.id}\n placeholder=\"Add reply...\"\n buttonText=\"Reply\"\n onSuccess={() => setShowReplyForm(false)}\n />\n )}\n </div>\n \n {comment.repliesCount > 0 && (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setShowReplies(!showReplies)}\n className=\"text-blue-600 hover:text-blue-700 h-8 px-0 font-medium text-xs rounded-full hover:bg-blue-50 transition-colors\"\n data-testid={`button-toggle-replies-${comment.id}`}\n >\n {showReplies ? (\n <>\n <ChevronUp className=\"h-3 w-3 mr-1\" />\n Hide replies\n </>\n ) : (\n <>\n <ChevronDown className=\"h-3 w-3 mr-1\" />\n View {comment.repliesCount} replies\n </>\n )}\n </Button>\n )}\n \n {showReplies && repliesData?.comments && (\n <div className=\"mt-4 space-y-3\">\n {repliesData.comments.map((reply: Comment) => (\n <CommentItem \n key={reply.id} \n comment={reply} \n articleId={articleId}\n level={level + 1}\n />\n ))}\n </div>\n )}\n </div>\n </div>\n </div>\n );\n}\n\nexport default function CommentSection({ articleId }: CommentSectionProps) {\n const [sortBy, setSortBy] = useState<'newest' | 'popular'>('newest');\n const { data, isLoading } = useQuery({\n queryKey: ['/api/articles', articleId, 'comments', sortBy],\n queryFn: async () => {\n const response = await apiRequest('GET', `/api/articles/${articleId}/comments?limit=20&sort=${sortBy}`);\n return response.json();\n },\n });\n\n if (isLoading) {\n return (\n <div className=\"bg-background p-6\">\n <h3 className=\"text-xl font-bold mb-4\">Comments</h3>\n <div className=\"space-y-6\">\n {[...Array(3)].map((_, i) => (\n <div key={i} className=\"animate-pulse flex gap-3\">\n <div className=\"w-10 h-10 bg-muted rounded-full\"></div>\n <div className=\"flex-1 space-y-2\">\n <div className=\"h-4 bg-muted rounded w-32\"></div>\n <div className=\"h-12 bg-muted rounded\"></div>\n </div>\n </div>\n ))}\n </div>\n </div>\n );\n }\n\n const comments = data?.comments || [];\n const total = data?.total || 0;\n\n return (\n <div className=\"bg-background pb-6\" data-testid=\"section-comments\">\n {/* Comments Header */}\n <div className=\"px-6 py-4 border-b\">\n <div className=\"flex items-center justify-between mb-4\">\n <h3 className=\"text-xl font-bold\">{total} Comments</h3>\n <Select value={sortBy} onValueChange={(value) => setSortBy(value as 'newest' | 'popular')}>\n <SelectTrigger className=\"w-[140px] h-8 text-sm border-none bg-transparent hover:bg-muted/50\">\n <ArrowUpDown className=\"h-4 w-4 mr-2\" />\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"newest\">Newest</SelectItem>\n <SelectItem value=\"popular\">Popular</SelectItem>\n </SelectContent>\n </Select>\n </div>\n </div>\n \n {/* Comment Form */}\n <div className=\"px-6 pt-6\">\n <CommentForm articleId={articleId} />\n </div>\n \n {/* Comments List */}\n <div className=\"px-6\">\n {comments.length === 0 ? (\n <div className=\"text-center text-muted-foreground py-12\">\n <div className=\"w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center\">\n <span className=\"text-2xl\">💬</span>\n </div>\n <p className=\"text-lg mb-2\">No comments yet</p>\n <p className=\"text-sm\">Be the first to leave a comment!</p>\n </div>\n ) : (\n <div className=\"divide-y divide-muted/30\">\n {comments.map((comment: Comment, index: number) => (\n <CommentItem key={comment.id} comment={comment} articleId={articleId} />\n ))}\n </div>\n )}\n </div>\n </div>\n );\n}","size_bytes":15278},"client/src/components/AvatarPreviewDialog.tsx":{"content":"import { useState } from \"react\";\nimport { \n Dialog, \n DialogContent, \n DialogHeader, \n DialogTitle,\n DialogTrigger \n} from \"@/components/ui/dialog\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\n\ninterface AvatarPreviewDialogProps {\n children: React.ReactNode;\n avatarSrc?: string;\n profileImageSrc?: string;\n avatarAlt: string;\n fallbackText: string;\n title?: string;\n description?: string;\n}\n\nexport default function AvatarPreviewDialog({\n children,\n avatarSrc,\n profileImageSrc,\n avatarAlt,\n fallbackText,\n title,\n description\n}: AvatarPreviewDialogProps) {\n return (\n <Dialog>\n <DialogTrigger asChild>\n {children}\n </DialogTrigger>\n <DialogContent className=\"max-w-sm w-auto max-w-[calc(100vw-3rem)] fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%]\">\n <div className=\"flex flex-col items-center p-6\">\n <Avatar className=\"h-32 w-32 mb-4 ring-4 ring-primary/10\">\n <AvatarImage src={avatarSrc} alt={avatarAlt} className=\"object-cover\" />\n <AvatarFallback className=\"text-2xl font-bold\">\n {fallbackText}\n </AvatarFallback>\n </Avatar>\n {description && (\n <p className=\"text-center text-muted-foreground text-sm leading-relaxed\">\n {description}\n </p>\n )}\n </div>\n </DialogContent>\n </Dialog>\n );\n}","size_bytes":1429},"client/src/components/ArticlePopup.tsx":{"content":"import { useState, useMemo } from \"react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport { formatTimeAgo } from \"@/lib/utils\";\nimport { X, Share, Play, Pause, Loader2 } from \"lucide-react\";\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Card } from \"@/components/ui/card\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { useTTS } from \"@/hooks/useTTS\";\nimport type { Article } from \"@shared/schema\";\n\ninterface ArticlePopupProps {\n articleId: string;\n isOpen: boolean;\n onClose: () => void;\n}\n\nexport default function ArticlePopup({ articleId, isOpen, onClose }: ArticlePopupProps) {\n const [imageLoaded, setImageLoaded] = useState(false);\n const { isPlaying, isPaused, isLoading, toggle } = useTTS({ \n voice: 'nova',\n speed: 1.0 \n });\n\n // Fetch article data\n const { data: article, isLoading: articleLoading } = useQuery<Article>({\n queryKey: ['/api/articles', articleId],\n enabled: !!articleId && isOpen\n });\n\n // Fetch outlet information\n const { data: outlet } = useQuery<{name: string; category: string}>({\n queryKey: ['/api/outlets', article?.outletId],\n enabled: !!article?.outletId && isOpen\n });\n\n // Clean up and truncate article body\n const cleanedBody = useMemo(() => {\n if (!article?.body) return '';\n const pattern = /^\\s*(?:This article was\\s+)?originally published\\s+(?:at|on)\\s+\\S+\\.?\\s*(?:\\r?\\n){1,2}/i;\n const cleaned = article.body.replace(pattern, '').trimStart();\n \n // Truncate at 3000 characters, but complete the sentence\n if (cleaned.length <= 3000) return cleaned;\n \n // Find the next sentence ending after 3000 characters\n const truncatePoint = 3000;\n const afterTruncate = cleaned.substring(truncatePoint);\n \n // Look for sentence endings: period, exclamation, question mark followed by space or end of string\n const sentenceEndMatch = afterTruncate.match(/[.!?](?:\\s|$)/);\n \n if (sentenceEndMatch) {\n // Include the sentence ending character\n const endIndex = truncatePoint + sentenceEndMatch.index + 1;\n return cleaned.substring(0, endIndex);\n }\n \n // If no sentence ending found within reasonable distance, just cut at 3000\n return cleaned.substring(0, 3000);\n }, [article?.body]);\n\n const handleShare = () => {\n if (navigator.share && article) {\n navigator.share({\n title: article.title,\n text: article.summary,\n url: window.location.href,\n });\n } else if (article) {\n navigator.clipboard.writeText(window.location.href);\n console.log('URL copied to clipboard');\n }\n };\n\n const handlePlayAudio = () => {\n toggle(articleId);\n };\n\n if (!isOpen) return null;\n\n if (articleLoading || !article) {\n return (\n <Dialog open={isOpen} onOpenChange={onClose}>\n <DialogContent className=\"max-w-4xl max-h-[90vh] p-0\">\n <div className=\"flex items-center justify-center p-8\">\n <div className=\"text-center space-y-2\">\n <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto\"></div>\n <p className=\"text-muted-foreground\">Loading article...</p>\n </div>\n </div>\n </DialogContent>\n </Dialog>\n );\n }\n\n return (\n <Dialog open={isOpen} onOpenChange={onClose}>\n <DialogContent className=\"max-w-4xl max-h-[90vh] p-0\">\n <ScrollArea className=\"h-full max-h-[90vh]\">\n <div className=\"p-6 space-y-6\">\n {/* Header with close button */}\n <DialogHeader className=\"flex flex-row items-center justify-between space-y-0\">\n <DialogTitle className=\"sr-only\">Article Details</DialogTitle>\n <div className=\"flex gap-2 ml-auto\">\n <Button\n variant=\"outline\"\n size=\"icon\"\n onClick={handlePlayAudio}\n disabled={isLoading}\n data-testid=\"button-audio-popup\"\n className=\"h-8 w-8\"\n >\n {isLoading ? (\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n ) : isPlaying && !isPaused ? (\n <Pause className=\"h-4 w-4\" />\n ) : (\n <Play className=\"h-4 w-4\" />\n )}\n </Button>\n <Button\n variant=\"outline\"\n size=\"icon\"\n onClick={handleShare}\n data-testid=\"button-share-popup\"\n className=\"h-8 w-8\"\n >\n <Share className=\"h-4 w-4\" />\n </Button>\n <Button\n variant=\"outline\"\n size=\"icon\"\n onClick={onClose}\n className=\"h-8 w-8\"\n >\n <X className=\"h-4 w-4\" />\n </Button>\n </div>\n </DialogHeader>\n\n {/* Hero image */}\n <div className=\"aspect-video relative overflow-hidden rounded-lg\">\n <img\n src={article.thumbnail}\n alt={article.title}\n className={`w-full h-full object-cover transition-opacity duration-300 ${\n imageLoaded ? 'opacity-100' : 'opacity-0'\n }`}\n onLoad={() => setImageLoaded(true)}\n data-testid=\"img-article-popup-hero\"\n />\n {!imageLoaded && (\n <div className=\"absolute inset-0 bg-muted animate-pulse\" />\n )}\n \n {/* Category badge */}\n <div className=\"absolute top-4 left-4\">\n <Badge variant=\"secondary\" data-testid=\"badge-category-popup\">\n {outlet?.category || 'General'}\n </Badge>\n </div>\n </div>\n\n {/* Article content */}\n <Card className=\"p-6 space-y-6\">\n {/* Meta info */}\n <div className=\"flex items-center justify-between text-sm text-muted-foreground\">\n <span data-testid=\"text-outlet-popup\">{outlet?.name || 'Unknown Outlet'}</span>\n <span data-testid=\"text-published-popup\">\n {formatTimeAgo(article.publishedAt)}\n </span>\n </div>\n\n {/* Title */}\n <h1 \n className=\"text-2xl font-bold leading-tight\"\n data-testid=\"text-title-popup\"\n >\n {article.title}\n </h1>\n\n {/* Summary */}\n <p \n className=\"text-lg text-muted-foreground leading-relaxed\"\n data-testid=\"text-summary-popup\"\n >\n {article.summary}\n </p>\n\n {/* Tags */}\n {article.tags && article.tags.length > 0 && (\n <div className=\"flex flex-wrap gap-2\">\n {article.tags.map((tag, index) => (\n <Badge \n key={index} \n variant=\"outline\" \n className=\"text-xs\"\n data-testid={`tag-popup-${index}`}\n >\n {tag}\n </Badge>\n ))}\n </div>\n )}\n\n {/* Body content */}\n <div \n className=\"prose prose-sm max-w-none dark:prose-invert\"\n data-testid=\"text-body-popup\"\n >\n {cleanedBody.split(/\\r?\\n\\r?\\n+/).filter(Boolean).map((paragraph, index) => (\n <p key={index} className=\"mb-4 leading-relaxed\">\n {paragraph}\n </p>\n ))}\n </div>\n </Card>\n\n </div>\n </ScrollArea>\n </DialogContent>\n </Dialog>\n );\n}","size_bytes":8018},"client/src/components/FloatingBackButton.tsx":{"content":"import { useState, useEffect } from \"react\";\nimport { ChevronLeft } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface FloatingBackButtonProps {\n onClick: () => void;\n show?: boolean;\n}\n\nexport default function FloatingBackButton({ onClick, show = true }: FloatingBackButtonProps) {\n const [isVisible, setIsVisible] = useState(false);\n const [isIOS, setIsIOS] = useState(false);\n\n useEffect(() => {\n // iOS 감지 (모든 브라우저에서)\n const detectIOS = () => {\n const userAgent = window.navigator.userAgent;\n return /iPad|iPhone|iPod/.test(userAgent);\n };\n\n setIsIOS(detectIOS());\n\n const handleScroll = () => {\n // 스크롤을 조금이라도 내리면 버튼 표시\n const scrolled = window.scrollY > 60; // 헤더 높이보다 조금 더 스크롤했을 때\n setIsVisible(scrolled);\n };\n\n if (show) {\n window.addEventListener('scroll', handleScroll);\n handleScroll(); // 초기 상태 설정\n }\n\n return () => {\n window.removeEventListener('scroll', handleScroll);\n };\n }, [show]);\n\n // iOS가 아니거나 show가 false이면 렌더링하지 않음\n if (!isIOS || !show) {\n return null;\n }\n\n return (\n <Button\n variant=\"secondary\" \n size=\"icon\"\n onClick={onClick}\n data-testid=\"floating-back-button\"\n className={`\n fixed top-20 left-4 z-50 h-10 w-10 \n rounded-full shadow-lg backdrop-blur-sm\n transition-all duration-300 ease-in-out\n ${isVisible \n ? 'opacity-100 translate-x-0 pointer-events-auto' \n : 'opacity-0 -translate-x-8 pointer-events-none'\n }\n bg-background/90 hover:bg-background/95\n border border-border/50\n `}\n >\n <ChevronLeft className=\"h-5 w-5\" />\n </Button>\n );\n}","size_bytes":1816},"client/src/components/ThumbnailGenerator.tsx":{"content":"import { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from \"@/components/ui/dialog\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Settings, ImageIcon, Loader2, CheckCircle, XCircle } from \"lucide-react\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { apiRequest } from \"@/lib/queryClient\";\nimport { useToast } from \"@/hooks/use-toast\";\n\ninterface BatchResult {\n success: boolean;\n processed: number;\n failed: number;\n results: Array<{\n articleId: string;\n title: string;\n thumbnailPath: string;\n success: boolean;\n }>;\n errors: Array<{\n articleId: string;\n title: string;\n error: string;\n }>;\n message: string;\n}\n\nexport default function ThumbnailGenerator() {\n const [isOpen, setIsOpen] = useState(false);\n const [batchLimit, setBatchLimit] = useState(10);\n const queryClient = useQueryClient();\n const { toast } = useToast();\n\n const batchGenerateMutation = useMutation({\n mutationFn: async (limit: number): Promise<BatchResult> => {\n const response = await apiRequest('POST', '/api/articles/generate-thumbnails', { limit });\n return response.json();\n },\n onSuccess: (data) => {\n // Refetch data to show new thumbnails\n queryClient.invalidateQueries({ queryKey: ['/api/feed'] });\n queryClient.invalidateQueries({ queryKey: ['/api/articles'] });\n \n toast({\n title: \"Batch Generation Complete\",\n description: `Generated ${data.processed} thumbnails${data.failed > 0 ? `, ${data.failed} failed` : ''}.`,\n variant: data.failed > 0 ? \"destructive\" : \"default\",\n });\n },\n onError: (error) => {\n console.error('Error in batch generation:', error);\n toast({\n title: \"Batch Generation Failed\",\n description: error instanceof Error ? error.message : \"Failed to generate thumbnails. Please try again.\",\n variant: \"destructive\",\n });\n },\n });\n\n const handleBatchGenerate = () => {\n batchGenerateMutation.mutate(batchLimit);\n };\n\n return (\n <>\n {/* Floating Admin Button */}\n <Dialog open={isOpen} onOpenChange={setIsOpen}>\n <DialogTrigger asChild>\n <Button\n variant=\"outline\"\n size=\"icon\"\n className=\"fixed bottom-4 right-4 h-12 w-12 shadow-lg z-50 bg-background border-border hover:bg-accent\"\n data-testid=\"button-thumbnail-generator\"\n >\n <Settings className=\"h-5 w-5\" />\n </Button>\n </DialogTrigger>\n \n <DialogContent className=\"max-w-2xl max-h-[90vh] overflow-hidden\">\n <DialogHeader>\n <DialogTitle className=\"flex items-center gap-2\">\n <ImageIcon className=\"h-5 w-5\" />\n Thumbnail Generator\n </DialogTitle>\n <DialogDescription>\n Generate AI thumbnails for articles that don't have them\n </DialogDescription>\n </DialogHeader>\n\n <div className=\"space-y-6\">\n {/* Batch Generation Section */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-lg\">Batch Generation</CardTitle>\n <CardDescription>\n Generate thumbnails for multiple articles at once\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <div className=\"flex items-center gap-4\">\n <div className=\"flex items-center gap-2\">\n <label htmlFor=\"batch-limit\" className=\"text-sm font-medium\">\n Limit:\n </label>\n <select\n id=\"batch-limit\"\n value={batchLimit}\n onChange={(e) => setBatchLimit(Number(e.target.value))}\n className=\"px-2 py-1 border rounded-md text-sm\"\n data-testid=\"select-batch-limit\"\n >\n <option value={5}>5 articles</option>\n <option value={10}>10 articles</option>\n <option value={15}>15 articles</option>\n <option value={20}>20 articles</option>\n </select>\n </div>\n \n <Button\n onClick={handleBatchGenerate}\n disabled={batchGenerateMutation.isPending}\n className=\"flex items-center gap-2\"\n data-testid=\"button-batch-generate\"\n >\n {batchGenerateMutation.isPending ? (\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n ) : (\n <ImageIcon className=\"h-4 w-4\" />\n )}\n Generate Batch\n </Button>\n </div>\n \n {batchGenerateMutation.isPending && (\n <div className=\"text-sm text-muted-foreground\">\n <div className=\"flex items-center gap-2\">\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n Processing articles... This may take a few minutes.\n </div>\n </div>\n )}\n </CardContent>\n </Card>\n\n {/* Results Section */}\n {batchGenerateMutation.data && (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-lg flex items-center gap-2\">\n Batch Results\n <Badge variant={batchGenerateMutation.data.failed > 0 ? \"destructive\" : \"default\"}>\n {batchGenerateMutation.data.processed} processed, {batchGenerateMutation.data.failed} failed\n </Badge>\n </CardTitle>\n </CardHeader>\n <CardContent>\n <ScrollArea className=\"h-64 w-full\">\n <div className=\"space-y-2\">\n {/* Successful generations */}\n {batchGenerateMutation.data.results.map((result) => (\n <div\n key={result.articleId}\n className=\"flex items-center gap-3 p-2 bg-green-50 dark:bg-green-950/20 rounded-lg\"\n >\n <CheckCircle className=\"h-4 w-4 text-green-600 flex-shrink-0\" />\n <div className=\"flex-1 min-w-0\">\n <p className=\"text-sm font-medium truncate\">{result.title}</p>\n <p className=\"text-xs text-muted-foreground\">Generated successfully</p>\n </div>\n </div>\n ))}\n \n {/* Failed generations */}\n {batchGenerateMutation.data.errors.map((error) => (\n <div\n key={error.articleId}\n className=\"flex items-center gap-3 p-2 bg-red-50 dark:bg-red-950/20 rounded-lg\"\n >\n <XCircle className=\"h-4 w-4 text-red-600 flex-shrink-0\" />\n <div className=\"flex-1 min-w-0\">\n <p className=\"text-sm font-medium truncate\">{error.title}</p>\n <p className=\"text-xs text-red-600\">{error.error}</p>\n </div>\n </div>\n ))}\n </div>\n </ScrollArea>\n </CardContent>\n </Card>\n )}\n\n <div className=\"text-xs text-muted-foreground\">\n <p>• Individual articles can be generated using the image icon on each article thumbnail</p>\n <p>• Only articles without thumbnails or using default placeholders will be processed</p>\n <p>• Generated thumbnails are automatically saved and displayed</p>\n </div>\n </div>\n </DialogContent>\n </Dialog>\n </>\n );\n}","size_bytes":8393},"client/src/components/ImagePreviewDialog.tsx":{"content":"import { X } from \"lucide-react\";\nimport { \n Dialog, \n DialogContent, \n DialogTrigger,\n DialogTitle,\n DialogHeader,\n DialogDescription\n} from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { VisuallyHidden } from \"@radix-ui/react-visually-hidden\";\n\ninterface ImagePreviewDialogProps {\n children: React.ReactNode;\n imageSrc?: string;\n imageAlt: string;\n title?: string;\n}\n\nexport default function ImagePreviewDialog({\n children,\n imageSrc,\n imageAlt,\n title\n}: ImagePreviewDialogProps) {\n if (!imageSrc) return <>{children}</>;\n\n return (\n <Dialog>\n <DialogTrigger asChild>\n {children}\n </DialogTrigger>\n <DialogContent className=\"bg-background rounded-lg shadow-lg max-w-md w-full m-4 max-h-[80vh] overflow-y-auto\">\n <DialogHeader className=\"sr-only\">\n <DialogTitle>\n <VisuallyHidden>{title || imageAlt}</VisuallyHidden>\n </DialogTitle>\n <DialogDescription>\n <VisuallyHidden>Profile image preview</VisuallyHidden>\n </DialogDescription>\n </DialogHeader>\n \n <div className=\"p-6\">\n <div className=\"flex items-center justify-between mb-6\">\n <div className=\"flex items-center gap-3\">\n <div className=\"h-12 w-12 bg-primary/10 rounded-full flex items-center justify-center\">\n <span className=\"text-sm font-medium\">{title?.slice(0, 2).toUpperCase()}</span>\n </div>\n <div>\n <h2 className=\"text-lg font-bold\" data-testid=\"text-modal-name\">\n {title}\n </h2>\n </div>\n </div>\n </div>\n \n {/* Image container */}\n <div className=\"flex justify-center\">\n <img\n src={imageSrc}\n alt={imageAlt}\n className=\"max-w-full max-h-[60vh] object-contain rounded-lg shadow-lg\"\n />\n </div>\n </div>\n </DialogContent>\n </Dialog>\n );\n}","size_bytes":2030},"client/src/components/SettingsDropdown.tsx":{"content":"import { Check, Settings, Globe, Sun, Moon, Monitor } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { LANGUAGES } from \"@shared/schema\";\nimport { useTheme } from \"@/hooks/useTheme\";\nimport { useLanguage } from \"@/hooks/useLanguage\";\n\nexport default function SettingsDropdown() {\n const { themeMode, setTheme } = useTheme();\n const { selectedLanguage, onLanguageChange } = useLanguage();\n const currentLanguage = LANGUAGES.find(lang => lang.code === selectedLanguage) || LANGUAGES[0];\n\n const themes = [\n { mode: 'light' as const, label: 'Light', icon: Sun },\n { mode: 'dark' as const, label: 'Dark', icon: Moon },\n { mode: 'system' as const, label: 'System', icon: Monitor },\n ];\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n data-testid=\"button-settings\"\n className=\"h-8 w-8\"\n >\n <Settings className=\"h-4 w-4\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-48\">\n <DropdownMenuLabel>Settings</DropdownMenuLabel>\n <DropdownMenuSeparator />\n \n {/* Theme Selection */}\n <DropdownMenuLabel className=\"text-xs text-muted-foreground\">Theme</DropdownMenuLabel>\n {themes.map((theme) => {\n const IconComponent = theme.icon;\n return (\n <DropdownMenuItem \n key={theme.mode}\n onClick={() => setTheme(theme.mode)} \n data-testid={`menuitem-theme-${theme.mode}`}\n >\n <IconComponent className=\"h-4 w-4 mr-2\" />\n <span className=\"flex-1\">{theme.label}</span>\n {themeMode === theme.mode && (\n <Check className=\"h-4 w-4\" />\n )}\n </DropdownMenuItem>\n );\n })}\n \n <DropdownMenuSeparator />\n \n {/* Language Selection */}\n <DropdownMenuLabel className=\"text-xs text-muted-foreground\">Language</DropdownMenuLabel>\n {LANGUAGES.map((language) => (\n <DropdownMenuItem\n key={language.code}\n onClick={() => onLanguageChange(language.code)}\n data-testid={`menuitem-lang-${language.code}`}\n >\n <Globe className=\"h-4 w-4 mr-2\" />\n <span className=\"flex-1\">{language.name}</span>\n {selectedLanguage === language.code && (\n <Check className=\"h-4 w-4\" />\n )}\n </DropdownMenuItem>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}","size_bytes":2782},"client/src/hooks/useTheme.ts":{"content":"import { useState, useEffect } from 'react';\n\ntype ThemeMode = 'light' | 'dark' | 'system';\ntype AppliedTheme = 'light' | 'dark';\n\nexport function useTheme() {\n const [themeMode, setThemeMode] = useState<ThemeMode>(() => {\n // Check localStorage first, then default to system\n const stored = localStorage.getItem('theme') as ThemeMode;\n if (stored && ['light', 'dark', 'system'].includes(stored)) {\n return stored;\n }\n return 'system';\n });\n\n const [appliedTheme, setAppliedTheme] = useState<AppliedTheme>(() => {\n if (themeMode === 'system') {\n return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n }\n return themeMode as AppliedTheme;\n });\n\n useEffect(() => {\n const updateAppliedTheme = () => {\n if (themeMode === 'system') {\n const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n setAppliedTheme(systemTheme);\n } else {\n setAppliedTheme(themeMode as AppliedTheme);\n }\n };\n\n updateAppliedTheme();\n\n // Listen for system theme changes when in system mode\n if (themeMode === 'system') {\n const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n mediaQuery.addEventListener('change', updateAppliedTheme);\n \n return () => {\n mediaQuery.removeEventListener('change', updateAppliedTheme);\n };\n }\n }, [themeMode]);\n\n useEffect(() => {\n const root = document.documentElement;\n \n // Remove existing theme classes\n root.classList.remove('light', 'dark');\n \n // Add new theme class\n root.classList.add(appliedTheme);\n \n // Store in localStorage\n localStorage.setItem('theme', themeMode);\n }, [appliedTheme, themeMode]);\n\n const toggleTheme = () => {\n setThemeMode(prev => {\n if (prev === 'light') return 'dark';\n if (prev === 'dark') return 'system';\n return 'light';\n });\n };\n\n return { \n theme: appliedTheme,\n themeMode, \n setTheme: setThemeMode, \n toggleTheme \n };\n}","size_bytes":2047},"client/src/hooks/useLanguage.ts":{"content":"import { useState } from 'react';\n\nexport function useLanguage() {\n const [selectedLanguage, setSelectedLanguage] = useState(() => {\n // Check localStorage first, then default to English\n const stored = localStorage.getItem('selectedLanguage');\n return stored || 'en';\n });\n\n const onLanguageChange = (language: string) => {\n setSelectedLanguage(language);\n localStorage.setItem('selectedLanguage', language);\n };\n\n return { selectedLanguage, onLanguageChange };\n}","size_bytes":484},"batch_update_images.js":{"content":"import fs from 'fs';\n\n// Read the update commands\nconst updates = JSON.parse(fs.readFileSync('image_updates.json', 'utf8'));\n\nasync function updateArticleImage(articleId, newImagePath) {\n try {\n const response = await fetch(`http://localhost:5000/api/articles/${articleId}`, {\n method: 'PATCH',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n thumbnail: newImagePath\n })\n });\n \n if (response.ok) {\n const result = await response.json();\n return { success: true, articleId, result };\n } else {\n const error = await response.text();\n return { success: false, articleId, error };\n }\n } catch (error) {\n return { success: false, articleId, error: error.message };\n }\n}\n\nasync function batchUpdateImages() {\n console.log(`Starting batch update of ${updates.length} articles...`);\n \n const results = [];\n const errors = [];\n \n for (let i = 0; i < updates.length; i++) {\n const update = updates[i];\n console.log(`\\nUpdating ${i + 1}/${updates.length}: ${update.title}`);\n console.log(`Setting image: ${update.newImage}`);\n \n const result = await updateArticleImage(update.articleId, update.newImage);\n \n if (result.success) {\n console.log(`✓ Updated successfully`);\n results.push(result);\n } else {\n console.log(`✗ Failed: ${result.error}`);\n errors.push(result);\n }\n \n // Small delay to prevent overwhelming the server\n await new Promise(resolve => setTimeout(resolve, 500));\n }\n \n console.log(`\\n\\nBatch update completed:`);\n console.log(`✓ Successful updates: ${results.length}`);\n console.log(`✗ Failed updates: ${errors.length}`);\n \n if (errors.length > 0) {\n console.log('\\nFailed updates:');\n errors.forEach(error => {\n console.log(`- ${error.articleId}: ${error.error}`);\n });\n }\n \n return { results, errors };\n}\n\n// Run the batch update\nbatchUpdateImages().catch(console.error);","size_bytes":1987},"generate-profiles.ts":{"content":"import { generateBatchProfiles, MediaOutletProfile } from \"./server/openai-utils\";\nimport { storage } from \"./server/storage\";\n\n// Define the specific 12 media outlets we want to generate profiles for\nconst TARGET_OUTLETS = [\n 'alex-karp', 'arthur-hayes', 'jacob-robert-steeves', '07806f4a-4fdd-4a46-a814-a06d07b5bb2c',\n '80a9a613-b6e8-4431-a0c2-1f531d43a9c0', 'ai', 'dex', 'a60f82a2-2d56-4889-b312-28c232c660f9',\n '7f641e7b-0eca-4688-8bb4-74a7a15a844e', 'bittensor', 'hyperliquid', 'palantir'\n];\n\nasync function main() {\n try {\n console.log(\"🚀 Starting AI profile generation for 12 media outlets...\");\n \n // Get all outlets first to find the ones we need\n const allExistingOutlets = await storage.getAllOutlets();\n const targetOutlets = allExistingOutlets.filter(outlet => \n TARGET_OUTLETS.includes(outlet.id)\n );\n \n console.log(`📊 Found ${targetOutlets.length} outlets to process`);\n \n // Convert to the format expected by AI generation\n const outletProfiles: MediaOutletProfile[] = targetOutlets.map(outlet => ({\n name: outlet.name,\n category: outlet.category,\n focusSubject: outlet.focusSubject,\n bio: outlet.bio,\n fullBio: outlet.fullBio || []\n }));\n \n // Generate detailed profiles using AI\n console.log(\"🤖 Generating detailed Wikipedia-style profiles with AI...\");\n const generatedProfiles = await generateBatchProfiles(outletProfiles);\n \n // Update each outlet with the generated profile\n console.log(\"💾 Saving generated profiles to database...\");\n let successCount = 0;\n \n for (const outlet of targetOutlets) {\n const generatedProfile = generatedProfiles[outlet.name];\n if (generatedProfile) {\n try {\n await storage.updateOutlet(outlet.id, { wikiProfile: generatedProfile });\n console.log(`✅ Updated profile for ${outlet.name}`);\n successCount++;\n } catch (error) {\n console.error(`❌ Failed to update ${outlet.name}:`, error);\n }\n }\n }\n \n console.log(`\\n🎉 Profile generation completed!`);\n console.log(`✅ Successfully generated and saved ${successCount}/${targetOutlets.length} profiles`);\n console.log(\"\\nNext steps:\");\n console.log(\"1. Update the frontend UI to display the new detailed profiles\");\n console.log(\"2. Test the new profile display functionality\");\n \n } catch (error) {\n console.error(\"❌ Error during profile generation:\", error);\n process.exit(1);\n }\n}\n\n// Only run if this file is executed directly\nif (import.meta.url === `file://${process.argv[1]}`) {\n main().then(() => {\n console.log(\"✨ Profile generation script completed successfully\");\n process.exit(0);\n }).catch((error) => {\n console.error(\"💥 Profile generation script failed:\", error);\n process.exit(1);\n });\n}\n\nexport { main as generateAllProfiles };","size_bytes":2884},"server/openai-utils.ts":{"content":"import OpenAI from \"openai\";\n\n// the newest OpenAI model is \"gpt-5\" which was released August 7, 2025. do not change this unless explicitly requested by the user\nconst openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });\n\nexport interface MediaOutletProfile {\n name: string;\n category: string;\n focusSubject: string;\n bio: string;\n fullBio: string[];\n}\n\nexport async function generateDetailedProfile(outlet: MediaOutletProfile): Promise<string> {\n try {\n const prompt = `Create a comprehensive, Wikipedia-style profile for the following media outlet/subject. The profile should be detailed, extensive, and well-structured with multiple sections. Write in a neutral, encyclopedic tone similar to Wikipedia articles.\n\nMedia Outlet Information:\n- Name: ${outlet.name}\n- Category: ${outlet.category}\n- Focus Subject: ${outlet.focusSubject}\n- Current Bio: ${outlet.bio}\n- Additional Info: ${outlet.fullBio.join(' ')}\n\nPlease create a detailed profile that includes:\n\n1. **Overview/Introduction** - Comprehensive introduction paragraph\n2. **Background & History** - Detailed history and formation\n3. **Key Achievements** - Major accomplishments and milestones\n4. **Technology & Innovation** - Technical aspects, innovations, or methodologies (if applicable)\n5. **Market Position & Influence** - Position in industry/market and influence\n6. **Notable Developments** - Significant events, partnerships, or developments\n7. **Future Outlook** - Current projects and future direction\n8. **Industry Impact** - Broader impact on the industry or field\n\nFor people: Include personal background, career history, education, major contributions, and influence.\nFor companies: Include founding story, business model, products/services, market position, and key partnerships.\nFor topics/technologies: Include technical background, development history, applications, and significance.\n\nMake it comprehensive and informative, similar to a detailed Wikipedia article. Use HTML formatting with proper headings (h2, h3), paragraphs, and lists where appropriate. Aim for 2000-3000 words.\n\nRespond with only the HTML content, no markdown or additional formatting.`;\n\n const response = await openai.chat.completions.create({\n model: \"gpt-5\",\n messages: [{ role: \"user\", content: prompt }],\n max_completion_tokens: 4000\n });\n\n return response.choices[0].message.content || \"\";\n } catch (error) {\n console.error(\"Error generating detailed profile:\", error);\n throw new Error(`Failed to generate profile: ${error instanceof Error ? error.message : \"Unknown error\"}`);\n }\n}\n\nexport async function generateBatchProfiles(outlets: MediaOutletProfile[]): Promise<Record<string, string>> {\n const profiles: Record<string, string> = {};\n \n for (const outlet of outlets) {\n try {\n console.log(`Generating profile for ${outlet.name}...`);\n const profile = await generateDetailedProfile(outlet);\n profiles[outlet.name] = profile;\n \n // Add a small delay to avoid rate limiting\n await new Promise(resolve => setTimeout(resolve, 1000));\n } catch (error) {\n console.error(`Failed to generate profile for ${outlet.name}:`, error);\n profiles[outlet.name] = `<h2>Profile Generation Error</h2><p>Unable to generate detailed profile for ${outlet.name}. Please try again later.</p>`;\n }\n }\n \n return profiles;\n}","size_bytes":3365},"client/src/components/ArticleActionBar.tsx":{"content":"import { useState } from \"react\";\nimport { MessageCircle, Share2, Bookmark, Plus, Minus, RotateCcw } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport TextSizeIcon from \"./TextSizeIcon\";\n\ninterface ArticleActionBarProps {\n commentCount: number;\n isBookmarked: boolean;\n onCommentClick: () => void;\n onShareClick: () => void;\n onBookmarkClick: () => void;\n textSize: number;\n onTextSizeChange: (size: number) => void;\n}\n\nexport default function ArticleActionBar({\n commentCount,\n isBookmarked,\n onCommentClick,\n onShareClick,\n onBookmarkClick,\n textSize,\n onTextSizeChange,\n}: ArticleActionBarProps) {\n const [showTextSizeControls, setShowTextSizeControls] = useState(false);\n\n return (\n <>\n {/* Text Size Controls */}\n {showTextSizeControls && (\n <div className=\"fixed bottom-20 right-4 z-50 animate-in fade-in slide-in-from-bottom-2 duration-200\">\n <Card className=\"p-2 shadow-lg\">\n <div className=\"flex flex-col gap-1\">\n {/* Plus button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-10 w-10\"\n onClick={() => onTextSizeChange(Math.min(9, textSize + 1))}\n disabled={textSize === 9}\n data-testid=\"button-text-size-increase\"\n >\n <Plus className=\"h-5 w-5\" />\n </Button>\n \n {/* Minus button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-10 w-10\"\n onClick={() => onTextSizeChange(Math.max(0, textSize - 1))}\n disabled={textSize === 0}\n data-testid=\"button-text-size-decrease\"\n >\n <Minus className=\"h-5 w-5\" />\n </Button>\n \n {/* Reset button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-10 w-10\"\n onClick={() => {\n onTextSizeChange(5);\n setShowTextSizeControls(false);\n }}\n data-testid=\"button-text-size-reset\"\n >\n <RotateCcw className=\"h-5 w-5\" />\n </Button>\n </div>\n </Card>\n </div>\n )}\n\n {/* Action Bar */}\n <div className=\"bg-background border-t border-border shadow-lg\">\n <div className=\"flex items-center justify-around p-2 max-w-2xl mx-auto\">\n {/* Comments Button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"relative flex flex-col items-center h-auto py-2 px-4\"\n onClick={onCommentClick}\n data-testid=\"button-comments\"\n >\n <MessageCircle className=\"h-6 w-6\" />\n {commentCount > 0 && (\n <span className=\"text-xs mt-1\" data-testid=\"text-comment-count\">\n {commentCount}\n </span>\n )}\n </Button>\n\n {/* Share Button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"flex flex-col items-center h-auto py-2 px-4\"\n onClick={onShareClick}\n data-testid=\"button-share\"\n >\n <Share2 className=\"h-6 w-6\" />\n </Button>\n\n {/* Bookmark Button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"flex flex-col items-center h-auto py-2 px-4\"\n onClick={onBookmarkClick}\n data-testid=\"button-bookmark\"\n >\n <Bookmark\n className={`h-6 w-6 ${isBookmarked ? \"fill-current\" : \"\"}`}\n />\n </Button>\n\n {/* Text Size Button */}\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"flex flex-col items-center h-auto py-2 px-4\"\n onClick={() => setShowTextSizeControls(true)}\n data-testid=\"button-text-size\"\n >\n <TextSizeIcon className=\"h-6 w-6\" />\n </Button>\n </div>\n </div>\n </>\n );\n}\n","size_bytes":4224},"client/src/components/CommentsDrawer.tsx":{"content":"import { useState } from \"react\";\nimport { useQuery, useMutation } from \"@tanstack/react-query\";\nimport { X, Send, Loader2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Input } from \"@/components/ui/input\";\nimport { apiRequest, queryClient } from \"@/lib/queryClient\";\nimport { formatTimeAgo } from \"@/lib/utils\";\nimport type { Comment } from \"@shared/schema\";\n\ninterface CommentsDrawerProps {\n articleId: string;\n isOpen: boolean;\n onClose: () => void;\n}\n\nexport default function CommentsDrawer({\n articleId,\n isOpen,\n onClose,\n}: CommentsDrawerProps) {\n const [nickname, setNickname] = useState(() => {\n return localStorage.getItem(\"commentNickname\") || \"\";\n });\n const [commentContent, setCommentContent] = useState(\"\");\n\n const { data: commentsData, isLoading } = useQuery<{\n comments: Comment[];\n total: number;\n }>({\n queryKey: [\"/api/comments\", articleId],\n enabled: isOpen && !!articleId,\n });\n\n const createComment = useMutation({\n mutationFn: async (data: { content: string; nickname: string }) => {\n const response = await apiRequest(\"POST\", \"/api/comments\", {\n articleId,\n content: data.content,\n nickname: data.nickname,\n });\n return response.json();\n },\n onSuccess: () => {\n queryClient.invalidateQueries({ queryKey: [\"/api/comments\", articleId] });\n queryClient.invalidateQueries({\n queryKey: [\"/api/articles\", articleId, \"comment-count\"],\n });\n setCommentContent(\"\");\n },\n });\n\n const handleSubmit = (e: React.FormEvent) => {\n e.preventDefault();\n if (!commentContent.trim() || !nickname.trim()) return;\n\n localStorage.setItem(\"commentNickname\", nickname);\n createComment.mutate({\n content: commentContent,\n nickname: nickname,\n });\n };\n\n const comments = commentsData?.comments || [];\n const total = commentsData?.total || 0;\n\n if (!isOpen) return null;\n\n return (\n <div\n className=\"fixed inset-0 z-50 bg-black/30 animate-in fade-in duration-200\"\n onClick={onClose}\n >\n <div\n className=\"fixed bottom-0 left-0 right-0 bg-background rounded-t-2xl max-h-[80vh] flex flex-col animate-in slide-in-from-bottom duration-300\"\n onClick={(e) => e.stopPropagation()}\n >\n {/* Header */}\n <div className=\"flex items-center justify-between p-4 border-b\">\n <h2 className=\"text-lg font-semibold\" data-testid=\"text-comments-title\">\n Comments ({total})\n </h2>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n onClick={onClose}\n data-testid=\"button-close-comments\"\n >\n <X className=\"h-5 w-5\" />\n </Button>\n </div>\n\n {/* Comments List */}\n <div className=\"flex-1 overflow-y-auto p-4 space-y-4\">\n {isLoading ? (\n <div className=\"flex items-center justify-center py-8\">\n <Loader2 className=\"h-6 w-6 animate-spin text-muted-foreground\" />\n </div>\n ) : comments.length === 0 ? (\n <div className=\"text-center py-8 text-muted-foreground\">\n <p>No comments yet. Be the first to comment!</p>\n </div>\n ) : (\n comments.map((comment) => (\n <Card key={comment.id} className=\"p-4\" data-testid={`comment-${comment.id}`}>\n <div className=\"flex items-start gap-3\">\n <div className=\"w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0\">\n <span className=\"text-sm font-medium text-primary\">\n {comment.nickname.charAt(0).toUpperCase()}\n </span>\n </div>\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-baseline gap-2 mb-1\">\n <span className=\"font-medium text-sm\" data-testid=\"text-comment-nickname\">\n {comment.nickname}\n </span>\n <span className=\"text-xs text-muted-foreground\" data-testid=\"text-comment-time\">\n {formatTimeAgo(comment.createdAt)}\n </span>\n </div>\n <p className=\"text-sm leading-relaxed\" data-testid=\"text-comment-content\">\n {comment.content}\n </p>\n </div>\n </div>\n </Card>\n ))\n )}\n </div>\n\n {/* Comment Input */}\n <form\n onSubmit={handleSubmit}\n className=\"p-4 border-t bg-background space-y-3\"\n >\n {!nickname && (\n <Input\n placeholder=\"Your nickname\"\n value={nickname}\n onChange={(e) => setNickname(e.target.value)}\n maxLength={50}\n data-testid=\"input-nickname\"\n />\n )}\n <div className=\"flex gap-2\">\n <Textarea\n placeholder=\"Write a comment...\"\n value={commentContent}\n onChange={(e) => setCommentContent(e.target.value)}\n className=\"resize-none min-h-[60px]\"\n maxLength={500}\n data-testid=\"input-comment\"\n />\n <Button\n type=\"submit\"\n size=\"icon\"\n disabled={\n !commentContent.trim() ||\n !nickname.trim() ||\n createComment.isPending\n }\n data-testid=\"button-submit-comment\"\n >\n {createComment.isPending ? (\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n ) : (\n <Send className=\"h-4 w-4\" />\n )}\n </Button>\n </div>\n </form>\n </div>\n </div>\n );\n}\n","size_bytes":5959},"client/src/components/PredictionMarketCard.tsx":{"content":"import { DollarSign, Calendar } from \"lucide-react\";\nimport { Card } from \"@/components/ui/card\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { PredictionMarket } from \"@shared/schema\";\n\ninterface PredictionMarketCardProps {\n market: PredictionMarket;\n onClick?: () => void;\n}\n\ninterface MarketOption {\n name: string;\n price: number;\n image?: string;\n}\n\nexport default function PredictionMarketCard({ market, onClick }: PredictionMarketCardProps) {\n const endDate = new Date(market.endDate);\n const timeLeft = formatDistanceToNow(endDate, { addSuffix: true });\n \n const isBinary = market.marketType === \"binary\";\n let options: MarketOption[] = [];\n \n if (!isBinary && market.options) {\n try {\n options = JSON.parse(market.options);\n } catch (e) {\n console.error(\"Failed to parse market options:\", e);\n }\n }\n \n return (\n <Card \n className=\"p-4 hover-elevate cursor-pointer\" \n onClick={onClick}\n data-testid={`card-prediction-${market.id}`}\n >\n <div className=\"space-y-3\">\n {/* Header with question and live badge */}\n <div className=\"flex items-start justify-between gap-2\">\n <h3 className=\"font-semibold text-sm flex-1\">{market.question}</h3>\n {market.isLive === 1 && (\n <Badge variant=\"destructive\" className=\"text-xs\">\n LIVE\n </Badge>\n )}\n </div>\n \n {/* Binary Market (Yes/No) */}\n {isBinary && market.yesPrice !== null && market.noPrice !== null && (\n <div className=\"space-y-2\">\n <div className=\"flex items-center justify-between gap-2\">\n <div className=\"flex items-center gap-2 flex-1\">\n <span className=\"text-2xl font-bold\">{market.yesPrice}%</span>\n <span className=\"text-xs text-muted-foreground\">YES</span>\n </div>\n <div className=\"flex items-center gap-2 flex-1\">\n <span className=\"text-2xl font-bold\">{market.noPrice}%</span>\n <span className=\"text-xs text-muted-foreground\">NO</span>\n </div>\n </div>\n <div className=\"flex items-center gap-2\">\n <Button \n variant=\"outline\" \n size=\"sm\" \n className=\"flex-1\"\n onClick={(e) => { e.stopPropagation(); }}\n data-testid=\"button-bet-yes\"\n >\n Yes\n </Button>\n <Button \n variant=\"outline\" \n size=\"sm\" \n className=\"flex-1\"\n onClick={(e) => { e.stopPropagation(); }}\n data-testid=\"button-bet-no\"\n >\n No\n </Button>\n </div>\n </div>\n )}\n \n {/* Multiple Choice Market */}\n {!isBinary && options.length > 0 && (\n <div className=\"space-y-2\">\n {options.map((option, index) => (\n <div key={index} className=\"flex items-center justify-between gap-3\">\n <div className=\"flex items-center gap-2 flex-1\">\n {option.image && (\n <img \n src={option.image} \n alt={option.name}\n className=\"w-6 h-6 rounded-full object-cover\"\n />\n )}\n <span className=\"text-sm font-medium\">{option.name}</span>\n </div>\n <div className=\"flex items-center gap-2\">\n <span className=\"text-lg font-bold\">{option.price}%</span>\n <div className=\"flex gap-1\">\n <Button \n variant=\"outline\" \n size=\"sm\" \n className=\"h-7 px-2 text-xs\"\n onClick={(e) => { e.stopPropagation(); }}\n data-testid={`button-yes-${index}`}\n >\n Yes\n </Button>\n <Button \n variant=\"outline\" \n size=\"sm\" \n className=\"h-7 px-2 text-xs\"\n onClick={(e) => { e.stopPropagation(); }}\n data-testid={`button-no-${index}`}\n >\n No\n </Button>\n </div>\n </div>\n </div>\n ))}\n </div>\n )}\n \n {/* Footer with volume, time, and category */}\n <div className=\"flex items-center gap-3 text-xs text-muted-foreground border-t pt-2\">\n <div className=\"flex items-center gap-1\">\n <DollarSign className=\"h-3 w-3\" />\n <span>${(market.totalVolume / 1000).toFixed(0)}k Vol.</span>\n </div>\n {market.category && (\n <span>{market.category}</span>\n )}\n <div className=\"flex items-center gap-1 ml-auto\">\n <Calendar className=\"h-3 w-3\" />\n <span>{timeLeft}</span>\n </div>\n </div>\n </div>\n </Card>\n );\n}\n","size_bytes":5221},"client/src/components/TextSizeIcon.tsx":{"content":"interface TextSizeIconProps {\n className?: string;\n}\n\nexport default function TextSizeIcon({ className = \"h-5 w-5\" }: TextSizeIconProps) {\n return (\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n className={className}\n >\n {/* Small T */}\n <line x1=\"6\" y1=\"7\" x2=\"12\" y2=\"7\" />\n <line x1=\"9\" y1=\"7\" x2=\"9\" y2=\"15\" />\n \n {/* Large T */}\n <line x1=\"10\" y1=\"10\" x2=\"20\" y2=\"10\" />\n <line x1=\"15\" y1=\"10\" x2=\"15\" y2=\"22\" />\n </svg>\n );\n}\n","size_bytes":596}},"version":1}