diff --git a/packages/webapp/data/aiCodingHubData.ts b/packages/webapp/data/aiCodingHubData.ts index dde526d0f0..ed58c6d030 100644 --- a/packages/webapp/data/aiCodingHubData.ts +++ b/packages/webapp/data/aiCodingHubData.ts @@ -70,7 +70,7 @@ export const getTodayDateString = (): string => { export const getRecentDateStrings = (): string[] => { const dates: string[] = []; - for (let i = 0; i < 2; i++) { + for (let i = 0; i < 2; i += 1) { const date = new Date(); date.setDate(date.getDate() - i); dates.push(date.toISOString().split('T')[0]); diff --git a/packages/webapp/pages/ai-coding-hub-monitor.tsx b/packages/webapp/pages/ai-coding-hub-monitor.tsx new file mode 100644 index 0000000000..8bc709b714 --- /dev/null +++ b/packages/webapp/pages/ai-coding-hub-monitor.tsx @@ -0,0 +1,141 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { NextSeo } from 'next-seo'; +import { getLayout } from '../components/layouts/NoSidebarLayout'; +import { feedItems } from '../data/aiCodingHubData'; + +type MonitoredModel = { + id: string; + label: string; + keywords: string[]; +}; + +const monitoredModels: MonitoredModel[] = [ + { + id: 'codex', + label: 'Codex', + keywords: ['codex', 'gpt-5', 'chatgpt', 'openai'], + }, + { + id: 'opus', + label: 'Opus', + keywords: ['opus', 'anthropic', 'claude 4.6'], + }, + { + id: 'claude_code', + label: 'Claude Code', + keywords: ['claude code', 'claude', 'anthropic'], + }, + { + id: 'cursor', + label: 'Cursor', + keywords: ['cursor'], + }, + { + id: 'kimi', + label: 'Kimi', + keywords: ['kimi', 'moonshot'], + }, + { + id: 'copilot', + label: 'Copilot', + keywords: ['copilot', 'github copilot'], + }, + { + id: 'opencode', + label: 'OpenCode', + keywords: ['opencode', 'open code'], + }, +]; + +const AiCodingHubMonitorPage = (): ReactElement => { + const monitoredTags = useMemo(() => { + const counts = new Map(); + + feedItems.forEach((item) => { + item.tags.forEach((tag) => { + const normalizedTag = tag.trim(); + if (!normalizedTag) { + return; + } + counts.set(normalizedTag, (counts.get(normalizedTag) || 0) + 1); + }); + }); + + return [...counts.entries()] + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count); + }, []); + + return ( +
+ +
+
+

+ Monitored Models & Tags +

+

+ Complete overview of all entities we track in this feed. +

+ + Back to feed + +
+ +
+

+ Models we monitor +

+
+ {monitoredModels.map((model) => ( +
+

+ {model.label} +

+

+ {model.keywords.join(', ')} +

+
+ ))} +
+
+ +
+

+ Tags we monitor +

+
+ {monitoredTags.map(({ tag, count }) => ( +
+ #{tag} + {count} +
+ ))} +
+
+
+
+ ); +}; + +AiCodingHubMonitorPage.getLayout = getLayout; +AiCodingHubMonitorPage.layoutProps = { + screenCentered: false, + hideBackButton: true, +}; + +export default AiCodingHubMonitorPage; diff --git a/packages/webapp/pages/ai-coding-hub.tsx b/packages/webapp/pages/ai-coding-hub.tsx index 9985f71160..27dbdede16 100644 --- a/packages/webapp/pages/ai-coding-hub.tsx +++ b/packages/webapp/pages/ai-coding-hub.tsx @@ -1,400 +1,1606 @@ import type { ReactElement } from 'react'; -import React, { useState, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { useRouter } from 'next/router'; import { NextSeo } from 'next-seo'; import classNames from 'classnames'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { - Typography, - TypographyColor, - TypographyType, -} from '@dailydotdev/shared/src/components/typography/Typography'; import { TerminalIcon, TwitterIcon, + TrendingIcon, + AlertIcon, + MicrosoftIcon, + HotIcon, + InfoIcon, + UpvoteIcon, + DiscussIcon, + BookmarkIcon, + ShareIcon, } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; -import Link from '@dailydotdev/shared/src/components/utilities/Link'; import { getLayout } from '../components/layouts/NoSidebarLayout'; import { - feedItems, + feedItems as rawFeedItems, categoryLabels, - getRelativeDate, - getBreakingItems, - getMilestoneItems, - getTrendingTools, } from '../data/aiCodingHubData'; import type { FeedItem, Category } from '../data/aiCodingHubData'; -const SignalCard = ({ item }: { item: FeedItem }): ReactElement => { - const tweetUrl = `https://twitter.com/i/web/status/${item.source_tweet_id}`; - const detailUrl = `/ai-coding-hub/${item.id}`; +// --- HELPERS --- + +// Static date format to avoid hydration mismatch (no "X days ago" that changes) +const formatDate = (dateStr: string): string => { + if (!dateStr) { + return ''; + } + const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) { + return dateStr; + } + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +}; + +const getImpactScore = (id: string): number => { + let hash = 0; + for (let i = 0; i < id.length; i += 1) { + // eslint-disable-next-line no-bitwise + hash = (hash << 5) - hash + id.charCodeAt(i); + // eslint-disable-next-line no-bitwise + hash &= hash; + } + return 85 + (Math.abs(hash) % 10); +}; + +const getTimeLabelFromSeed = (seed: string): string => { + const normalizedSeed = seed || '0'; + let value = 0; + + for (let i = 0; i < normalizedSeed.length; i += 1) { + value = (value * 31 + normalizedSeed.charCodeAt(i)) % 1440; + } + + const hours = Math.floor(value / 60) + .toString() + .padStart(2, '0'); + const minutes = (value % 60).toString().padStart(2, '0'); + return `${hours}:${minutes}`; +}; + +const getInteractionCounts = (id: string) => { + let hash = 0; + for (let i = 0; i < id.length; i += 1) { + // eslint-disable-next-line no-bitwise + hash = (hash << 5) - hash + id.charCodeAt(i); + // eslint-disable-next-line no-bitwise + hash |= 0; + } + const upvotes = (Math.abs(hash) % 50) + 5; + // eslint-disable-next-line no-bitwise + const comments = Math.abs(hash >> 2) % 20; + return { upvotes, comments }; +}; + +// Precompute at module level (deterministic, no Date-based drift) +const feedItems: FeedItem[] = Array.isArray(rawFeedItems) + ? rawFeedItems.filter( + (item) => + item && + typeof item.id === 'string' && + typeof item.headline === 'string' && + item.headline.length > 0, + ) + : []; + +const breakingCategories = new Set(['drama', 'leak', 'hot_take']); + +type ModelDefinition = { + id: string; + label: string; + keywords: string[]; +}; + +type ModelStat = { + id: string; + label: string; + count: number; + latestDate: number; +}; + +type ModelFeedData = { + stats: ModelStat[]; + itemsByModel: Record; + trendingModelId: string | null; +}; + +const modelDefinitions: ModelDefinition[] = [ + { + id: 'codex', + label: 'Codex', + keywords: ['codex', 'gpt-5', 'gpt_5', 'chatgpt', 'openai'], + }, + { + id: 'opus', + label: 'Opus', + keywords: ['opus', 'anthropic', 'claude 4', 'claude 4.6', 'opus 4.6'], + }, + { + id: 'claude_code', + label: 'Claude Code', + keywords: ['claude code', 'claude', 'anthropic'], + }, + { + id: 'kimi', + label: 'Kimi', + keywords: ['kimi', 'moonshot'], + }, + { + id: 'copilot', + label: 'Copilot', + keywords: ['copilot', 'github copilot'], + }, + { + id: 'cursor', + label: 'Cursor', + keywords: ['cursor'], + }, + { + id: 'opencode', + label: 'OpenCode', + keywords: ['opencode', 'open code'], + }, +]; + +const normalizeText = (value: string): string => value.toLowerCase(); + +const getItemModelIds = (item: FeedItem): string[] => { + const haystack = normalizeText( + `${item.headline} ${item.summary} ${(item.tags || []).join(' ')}`, + ); + + return modelDefinitions + .filter((model) => + model.keywords.some((keyword) => + haystack.includes(normalizeText(keyword)), + ), + ) + .map((model) => model.id); +}; + +const getModelFeedData = (items: FeedItem[]): ModelFeedData => { + const itemsByModel: Record = {}; + const latestDateByModel: Record = {}; + + modelDefinitions.forEach((model) => { + itemsByModel[model.id] = []; + latestDateByModel[model.id] = 0; + }); + + items.forEach((item) => { + const modelIds = getItemModelIds(item); + if (modelIds.length === 0) { + return; + } + + const itemDate = new Date(item.date).getTime() || 0; + modelIds.forEach((modelId) => { + itemsByModel[modelId].push(item); + if (itemDate > latestDateByModel[modelId]) { + latestDateByModel[modelId] = itemDate; + } + }); + }); + + const stats = modelDefinitions + .map((model) => ({ + id: model.id, + label: model.label, + count: itemsByModel[model.id].length, + latestDate: latestDateByModel[model.id], + })) + .filter((model) => model.count > 0) + .sort((a, b) => { + if (b.count !== a.count) { + return b.count - a.count; + } + return b.latestDate - a.latestDate; + }); + + return { + stats, + itemsByModel, + trendingModelId: stats[0]?.id || null, + }; +}; + +const getPrimaryModelLabel = (item: FeedItem): string => { + const modelId = getItemModelIds(item)[0]; + if (!modelId) { + return 'General AI'; + } + const model = modelDefinitions.find( + (definition) => definition.id === modelId, + ); + return model?.label || 'General AI'; +}; + +const getBotHandle = (item: FeedItem): string => { + const label = getPrimaryModelLabel(item).toLowerCase().replace(/\s+/g, '_'); + return `@${label}_watch`; +}; + +const communityCategories = new Set([ + 'thread', + 'hot_take', + 'insight', + 'commentary', + 'tips', +]); + +const getCommunityWireItems = (items: FeedItem[]): FeedItem[] => { + const prioritized = items.filter((item) => + communityCategories.has(item.category), + ); + if (prioritized.length >= 4) { + return prioritized; + } + + // Keep fallback deterministic and compact. + const fallback = items.filter((item) => !prioritized.includes(item)); + return [...prioritized, ...fallback].slice(0, 8); +}; + +// --- THEME --- + +const THEME = { + bg: 'bg-background-default', +}; + +// --- COMPONENTS: ATOMS --- + +const Badge = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => ( + + {children} + +); + +// --- COMPONENTS: TICKER --- + +type TickerItemData = { + id: string; + text: string; + score: number; + trend: number; + icon?: ReactElement; +}; + +const tickerItems: TickerItemData[] = [ + { + id: '1', + text: 'GPT-5 Rumors', + score: 9.2, + trend: 12, + icon: , + }, + { + id: '2', + text: 'Cursor Adoption', + score: 8.5, + trend: 24, + icon: , + }, + { + id: '3', + text: 'Devin AI Updates', + score: 7.8, + trend: 5, + icon: , + }, + { + id: '4', + text: 'Claude 3.7 Leaks', + score: 8.9, + trend: 15, + icon: , + }, + { + id: '5', + text: 'Copilot Sentiment', + score: 6.4, + trend: -3, + icon: , + }, +]; + +const NewsTickerItem = ({ item }: { item: TickerItemData }) => ( +
+ {item.icon && {item.icon}} + + {item.text} + + + {item.score} + + 0 ? 'text-text-secondary' : 'text-text-tertiary', + )} + > + {item.trend > 0 ? '▲' : '▼'} {Math.abs(item.trend)}% + +
+); +const MarqueeTicker = ({ + items, + className, +}: { + items: TickerItemData[]; + className?: string; +}) => ( +
+
+
+ {items.map((item) => ( + + ))} + {items.map((item, i) => ( + + ))} +
+
+
+
+); + +const MarketOverview = ({ + breakingItems, + trendingModelLabel, +}: { + breakingItems: FeedItem[]; + trendingModelLabel: string; +}) => { + const topAlert = breakingItems[0]; + + return ( +
+
+
+
+ Trending Model +
+
+ {trendingModelLabel} + + +14% + +
+
+
+
+ Top Alert +
+
+ {topAlert?.headline || 'System nominal. No critical alerts.'} +
+
+
+
+ ); +}; + +const ModelChipRail = ({ + stats, + activeModelId, + onSelect, + className, +}: { + stats: ModelStat[]; + activeModelId: string; + onSelect: (modelId: string) => void; + className?: string; +}) => { return ( -
-
- +
+ + {stats.map((model) => ( + + ))}
+
+ ); +}; - - - void; +}) => { + const previewItems = items.slice(0, 4); + const getAvatarUrl = (item: FeedItem): string => + `https://i.pravatar.cc/80?u=${encodeURIComponent(getBotHandle(item))}`; + + return ( +
+ + + +
+ ); +}; -
-
    - {item.tags.slice(0, 3).map((tag) => ( -
  • - #{tag.replace(/_/g, '')} -
  • - ))} -
- - - source - +const CommunityWireFeed = ({ + items, + dateLabels, + onBack, +}: { + items: FeedItem[]; + dateLabels: Record; + onBack: () => void; +}) => { + return ( +
+
+
+
+ + AI + +
+

+ Community Wire Bot +

+

+ online • broadcast +

+
+
+ +
+

+ Live bot updates from the daily.dev community. +

+
+
+ {items.map((item) => ( +
+ + AI + +
+
+ + {getBotHandle(item)} + + + {dateLabels[item.id] || item.date} + +
+

+ {item.headline} +

+

+ {item.summary} +

+
+ + {getPrimaryModelLabel(item)} + + + update + +
+
+
+ ))}
-
+
); }; -type StatusSignal = { - icon: string; +// --- COMPONENTS: MEDIA SHARE MONITOR --- + +type TrendPulse = { + id: string; label: string; - text: string; - priority: number; + mentions: number; + currentMentions: number; + previousMentions: number; + delta: number; + latestSeenAt: number; + hotScore: number; + sparklineData: number[]; }; -const getSignalIcon = (category: Category): string => { - if (category === 'drama') { - return '🚨'; - } - if (category === 'leak') { - return '🔍'; - } - return '🌶️'; -}; +const getTrendPulseData = (items: FeedItem[]): TrendPulse[] => { + const itemTimes = items + .map((item) => new Date(item.date).getTime()) + .filter((time) => Number.isFinite(time) && time > 0); + const latestFeedTime = itemTimes.length > 0 ? Math.max(...itemTimes) : 0; + const dayMs = 24 * 60 * 60 * 1000; + const currentWindowStart = latestFeedTime - dayMs; + const previousWindowStart = latestFeedTime - dayMs * 2; + const weeklyWindowStart = latestFeedTime - dayMs * 7; -const SmartStatusBar = (): ReactElement | null => { - const signals = useMemo((): StatusSignal[] => { - const result: StatusSignal[] = []; - - const breaking = getBreakingItems(feedItems); - breaking.forEach((item) => { - result.push({ - icon: getSignalIcon(item.category), - label: categoryLabels[item.category], - text: item.headline, - priority: 1, - }); - }); + const rows = modelDefinitions.map((model) => { + let weeklyMentions = 0; + let currentMentions = 0; + let previousMentions = 0; + let latestSeenAt = 0; + const dailyCounts = Array(7).fill(0); - const milestones = getMilestoneItems(feedItems); - milestones.slice(0, 2).forEach((item) => { - result.push({ - icon: '📈', - label: 'MILESTONE', - text: item.headline, - priority: 2, - }); + items.forEach((item) => { + const modelIds = getItemModelIds(item); + if (!modelIds.includes(model.id)) { + return; + } + + const itemTime = new Date(item.date).getTime() || 0; + latestSeenAt = Math.max(latestSeenAt, itemTime); + + if (itemTime >= weeklyWindowStart) { + weeklyMentions += 1; + // Calculate day index (0 to 6, where 6 is today/latest) + const dayIndex = Math.floor((itemTime - weeklyWindowStart) / dayMs); + if (dayIndex >= 0 && dayIndex < 7) { + dailyCounts[dayIndex] += 1; + } + } + + if (itemTime >= currentWindowStart) { + currentMentions += 1; + } else if (itemTime >= previousWindowStart) { + previousMentions += 1; + } }); - const trending = getTrendingTools(feedItems); - if (trending.length > 0) { - const top = trending[0]; - result.push({ - icon: '🔥', - label: 'TRENDING', - text: `${top.name.replace(/_/g, ' ')} (${top.count} mentions)`, - priority: 3, - }); + let delta = 0; + if (previousMentions > 0) { + delta = ((currentMentions - previousMentions) / previousMentions) * 100; + } else if (currentMentions > 0) { + delta = 100; } - return result.sort((a, b) => a.priority - b.priority); - }, []); + return { + id: model.id, + label: model.label, + mentions: weeklyMentions, + currentMentions, + previousMentions, + delta, + latestSeenAt, + hotScore: currentMentions * 3 + weeklyMentions, + sparklineData: dailyCounts, + }; + }); - if (signals.length === 0) { - return null; + return rows + .filter((row) => row.mentions > 0 || row.currentMentions > 0) + .sort((a, b) => b.hotScore - a.hotScore); +}; + +const trendPulseData: TrendPulse[] = getTrendPulseData(feedItems); + +const monitorTabs = [ + { id: 'hot', label: 'Hot' }, + { id: 'alpha', label: 'Alpha' }, + { id: 'new', label: 'New' }, + { id: 'gainers', label: 'Gainers' }, + { id: 'losers', label: 'Losers' }, +] as const; + +type MonitorTabId = (typeof monitorTabs)[number]['id']; + +const getFilteredPulseItems = (tabId: MonitorTabId): TrendPulse[] => { + if (tabId === 'gainers') { + return trendPulseData + .filter((item) => item.delta > 0) + .sort((a, b) => b.delta - a.delta) + .slice(0, 5); } - const renderSignal = (signal: StatusSignal, prefix: string) => ( - - {signal.icon} - - {signal.text} - - - - ); + if (tabId === 'losers') { + return trendPulseData + .filter((item) => item.delta < 0) + .sort((a, b) => a.delta - b.delta) + .slice(0, 5); + } + + if (tabId === 'new') { + return [...trendPulseData] + .sort((a, b) => b.latestSeenAt - a.latestSeenAt) + .slice(0, 5); + } + + if (tabId === 'alpha') { + return [...trendPulseData].sort((a, b) => b.delta - a.delta).slice(0, 5); + } + + return [...trendPulseData] + .sort((a, b) => b.hotScore - a.hotScore) + .slice(0, 5); +}; + +const Sparkline = ({ + data, + isNegative, +}: { + data: number[]; + isNegative: boolean; +}) => { + const width = 36; + const height = 12; + const max = Math.max(...data, 1); + const min = 0; // Base from 0 to show magnitude + const range = max - min; + + const points = data + .map((val, i) => { + const x = (i / (data.length - 1)) * width; + const y = height - ((val - min) / range) * height; + return `${x},${y}`; + }) + .join(' '); return ( -
- + /> + +
+ ); +}; + +const TrendPulseRow = ({ item }: { item: TrendPulse }) => { + const isNegative = item.delta < 0; + + return ( +
+
+
+ + {item.label} + + {Math.abs(item.delta) >= 10 && ( + + + + )} +
+
+
+

+ {item.mentions.toLocaleString()} +

+
- {signals.map((signal) => renderSignal(signal, 'a'))} - {signals.map((signal) => renderSignal(signal, 'b'))} + {item.delta > 0 ? '+' : ''} + {item.delta.toFixed(2)}%
+
); }; -type FilterCategory = - | 'ALL' - | 'product_launch' - | 'feature' - | 'tips' - | 'announcement' - | 'drama' - | 'hot_take'; - -const AiCodingHubPage = (): ReactElement => { - const [activeCategory, setActiveCategory] = useState('ALL'); - - const filteredFeedItems = useMemo(() => { - if (activeCategory === 'ALL') { - return feedItems; - } - return feedItems.filter((item) => item.category === activeCategory); - }, [activeCategory]); +const WidgetComparisonMonitor = () => { + const router = useRouter(); + const [activeTabId, setActiveTabId] = useState('hot'); + const visibleItems = useMemo( + () => getFilteredPulseItems(activeTabId), + [activeTabId], + ); + + return ( +
+
+
+ {monitorTabs.map((tab) => ( + + ))} +
+
+ Model + Mentions + 24h chg% + Trend (7d) +
+
+ {visibleItems.map((item) => ( + + ))} +
+ +
+
+ ); +}; + +// --- COMPONENTS: HEADER --- + +const CompactHeader = () => { + const [dateStr, setDateStr] = useState(''); + + useEffect(() => { + setDateStr(new Date().toLocaleDateString()); + }, []); + + return ( +
+
+
+
+ +
+
+

+ AI CODING HUB +

+
+
+
+ {dateStr && ( + + {dateStr} + + )} +
+
+
+
+ + + + + + Live • 6.2k Sources + +
+
+ Updated 1m +
+
+
+ ); +}; - const todayCount = useMemo( - () => - feedItems.filter((item) => getRelativeDate(item.date) === 'today').length, - [], +// --- COMPONENTS: FEED ITEMS --- + +const NewsItem = ({ + item, + dateLabel, + isLatest = false, +}: { + item: FeedItem; + dateLabel: string; + isLatest?: boolean; +}) => { + const label = + categoryLabels[item.category as Category] || item.category || 'NEWS'; + const { upvotes, comments } = useMemo( + () => getInteractionCounts(item.id), + [item.id], ); return ( -
- +
+
+ {isLatest && ( + + Just in + + )} + + {label} + +
+ · + {getPrimaryModelLabel(item)} + · + {dateLabel} +
+
+
+

+ {item.headline} +

+

+ {item.summary} +

+
+
+ + + + +
+
+ ); +}; + +const SignalContextCard = ({ item }: { item: FeedItem }) => ( +
+
+
+ +
+
+
+ + Context • {getPrimaryModelLabel(item)} + +
+

+ {item.summary} +

+
+
+
+); + +// --- COMPONENTS: TOPIC ANALYSIS CARD --- + +const TopicAnalysisCard = ({ item }: { item: FeedItem }) => { + const impactScore = useMemo(() => getImpactScore(item.id), [item.id]); - {/* Header */} -
-
-
- +
+
+
+ +
+ + {impactScore} + +
+
+
+ +
+
+ + Topic Analysis + + + + +
+ +

+ {item.headline.replace(/^Topic Analysis:\s*/i, '')} +

+ +
+
+
+
+ +
+
+
+
+ ); +}; + +// --- COMPONENTS: BREAKING NEWS SECTION --- + +const BreakingNewsCarousel = ({ + items, + dateLabels, +}: { + items: FeedItem[]; + dateLabels: Record; +}) => { + const [pageIndex, setPageIndex] = useState(0); + const scrollRef = useRef(null); + const pageCount = items.length; + + const handleScroll = () => { + if (!scrollRef.current) { + return; + } + const { scrollLeft, clientWidth } = scrollRef.current; + const newIndex = Math.round(scrollLeft / clientWidth); + if (newIndex !== pageIndex) { + setPageIndex(newIndex); + } + }; + + const scrollToPage = (index: number) => { + if (scrollRef.current) { + scrollRef.current.scrollTo({ + left: index * scrollRef.current.clientWidth, + behavior: 'smooth', + }); + setPageIndex(index); + } + }; + + if (items.length === 0) { + return null; + } + + return ( +
+
+
+ + + Breaking + +
+
+ {Array.from({ length: pageCount }).map((_, index) => ( +
+
+ ); +}; + +const LivePodcastStrip = () => { + const podcasts = [ + { + id: 'pod-1', + handle: '@ai_live_daily', + text: 'This livestream is trending', + listeners: '1.2k listening', + }, + { + id: 'pod-2', + handle: '@sam_altman', + text: 'Reasoning roadmap AMA is live', + listeners: '980 listening', + }, + { + id: 'pod-3', + handle: '@cursor_team', + text: 'Agent workflows weekly standup', + listeners: '760 listening', + }, + ] as const; + + return ( +
+
+ {podcasts.map((podcast) => ( + + ))} +
+
+ ); +}; + +// --- COMPONENTS: PULL TO REFRESH --- + +const CyberRefreshIndicator = ({ + pullY, + isRefreshing, +}: { + pullY: number; + isRefreshing: boolean; +}) => { + const [hexString, setHexString] = useState('0x00'); + const opacity = Math.min(pullY / 80, 1); + + useEffect(() => { + if (pullY > 10 || isRefreshing) { + const interval = setInterval(() => { + setHexString( + `0x${Math.floor(Math.random() * 16777215) + .toString(16) + .toUpperCase()}`, + ); + }, 100); + return () => clearInterval(interval); + } + return undefined; + }, [pullY, isRefreshing]); + + if (pullY === 0 && !isRefreshing) { + return null; + } + + return ( +
+
+
+
+ + {isRefreshing ? '► UPDATING_FEED' : '▼ PULL_TO_SCAN'} +
-
-
+
+ ADDR: + {hexString} +
+
+ {Array.from({ length: 10 }).map((_, i) => { + const isActive = i < (pullY / 150) * 10; + let dotClass = 'bg-border-subtlest-tertiary'; + + if (isRefreshing) { + dotClass = 'animate-pulse bg-accent-avocado-default'; + } else if (isActive) { + dotClass = 'bg-accent-avocado-default'; + } - {/* Smart Status Bar */} -
-
- + return ( +
+ ); + })} +
+
+ ); +}; -
- {/* Category Filters */} -
- - - - - - - -
+// --- FEED COMPOSER --- + +const FeedComposer = ({ + items, + breakingItems, + dateLabels, +}: { + items: FeedItem[]; + breakingItems: FeedItem[]; + dateLabels: Record; +}) => { + const breakingIds = new Set(breakingItems.map((b) => b.id)); + const remaining = items.filter((i) => !breakingIds.has(i.id)); + const feedComponents: ReactElement[] = []; + + remaining.forEach((item, index) => { + const isTopicCard = (index + 1) % 5 === 0; + const isContextCard = (index + 1) % 3 === 0 && !isTopicCard; + const dateLabel = dateLabels[item.id] || item.date; + + if (isTopicCard) { + feedComponents.push( + , + ); + } else if (isContextCard) { + feedComponents.push( + , + ); + } else { + feedComponents.push( + , + ); + } + }); + + if (items.length === 0) { + return ( +
+ No feed items available. +
+ ); + } + + return ( + <> + {feedComponents} +
+ End of Stream +
+ + ); +}; + +// --- MAIN PAGE --- + +// Pre-compute everything at module level so SSR and client are identical +const allItems = feedItems; + +// Pre-compute date labels using formatDate (deterministic, no "now" dependency) +const dateLabels: Record = {}; +allItems.forEach((item) => { + dateLabels[item.id] = formatDate(item.date); +}); + +function AiCodingHubContent(): ReactElement { + const [pullY, setPullY] = useState(0); + const [isRefreshing, setIsRefreshing] = useState(false); + const touchStartXRef = useRef(null); + + const handleTouchStart = (e: React.TouchEvent) => { + if (window.scrollY === 0) { + touchStartXRef.current = e.touches[0].clientY; + } + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (touchStartXRef.current !== null && window.scrollY === 0) { + const currentY = e.touches[0].clientY; + const diff = currentY - touchStartXRef.current; + if (diff > 0) { + const resistance = Math.min(diff * 0.5, 150); + setPullY(resistance); + } + } + }; + + const handleTouchEnd = () => { + if (pullY > 80) { + setIsRefreshing(true); + setPullY(80); + setTimeout(() => { + setIsRefreshing(false); + setPullY(0); + }, 2000); + } else { + setPullY(0); + } + touchStartXRef.current = null; + }; + + const modelFeedData = useMemo(() => getModelFeedData(allItems), []); + const [activeModelId, setActiveModelId] = useState('all'); + const [activeFeedView, setActiveFeedView] = useState<'main' | 'community'>( + 'main', + ); + const feedStartRef = useRef(null); + const [showBottomNav, setShowBottomNav] = useState(true); + + useEffect(() => { + if (activeFeedView !== 'main') { + setShowBottomNav(false); + return undefined; + } + + const feedStartNode = feedStartRef.current; + if (!feedStartNode) { + return undefined; + } + + const observer = new IntersectionObserver( + ([entry]) => { + // Show bottom bar when the in-flow rail is below the viewport (user hasn't scrolled to it yet) + // Hide bottom bar when the in-flow rail is visible or above the viewport (sticky top takes over) + const isBelow = + !entry.isIntersecting && + entry.boundingClientRect.top > window.innerHeight; + setShowBottomNav(isBelow); + }, + { + threshold: 0, + }, + ); + + observer.observe(feedStartNode); + + return () => observer.disconnect(); + }, [activeFeedView]); + + const scopedItems = + activeModelId === 'all' + ? allItems + : modelFeedData.itemsByModel[activeModelId] || []; + const communityWireItems = getCommunityWireItems(scopedItems); + const scopedBreakingItems = scopedItems + .filter((item) => breakingCategories.has(item.category)) + .slice(0, 9); + const trendingModelLabel = + modelFeedData.stats.find( + (model) => model.id === modelFeedData.trendingModelId, + )?.label || 'N/A'; + + const handleModelSelect = (modelId: string): void => { + setActiveModelId(modelId); + const el = feedStartRef.current; + if (!el) { + return; + } + const rect = el.getBoundingClientRect(); + const isAlreadySticky = rect.top <= 1 && rect.top >= -1; + if (!isAlreadySticky) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }; - {/* Feed */} -
- {filteredFeedItems.length > 0 ? ( - filteredFeedItems.map((item) => ( - - )) + return ( +
+ + +
+ +
+ + {activeFeedView === 'community' ? ( + setActiveFeedView('main')} + /> ) : ( -
- - No signals in this category yet - -
+ <> + + + + { + setActiveFeedView('community'); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }} + /> + +
+ +
+ +
+ +
+ )}
- - {/* Footer */} -
- - Curated from Twitter, GitHub, and the dev community. Updated daily. - -
+ {showBottomNav && activeFeedView === 'main' && ( +
+
+ +
+
+ )}
); -}; +} + +const AiCodingHubPage = (): ReactElement => ; AiCodingHubPage.getLayout = getLayout; AiCodingHubPage.layoutProps = { screenCentered: false, hideBackButton: true }; diff --git a/packages/webapp/pages/ai-coding-hub/[id].tsx b/packages/webapp/pages/ai-coding-hub/[id].tsx index 76a537c4b0..ddf9b6895f 100644 --- a/packages/webapp/pages/ai-coding-hub/[id].tsx +++ b/packages/webapp/pages/ai-coding-hub/[id].tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/router'; import { NextSeo } from 'next-seo'; import classNames from 'classnames'; @@ -71,7 +71,7 @@ const TweetEmbed = ({ tweetId }: { tweetId: string }): ReactElement => { href={`https://twitter.com/i/web/status/${tweetId}`} target="_blank" rel="noopener noreferrer" - className="text-text-quaternary text-sm" + className="text-sm text-text-quaternary" > Loading tweet...