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>
This commit is contained in:
jungwoo choi
2025-10-23 14:30:25 +09:00
commit 919afe56f2
1516 changed files with 64072 additions and 0 deletions

24
client/index.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" />
<title>NewsHub - Specialized Media Outlets</title>
<meta name="description" content="Discover specialized news outlets focused on key people, topics, and companies in the blockchain and AI space. Stay informed with targeted journalism." />
<!-- Open Graph tags -->
<meta property="og:title" content="NewsHub - Specialized Media Outlets" />
<meta property="og:description" content="Specialized news outlets for blockchain and AI industry leaders" />
<meta property="og:type" content="website" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- This is a replit script which adds a banner on the top of the page when opened in development mode outside the replit environment -->
<script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

38
client/src/App.tsx Normal file
View File

@ -0,0 +1,38 @@
import { Switch, Route } from "wouter";
import { queryClient } from "./lib/queryClient";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { LanguageProvider } from "@/contexts/LanguageContext";
import HomePage from "@/pages/HomePage";
import OutletPage from "@/pages/OutletPage";
import ArticlePage from "@/pages/ArticlePage";
import NotFound from "@/pages/not-found";
function Router() {
return (
<Switch>
<Route path="/" component={HomePage} />
<Route path="/outlet/:id" component={OutletPage} />
<Route path="/article/:id" component={ArticlePage} />
<Route component={NotFound} />
</Switch>
);
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<LanguageProvider>
<TooltipProvider>
<div className="min-h-screen bg-background text-foreground">
<Router />
</div>
<Toaster />
</TooltipProvider>
</LanguageProvider>
</QueryClientProvider>
);
}
export default App;

View File

@ -0,0 +1,134 @@
import { useState } from "react";
import { MessageCircle, Share2, Bookmark, Plus, Minus, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import TextSizeIcon from "./TextSizeIcon";
interface ArticleActionBarProps {
commentCount: number;
isBookmarked: boolean;
onCommentClick: () => void;
onShareClick: () => void;
onBookmarkClick: () => void;
textSize: number;
onTextSizeChange: (size: number) => void;
}
export default function ArticleActionBar({
commentCount,
isBookmarked,
onCommentClick,
onShareClick,
onBookmarkClick,
textSize,
onTextSizeChange,
}: ArticleActionBarProps) {
const [showTextSizeControls, setShowTextSizeControls] = useState(false);
return (
<>
{/* Text Size Controls */}
{showTextSizeControls && (
<div className="fixed bottom-20 right-4 z-50 animate-in fade-in slide-in-from-bottom-2 duration-200">
<Card className="p-2 shadow-lg">
<div className="flex flex-col gap-1">
{/* Plus button */}
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => onTextSizeChange(Math.min(9, textSize + 1))}
disabled={textSize === 9}
data-testid="button-text-size-increase"
>
<Plus className="h-5 w-5" />
</Button>
{/* Minus button */}
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => onTextSizeChange(Math.max(0, textSize - 1))}
disabled={textSize === 0}
data-testid="button-text-size-decrease"
>
<Minus className="h-5 w-5" />
</Button>
{/* Reset button */}
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => {
onTextSizeChange(5);
setShowTextSizeControls(false);
}}
data-testid="button-text-size-reset"
>
<RotateCcw className="h-5 w-5" />
</Button>
</div>
</Card>
</div>
)}
{/* Action Bar */}
<div className="bg-background border-t border-border shadow-lg">
<div className="flex items-center justify-around p-2 max-w-2xl mx-auto">
{/* Comments Button */}
<Button
variant="ghost"
size="icon"
className="relative flex flex-col items-center h-auto py-2 px-4"
onClick={onCommentClick}
data-testid="button-comments"
>
<MessageCircle className="h-6 w-6" />
{commentCount > 0 && (
<span className="text-xs mt-1" data-testid="text-comment-count">
{commentCount}
</span>
)}
</Button>
{/* Share Button */}
<Button
variant="ghost"
size="icon"
className="flex flex-col items-center h-auto py-2 px-4"
onClick={onShareClick}
data-testid="button-share"
>
<Share2 className="h-6 w-6" />
</Button>
{/* Bookmark Button */}
<Button
variant="ghost"
size="icon"
className="flex flex-col items-center h-auto py-2 px-4"
onClick={onBookmarkClick}
data-testid="button-bookmark"
>
<Bookmark
className={`h-6 w-6 ${isBookmarked ? "fill-current" : ""}`}
/>
</Button>
{/* Text Size Button */}
<Button
variant="ghost"
size="icon"
className="flex flex-col items-center h-auto py-2 px-4"
onClick={() => setShowTextSizeControls(true)}
data-testid="button-text-size"
>
<TextSizeIcon className="h-6 w-6" />
</Button>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,230 @@
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ArrowRight, MessageCircle, ThumbsUp } from "lucide-react";
import { useMemo } from "react";
interface ArticleCardProps {
id: string;
title: string;
summary: string;
thumbnail: string;
publishedAt: Date;
timeAgo?: string;
outletName: string;
category: string;
onClick: (id: string) => void;
className?: string;
isAd?: boolean;
tags?: string[];
index?: number;
variant?: 'card' | 'list';
}
export default function ArticleCard({
id,
title,
summary,
thumbnail,
publishedAt,
timeAgo,
outletName,
category,
onClick,
className = "",
isAd = false,
tags = [],
index = 0,
variant = 'card',
}: ArticleCardProps) {
// Generate stable random data based on index and id (newer articles have smaller numbers)
const { minutesAgo, commentsCount, likesCount } = useMemo(() => {
// Use a simple hash of the id to get consistent randomness
const hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
const baseMinutes = index * 5;
const randomOffset = (hash % 5) + 1;
// Generate comments count (0-50 based on hash)
const comments = (hash % 51);
// Generate likes count (5-200 based on hash)
const likes = ((hash * 3) % 196) + 5;
return {
minutesAgo: baseMinutes + randomOffset,
commentsCount: comments,
likesCount: likes
};
}, [id, index]);
// List variant JSX
if (variant === 'list') {
return (
<Card
className={`overflow-hidden hover-elevate cursor-pointer ${className}`}
onClick={() => onClick(id)}
data-testid={`card-article-${id}`}
>
<div className="p-3 flex gap-3">
{/* Left: Thumbnail */}
<div className="w-20 h-16 shrink-0 relative overflow-hidden rounded">
<img
src={thumbnail}
alt={title}
className="w-full h-full object-cover"
data-testid={`img-thumbnail-${id}`}
/>
{isAd && (
<div className="absolute inset-0 flex items-center justify-center bg-amber-500/90">
<span className="text-xs text-white font-medium">Ad</span>
</div>
)}
</div>
{/* Right: Content */}
<div className="flex-1 min-w-0 space-y-1">
<h3
className="font-semibold text-sm leading-tight line-clamp-2"
data-testid={`text-title-${id}`}
>
{title}
</h3>
<p
className="text-xs text-muted-foreground line-clamp-2"
data-testid={`text-summary-${id}`}
>
{summary}
</p>
{/* Bottom: time and engagement */}
<div className="flex items-center justify-between text-xs text-muted-foreground pt-1">
<div className="flex items-center gap-2">
<span data-testid={`text-time-${id}`}>{timeAgo || `${minutesAgo} min ago`}</span>
</div>
{!isAd && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<MessageCircle className="h-3 w-3" />
<span data-testid={`text-comments-${id}`}>{commentsCount}</span>
</div>
<div className="flex items-center gap-1">
<ThumbsUp className="h-3 w-3" />
<span data-testid={`text-likes-${id}`}>{likesCount}</span>
</div>
</div>
)}
</div>
</div>
</div>
</Card>
);
}
// Card variant JSX (default)
return (
<Card
className={`overflow-hidden hover-elevate cursor-pointer flex flex-col ${className}`}
onClick={() => onClick(id)}
data-testid={`card-article-${id}`}
>
<div className="aspect-[21/9] relative overflow-hidden group">
<img
src={thumbnail}
alt={title}
className="w-full h-full object-cover"
data-testid={`img-thumbnail-${id}`}
/>
<div className="absolute top-2 left-2 flex gap-1">
{isAd ? (
<Badge variant="default" className="text-xs bg-amber-500 text-amber-50" data-testid={`badge-sponsored-${id}`} aria-label="Sponsored">
Sponsored
</Badge>
) : (
<Badge variant="secondary" className="text-xs">
{category}
</Badge>
)}
</div>
{/* Time info - bottom left (hide for ads) */}
{!isAd && (
<div className="absolute bottom-2 left-2">
<span className="text-xs text-white bg-black/60 px-2 py-1 rounded backdrop-blur-sm" data-testid={`text-time-${id}`}>
{timeAgo || `${minutesAgo} min ago`}
</span>
</div>
)}
</div>
<div className="p-4 space-y-2 flex-1 flex flex-col">
<h3
className="font-semibold text-base leading-tight line-clamp-3"
data-testid={`text-title-${id}`}
>
{title}
</h3>
<p
className="text-sm text-muted-foreground line-clamp-3 flex-1"
data-testid={`text-summary-${id}`}
>
{summary}
</p>
{/* Tags and Read More button */}
<div className="mt-auto pt-2">
{!isAd && tags && tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2" data-testid={`tags-${id}`}>
{tags.slice(0, 3).map((tag, tagIndex) => (
<Badge
key={tagIndex}
variant="outline"
className="text-xs px-2 py-0.5 h-5"
data-testid={`tag-${tag}-${id}`}
>
{tag}
</Badge>
))}
</div>
)}
{/* Engagement metrics and Read More button */}
<div className="flex items-center justify-between">
{/* Engagement metrics container - always present to maintain layout */}
<div className="flex-1">
{!isAd && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<MessageCircle className="h-3 w-3" />
<span data-testid={`text-comments-${id}`}>{commentsCount}</span>
</div>
<div className="flex items-center gap-1">
<ThumbsUp className="h-3 w-3" />
<span data-testid={`text-likes-${id}`}>{likesCount}</span>
</div>
</div>
)}
</div>
{/* Read More button - positioned at bottom right */}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onClick(id);
}}
className="text-xs px-3 py-1 h-7 text-muted-foreground hover:text-foreground"
data-testid={`button-read-more-${id}`}
>
Read More
<ArrowRight className="h-3 w-3 ml-1" />
</Button>
</div>
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,368 @@
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { formatTimeAgo } from "@/lib/utils";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import FeedItem from "./FeedItem";
import ArticlePopup from "./ArticlePopup";
import DOMPurify from "dompurify";
interface ArticleDetailProps {
id: string;
title: string;
summary: string;
body: string;
thumbnail: string;
publishedAt: Date;
timeAgo?: string; // Server-provided time ago text
outletName: string;
category: string;
tags?: string[];
subtopics?: Array<{ title: string; content: string[] }>;
textSize?: number;
onPreviousArticle?: () => void;
onNextArticle?: () => void;
hasPrevious?: boolean;
hasNext?: boolean;
onArticleClick?: (articleId: string) => void;
onOutletClick?: (outletId: string) => void;
}
export default function ArticleDetail({
id,
title,
summary,
body,
thumbnail,
publishedAt,
timeAgo,
outletName,
category,
tags = [],
subtopics = [],
textSize = 5,
onPreviousArticle,
onNextArticle,
hasPrevious = false,
hasNext = false,
onArticleClick,
onOutletClick,
}: ArticleDetailProps) {
const [imageLoaded, setImageLoaded] = useState(false);
// Use server-provided timeAgo or fallback to client calculation
const timeAgoText = timeAgo || formatTimeAgo(publishedAt);
// Clean up and truncate article body
const cleanedBody = useMemo(() => {
// More flexible pattern that handles:
// - Case insensitivity
// - Optional "This article was" prefix
// - "at" or "on" variations
// - CRLF line endings
// - Variable whitespace/newlines
const pattern = /^\s*(?:This article was\s+)?originally published\s+(?:at|on)\s+\S+\.?\s*(?:\r?\n){1,2}/i;
const cleaned = body.replace(pattern, '').trimStart();
// Truncate at 3000 characters, but complete the sentence
if (cleaned.length <= 3000) return cleaned;
// Find the next sentence ending after 3000 characters
const truncatePoint = 3000;
const afterTruncate = cleaned.substring(truncatePoint);
// Look for sentence endings: period, exclamation, question mark followed by space or end of string
const sentenceEndMatch = afterTruncate.match(/[.!?](?:\s|$)/);
if (sentenceEndMatch && sentenceEndMatch.index !== undefined) {
// Include the sentence ending character
const endIndex = truncatePoint + sentenceEndMatch.index + 1;
return cleaned.substring(0, endIndex);
}
// If no sentence ending found within reasonable distance, just cut at 3000
return cleaned.substring(0, 3000);
}, [body]);
const sanitizeHtml = (html: string) => {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'ul', 'ol', 'li', 'strong', 'em', 'a'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
ADD_ATTR: ['target', 'rel'],
});
};
// Parse text content and convert **text** to subtitle JSX elements
const parseBodyContent = (text: string) => {
const lines = text.split('\n');
const elements: React.ReactNode[] = [];
let currentParagraph: string[] = [];
let keyIndex = 0;
const flushParagraph = () => {
if (currentParagraph.length > 0) {
const paragraphText = currentParagraph.join('\n').trim();
if (paragraphText) {
// Process inline bold formatting in paragraphs
const processedParagraph = parseInlineBold(paragraphText);
elements.push(
<p key={`p-${keyIndex++}`} className="mb-4 leading-relaxed">
{processedParagraph}
</p>
);
}
currentParagraph = [];
}
};
const parseInlineBold = (text: string) => {
const parts = text.split(/(\*\*.*?\*\*)/);
return parts.map((part, index) => {
const boldMatch = part.match(/^\*\*(.*?)\*\*$/);
if (boldMatch) {
return <strong key={index}>{boldMatch[1]}</strong>;
}
return part;
});
};
lines.forEach((line, index) => {
const trimmedLine = line.trim();
// Check for different subtitle patterns
// Pattern 1: Full-line subtitle **Title**
const fullLineSubtitle = trimmedLine.match(/^\*\*(.*?)\*\*$/);
// Pattern 2: Prefixed subtitle **Title:** Content
const prefixedSubtitle = trimmedLine.match(/^\s*\*\*(.+?)\*\*\s*(.*)$/);
if (fullLineSubtitle && trimmedLine === `**${fullLineSubtitle[1]}**`) {
// Full-line subtitle - flush paragraph and add as h3
flushParagraph();
elements.push(
<h3 key={`subtitle-${keyIndex++}`} className="text-lg font-semibold mt-6 mb-3">
{fullLineSubtitle[1]}
</h3>
);
} else if (prefixedSubtitle && prefixedSubtitle[2].trim() !== '') {
// Prefixed subtitle with content - flush paragraph, add h3, then start new paragraph
flushParagraph();
elements.push(
<h3 key={`subtitle-${keyIndex++}`} className="text-lg font-semibold mt-6 mb-3">
{prefixedSubtitle[1]}
</h3>
);
// Start new paragraph with remaining content if it exists
if (prefixedSubtitle[2].trim()) {
currentParagraph.push(prefixedSubtitle[2].trim());
}
} else if (trimmedLine === '') {
// Empty line - flush current paragraph
flushParagraph();
} else {
// Regular content line - add to current paragraph
currentParagraph.push(line);
}
});
// Flush any remaining paragraph
flushParagraph();
return elements;
};
const scrollToContent = () => {
const contentElement = document.querySelector('[data-testid="article-content"]');
if (contentElement) {
contentElement.scrollIntoView({ behavior: 'smooth' });
}
};
// Calculate font size based on textSize (1-10 range, 5 is default)
// textSize 1 = 0.68rem, textSize 5 = 1rem, textSize 10 = 1.4rem
const bodyFontSize = `${(textSize / 10) * 0.8 + 0.6}rem`;
const summaryFontSize = `${(textSize / 10) * 0.8 + 0.8}rem`;
const titleFontSize = `${(textSize / 10) * 1.2 + 1.4}rem`;
// Fetch latest articles for the feed below comments
const { data: latestArticles } = useQuery({
queryKey: ['/api/feed', { limit: 20, exclude: id }],
queryFn: async () => {
const response = await fetch('/api/feed?limit=20');
return response.json();
},
});
const handleArticleClick = (articleId: string) => {
if (onArticleClick) {
onArticleClick(articleId);
}
};
const handleOutletClick = (outletId: string) => {
if (onOutletClick) {
onOutletClick(outletId);
}
};
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(null);
const handlePlayClick = (articleId: string) => {
setSelectedArticleId(articleId);
};
const handleClosePopup = () => {
setSelectedArticleId(null);
};
return (
<div className="max-w-4xl mx-auto">
{/* Hero image */}
<div className="aspect-video relative overflow-hidden rounded-lg mb-6">
<img
src={thumbnail}
alt={title}
className={`w-full h-full object-cover transition-opacity duration-300 cursor-pointer ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={() => setImageLoaded(true)}
onClick={scrollToContent}
data-testid="img-article-hero"
/>
{!imageLoaded && (
<div className="absolute inset-0 bg-muted animate-pulse" />
)}
{/* Category badge */}
<div className="absolute top-4 left-4">
<Badge variant="secondary" data-testid="badge-category">
{category}
</Badge>
</div>
{/* Time info - bottom left */}
<div className="absolute bottom-3 left-3">
<span className="text-xs text-white drop-shadow-lg" data-testid="text-published">
{timeAgoText}
</span>
</div>
</div>
{/* Article content */}
<Card className="p-6 space-y-6" data-testid="article-content">
{/* Title */}
<h1
className="font-bold leading-tight"
style={{ fontSize: titleFontSize }}
data-testid="text-title"
>
{title}
</h1>
{/* Summary */}
<p
className="text-muted-foreground leading-relaxed"
style={{ fontSize: summaryFontSize }}
data-testid="text-summary"
>
{summary}
</p>
{/* Body content */}
<div
className="prose prose-sm max-w-none dark:prose-invert"
style={{ fontSize: bodyFontSize }}
data-testid="text-body"
>
{subtopics && subtopics.length > 0 ? (
<div className="space-y-6">
{subtopics.map((topic, index) => (
<div key={index}>
<h3 className="text-lg font-semibold mb-3">{topic.title}</h3>
{Array.isArray(topic.content) ? (
topic.content.map((paragraph, pIndex) => (
<p key={pIndex} className="mb-4 leading-relaxed">
{paragraph}
</p>
))
) : (
<p className="mb-4 leading-relaxed">{topic.content}</p>
)}
</div>
))}
</div>
) : (
parseBodyContent(cleanedBody)
)}
</div>
</Card>
{/* Navigation */}
{(hasPrevious || hasNext) && (
<div className="flex items-center justify-between mt-6 gap-4">
<Button
variant="outline"
onClick={onPreviousArticle}
disabled={!hasPrevious}
data-testid="button-previous"
className="flex items-center gap-2 flex-1"
>
<ChevronLeft className="h-4 w-4" />
Previous Article
</Button>
<Button
variant="outline"
onClick={onNextArticle}
disabled={!hasNext}
data-testid="button-next"
className="flex items-center gap-2 flex-1"
>
Next Article
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
{/* Latest Articles Feed - YouTube style below comments */}
{latestArticles?.items && latestArticles.items.length > 0 && (
<div className="mt-8">
<div className="space-y-4">
{latestArticles.items
.filter((article: any) => article.id !== id) // Exclude current article
.map((article: any) => (
<FeedItem
key={article.id}
id={article.id}
title={article.title}
summary={article.summary}
thumbnail={article.thumbnail}
publishedAt={new Date(article.publishedAt)}
timeAgo={article.timeAgo}
outletName={article.outletName}
outletAvatar={article.outletAvatar}
viewCount={article.viewCount}
category={article.category}
onClick={handleArticleClick}
onOutletClick={handleOutletClick}
onPlayClick={handlePlayClick}
outletId={article.outletId}
className="hover:bg-muted/50 transition-colors rounded-lg p-2"
/>
))
}
</div>
</div>
)}
{/* Article Popup */}
<ArticlePopup
articleId={selectedArticleId || ''}
isOpen={!!selectedArticleId}
onClose={handleClosePopup}
/>
</div>
);
}

View File

@ -0,0 +1,226 @@
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { formatTimeAgo } from "@/lib/utils";
import { X, Share, Play, Pause, Loader2 } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTTS } from "@/hooks/useTTS";
import type { Article } from "@shared/schema";
interface ArticlePopupProps {
articleId: string;
isOpen: boolean;
onClose: () => void;
}
export default function ArticlePopup({ articleId, isOpen, onClose }: ArticlePopupProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const { isPlaying, isPaused, isLoading, toggle } = useTTS({
voice: 'nova',
speed: 1.0
});
// Fetch article data
const { data: article, isLoading: articleLoading } = useQuery<Article>({
queryKey: ['/api/articles', articleId],
enabled: !!articleId && isOpen
});
// Fetch outlet information
const { data: outlet } = useQuery<{name: string; category: string}>({
queryKey: ['/api/outlets', article?.outletId],
enabled: !!article?.outletId && isOpen
});
// Clean up and truncate article body
const cleanedBody = useMemo(() => {
if (!article?.body) return '';
const pattern = /^\s*(?:This article was\s+)?originally published\s+(?:at|on)\s+\S+\.?\s*(?:\r?\n){1,2}/i;
const cleaned = article.body.replace(pattern, '').trimStart();
// Truncate at 3000 characters, but complete the sentence
if (cleaned.length <= 3000) return cleaned;
// Find the next sentence ending after 3000 characters
const truncatePoint = 3000;
const afterTruncate = cleaned.substring(truncatePoint);
// Look for sentence endings: period, exclamation, question mark followed by space or end of string
const sentenceEndMatch = afterTruncate.match(/[.!?](?:\s|$)/);
if (sentenceEndMatch) {
// Include the sentence ending character
const endIndex = truncatePoint + sentenceEndMatch.index + 1;
return cleaned.substring(0, endIndex);
}
// If no sentence ending found within reasonable distance, just cut at 3000
return cleaned.substring(0, 3000);
}, [article?.body]);
const handleShare = () => {
if (navigator.share && article) {
navigator.share({
title: article.title,
text: article.summary,
url: window.location.href,
});
} else if (article) {
navigator.clipboard.writeText(window.location.href);
console.log('URL copied to clipboard');
}
};
const handlePlayAudio = () => {
toggle(articleId);
};
if (!isOpen) return null;
if (articleLoading || !article) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
<div className="flex items-center justify-center p-8">
<div className="text-center space-y-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground">Loading article...</p>
</div>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
<ScrollArea className="h-full max-h-[90vh]">
<div className="p-6 space-y-6">
{/* Header with close button */}
<DialogHeader className="flex flex-row items-center justify-between space-y-0">
<DialogTitle className="sr-only">Article Details</DialogTitle>
<div className="flex gap-2 ml-auto">
<Button
variant="outline"
size="icon"
onClick={handlePlayAudio}
disabled={isLoading}
data-testid="button-audio-popup"
className="h-8 w-8"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isPlaying && !isPaused ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
onClick={handleShare}
data-testid="button-share-popup"
className="h-8 w-8"
>
<Share className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={onClose}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
</DialogHeader>
{/* Hero image */}
<div className="aspect-video relative overflow-hidden rounded-lg">
<img
src={article.thumbnail}
alt={article.title}
className={`w-full h-full object-cover transition-opacity duration-300 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={() => setImageLoaded(true)}
data-testid="img-article-popup-hero"
/>
{!imageLoaded && (
<div className="absolute inset-0 bg-muted animate-pulse" />
)}
{/* Category badge */}
<div className="absolute top-4 left-4">
<Badge variant="secondary" data-testid="badge-category-popup">
{outlet?.category || 'General'}
</Badge>
</div>
</div>
{/* Article content */}
<Card className="p-6 space-y-6">
{/* Meta info */}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span data-testid="text-outlet-popup">{outlet?.name || 'Unknown Outlet'}</span>
<span data-testid="text-published-popup">
{formatTimeAgo(article.publishedAt)}
</span>
</div>
{/* Title */}
<h1
className="text-2xl font-bold leading-tight"
data-testid="text-title-popup"
>
{article.title}
</h1>
{/* Summary */}
<p
className="text-lg text-muted-foreground leading-relaxed"
data-testid="text-summary-popup"
>
{article.summary}
</p>
{/* Tags */}
{article.tags && article.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{article.tags.map((tag, index) => (
<Badge
key={index}
variant="outline"
className="text-xs"
data-testid={`tag-popup-${index}`}
>
{tag}
</Badge>
))}
</div>
)}
{/* Body content */}
<div
className="prose prose-sm max-w-none dark:prose-invert"
data-testid="text-body-popup"
>
{cleanedBody.split(/\r?\n\r?\n+/).filter(Boolean).map((paragraph, index) => (
<p key={index} className="mb-4 leading-relaxed">
{paragraph}
</p>
))}
</div>
</Card>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,80 @@
import { X } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface AvatarPreviewDialogProps {
children: React.ReactNode;
avatarSrc?: string;
profileImageSrc?: string;
avatarAlt: string;
fallbackText: string;
title?: string;
description?: string;
}
export default function AvatarPreviewDialog({
children,
avatarSrc,
profileImageSrc,
avatarAlt,
fallbackText,
title,
description
}: AvatarPreviewDialogProps) {
const imageSrc = profileImageSrc || avatarSrc;
return (
<Dialog>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="max-w-3xl p-0 bg-black/95 backdrop-blur-xl border-none shadow-2xl overflow-hidden">
<DialogTitle className="sr-only">{title || `${avatarAlt} profile picture`}</DialogTitle>
<div className="relative group">
<div className="relative overflow-hidden">
<img
src={imageSrc}
alt={avatarAlt}
className="w-full h-auto max-h-[80vh] object-contain animate-in zoom-in-95 fade-in duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
{/* Close button */}
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 bg-white/10 hover:bg-white/20 backdrop-blur-sm text-white border border-white/20 rounded-full h-10 w-10 transition-all duration-200 hover:scale-110"
onClick={(e) => {
e.stopPropagation();
const dialog = (e.target as HTMLElement).closest('[role="dialog"]');
if (dialog) {
const closeButton = dialog.querySelector('[aria-label="Close"]') as HTMLElement;
closeButton?.click();
}
}}
>
<X className="h-5 w-5" />
</Button>
{/* Image info overlay */}
{(title || description) && (
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
{title && (
<h3 className="text-white font-semibold text-lg mb-1">{title}</h3>
)}
{description && (
<p className="text-white/80 text-sm">{description}</p>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,210 @@
import { Users, TrendingUp, Building2, ChevronLeft, ChevronRight, MessageCircle, Share2, Bookmark } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLocation } from "wouter";
import { useState, useEffect } from "react";
import TextSizeIcon from "./TextSizeIcon";
// Sapiens logo from attached assets
const sapiensLogo = "/api/assets/sapiens-logo.png";
interface BottomTabBarProps {
activeTab?: 'people' | 'topics' | 'companies';
onTabChange?: (tab: 'people' | 'topics' | 'companies') => void;
className?: string;
variant?: 'tabs' | 'nav';
onTitleClick?: () => void; // For nav variant Sapiens title click
onBackClick?: () => void; // Custom back button handler
// Article action props for nav variant
commentCount?: number;
isBookmarked?: boolean;
onCommentClick?: () => void;
onShareClick?: () => void;
onBookmarkClick?: () => void;
onTextSizeClick?: () => void;
}
const tabs = [
{ id: 'people' as const, label: 'People', icon: Users },
{ id: 'topics' as const, label: 'Topics', icon: TrendingUp },
{ id: 'companies' as const, label: 'Companies', icon: Building2 },
];
export default function BottomTabBar({
activeTab,
onTabChange,
className = "",
variant = 'tabs',
onTitleClick,
onBackClick,
commentCount,
isBookmarked,
onCommentClick,
onShareClick,
onBookmarkClick,
onTextSizeClick,
}: BottomTabBarProps) {
const [, setLocation] = useLocation();
const [canGoForward, setCanGoForward] = useState(false);
useEffect(() => {
const handlePopState = () => {
// After a popstate (back button), we can potentially go forward
setCanGoForward(true);
};
const handleBeforeUnload = () => {
// Reset forward state when navigating away
setCanGoForward(false);
};
window.addEventListener('popstate', handlePopState);
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('popstate', handlePopState);
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, []);
if (variant === 'nav') {
// Navigation mode with back/forward buttons and action icons
const hasActionIcons = onCommentClick || onShareClick || onBookmarkClick || onTextSizeClick;
return (
<div className={`fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur border-t border-border z-40 ${className}`}>
<div className="flex items-center justify-between px-2 py-2">
{/* Back button */}
<Button
variant="ghost"
size="icon"
onClick={onBackClick || (() => window.history.back())}
data-testid="button-nav-back"
className="h-9 w-9 opacity-100"
>
<ChevronLeft className="h-5 w-5" />
</Button>
{/* Center content - Action icons OR Sapiens logo */}
{hasActionIcons ? (
<div className="flex items-center gap-1">
{/* Comments Button */}
{onCommentClick && (
<Button
variant="ghost"
size="icon"
className="relative h-9 w-9"
onClick={onCommentClick}
data-testid="button-comments"
>
<MessageCircle className="h-5 w-5" />
{commentCount !== undefined && commentCount > 0 && (
<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">
{commentCount}
</span>
)}
</Button>
)}
{/* Share Button */}
{onShareClick && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={onShareClick}
data-testid="button-share"
>
<Share2 className="h-5 w-5" />
</Button>
)}
{/* Bookmark Button */}
{onBookmarkClick && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={onBookmarkClick}
data-testid="button-bookmark"
>
<Bookmark
className={`h-5 w-5 ${isBookmarked ? "fill-current" : ""}`}
/>
</Button>
)}
{/* Text Size Button */}
{onTextSizeClick && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={onTextSizeClick}
data-testid="button-text-size"
>
<TextSizeIcon className="h-5 w-5" />
</Button>
)}
</div>
) : (
<button
onClick={onTitleClick || (() => setLocation('/'))}
className="hover:opacity-80 transition-opacity cursor-pointer"
data-testid="button-nav-logo"
>
<div className="h-4 flex items-center">
<img
src={sapiensLogo}
alt="SAPIENS"
className="h-full w-auto object-contain dark:invert"
/>
</div>
</button>
)}
{/* Forward button */}
<Button
variant="ghost"
size="icon"
onClick={() => window.history.forward()}
data-testid="button-nav-forward"
className={`h-9 w-9 ${canGoForward ? 'opacity-100' : 'opacity-40'}`}
disabled={!canGoForward}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
</div>
);
}
// Default tabs mode
return (
<div className={`fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur border-t border-border z-50 ${className}`}>
<div className="flex items-center justify-around px-2 py-1">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<Button
key={tab.id}
variant="ghost"
size="sm"
onClick={() => onTabChange?.(tab.id)}
className={`flex flex-col items-center gap-1 h-12 px-3 ${
isActive
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
data-testid={`tab-${tab.id}`}
>
<Icon className={`h-5 w-5 ${isActive ? 'fill-current' : ''}`} />
<span className="text-xs font-medium">{tab.label}</span>
</Button>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
interface Category {
id: string;
name: string;
count: number;
}
interface CategoryTabsProps {
categories: Category[];
activeCategory: string;
onCategoryChange: (categoryId: string) => void;
}
export default function CategoryTabs({
categories,
activeCategory,
onCategoryChange,
}: CategoryTabsProps) {
return (
<div className="flex gap-2 px-4 py-3 overflow-x-auto scrollbar-hide">
{categories.map((category) => (
<Button
key={category.id}
variant={activeCategory === category.id ? "default" : "outline"}
size="sm"
onClick={() => onCategoryChange(category.id)}
data-testid={`tab-${category.id}`}
className="flex items-center gap-2 whitespace-nowrap shrink-0"
>
<span>{category.name}</span>
<Badge
variant="secondary"
className="h-5 px-1.5 text-xs"
>
{category.count}
</Badge>
</Button>
))}
</div>
);
}

View File

@ -0,0 +1,429 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { ThumbsUp, ThumbsDown, ChevronDown, ChevronUp, ArrowUpDown } from "lucide-react";
import { formatTimeAgo } from "@/lib/utils";
import { apiRequest } from "@/lib/queryClient";
import AvatarPreviewDialog from "@/components/AvatarPreviewDialog";
interface Comment {
id: string;
content: string;
nickname: string;
avatar?: string;
createdAt: string;
likesCount: number;
dislikesCount: number;
repliesCount: number;
parentId?: string;
}
interface CommentSectionProps {
articleId: string;
}
function getUserIdentifier() {
let userId = localStorage.getItem('commentUserId');
if (!userId) {
userId = 'user_' + Math.random().toString(36).substring(2, 15);
localStorage.setItem('commentUserId', userId);
}
return userId;
}
function CommentForm({
articleId,
parentId,
onSuccess,
placeholder = "Add comment...",
buttonText = "Comment"
}: {
articleId: string;
parentId?: string;
onSuccess?: () => void;
placeholder?: string;
buttonText?: string;
}) {
const [content, setContent] = useState("");
const [nickname, setNickname] = useState(() =>
localStorage.getItem('commentNickname') || ""
);
const [showForm, setShowForm] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const queryClient = useQueryClient();
const createComment = useMutation({
mutationFn: async (data: { content: string; nickname: string; parentId?: string }) => {
const response = await apiRequest('POST', `/api/articles/${articleId}/comments`, data);
return response.json();
},
onSuccess: () => {
setContent("");
setShowForm(false);
setIsFocused(false);
localStorage.setItem('commentNickname', nickname);
queryClient.invalidateQueries({ queryKey: ['/api/articles', articleId, 'comments'] });
if (parentId) {
queryClient.invalidateQueries({ queryKey: ['/api/comments', parentId, 'replies'] });
}
onSuccess?.();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim() || !nickname.trim()) return;
createComment.mutate({
content: content.trim(),
nickname: nickname.trim(),
parentId
});
};
if (!showForm && parentId) {
return (
<Button
variant="ghost"
size="sm"
onClick={() => setShowForm(true)}
className="text-sm font-normal p-0 h-auto hover:bg-transparent text-muted-foreground hover:text-foreground"
data-testid="button-reply"
>
Reply
</Button>
);
}
if (!showForm && !parentId) {
return (
<div className="mb-6">
<div
className="flex items-start gap-3"
onClick={() => setShowForm(true)}
data-testid="button-add-comment"
>
<Avatar className="w-10 h-10 flex-shrink-0">
<AvatarFallback>👤</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="border-b-2 border-muted hover:border-foreground transition-colors cursor-text py-2">
<span className="text-muted-foreground text-sm">{placeholder}</span>
</div>
</div>
</div>
</div>
);
}
return (
<div className="mb-6">
<form onSubmit={handleSubmit} className="space-y-3">
<div className="flex items-start gap-3">
<Avatar className="w-10 h-10 flex-shrink-0">
<AvatarFallback>👤</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-3">
{!localStorage.getItem('commentNickname') && (
<Input
placeholder="Enter your nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className="border-0 border-b-2 border-muted rounded-none bg-transparent px-0 focus-visible:ring-0 focus-visible:border-foreground"
data-testid="input-nickname"
required
/>
)}
<Textarea
placeholder={placeholder}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={() => setIsFocused(true)}
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"
data-testid="textarea-comment"
required
/>
{(isFocused || content) && (
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setShowForm(false);
setContent("");
setIsFocused(false);
}}
data-testid="button-cancel-comment"
className="h-9 px-4 rounded-full"
>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={!content.trim() || !nickname.trim() || createComment.isPending}
data-testid="button-submit-comment"
className="h-9 px-4 rounded-full"
>
{createComment.isPending ? "Posting..." : buttonText}
</Button>
</div>
)}
</div>
</div>
</form>
</div>
);
}
function CommentItem({ comment, articleId, level = 0 }: {
comment: Comment;
articleId: string;
level?: number;
}) {
const [showReplies, setShowReplies] = useState(false);
const [showReplyForm, setShowReplyForm] = useState(false);
const [userReaction, setUserReaction] = useState<'like' | 'dislike' | null>(null);
const queryClient = useQueryClient();
const userIdentifier = getUserIdentifier();
const { data: reactionData } = useQuery({
queryKey: ['/api/comments', comment.id, 'reactions', userIdentifier],
queryFn: async () => {
const response = await apiRequest('GET', `/api/comments/${comment.id}/reactions/${userIdentifier}`);
return response.json();
},
});
const { data: repliesData } = useQuery({
queryKey: ['/api/comments', comment.id, 'replies'],
queryFn: async () => {
const response = await apiRequest('GET', `/api/comments/${comment.id}/replies`);
return response.json();
},
enabled: showReplies && comment.repliesCount > 0,
});
const toggleReaction = useMutation({
mutationFn: async (reactionType: 'like' | 'dislike') => {
const response = await apiRequest('POST', `/api/comments/${comment.id}/reactions`, {
reactionType,
userIdentifier
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/articles', articleId, 'comments'] });
queryClient.invalidateQueries({ queryKey: ['/api/comments', comment.id, 'reactions', userIdentifier] });
},
});
const currentReaction = reactionData?.reactionType || userReaction;
const handleReaction = (reactionType: 'like' | 'dislike') => {
setUserReaction(currentReaction === reactionType ? null : reactionType);
toggleReaction.mutate(reactionType);
};
return (
<div className={`${level > 0 ? 'ml-10 pl-6 border-l border-muted' : ''} py-3`}>
<div className="flex gap-3">
{comment.avatar ? (
<AvatarPreviewDialog
avatarSrc={comment.avatar}
avatarAlt={comment.nickname}
fallbackText={comment.nickname.charAt(0).toUpperCase()}
title={`${comment.nickname} profile picture`}
>
<Avatar className="w-10 h-10 flex-shrink-0 cursor-pointer hover:ring-2 hover:ring-primary/20 transition-all">
<AvatarImage src={comment.avatar} alt={comment.nickname} />
<AvatarFallback>{comment.nickname.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
</AvatarPreviewDialog>
) : (
<Avatar className="w-10 h-10 flex-shrink-0">
<AvatarFallback>👤</AvatarFallback>
</Avatar>
)}
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2 text-sm">
<span className="font-semibold" data-testid={`text-comment-author-${comment.id}`}>
{comment.nickname}
</span>
<span className="text-muted-foreground text-xs" data-testid={`text-comment-time-${comment.id}`}>
{formatTimeAgo(comment.createdAt)}
</span>
</div>
<div className="text-sm leading-relaxed whitespace-pre-wrap" data-testid={`text-comment-content-${comment.id}`}>
{comment.content}
</div>
<div className="flex items-center gap-4 pt-1">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className={`h-8 px-3 rounded-full text-xs font-medium hover:bg-muted transition-colors ${
currentReaction === 'like'
? 'bg-blue-50 text-blue-600 hover:bg-blue-100'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={() => handleReaction('like')}
data-testid={`button-like-${comment.id}`}
>
<ThumbsUp className="h-3 w-3 mr-1" />
{comment.likesCount || 0}
</Button>
<Button
variant="ghost"
size="sm"
className={`h-8 px-3 rounded-full text-xs font-medium hover:bg-muted transition-colors ${
currentReaction === 'dislike'
? 'bg-red-50 text-red-600 hover:bg-red-100'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={() => handleReaction('dislike')}
data-testid={`button-dislike-${comment.id}`}
>
<ThumbsDown className="h-3 w-3 mr-1" />
{comment.dislikesCount || 0}
</Button>
</div>
{level < 2 && (
<CommentForm
articleId={articleId}
parentId={comment.id}
placeholder="Add reply..."
buttonText="Reply"
onSuccess={() => setShowReplyForm(false)}
/>
)}
</div>
{comment.repliesCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowReplies(!showReplies)}
className="text-blue-600 hover:text-blue-700 h-8 px-0 font-medium text-xs rounded-full hover:bg-blue-50 transition-colors"
data-testid={`button-toggle-replies-${comment.id}`}
>
{showReplies ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Hide replies
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
View {comment.repliesCount} replies
</>
)}
</Button>
)}
{showReplies && repliesData?.comments && (
<div className="mt-4 space-y-3">
{repliesData.comments.map((reply: Comment) => (
<CommentItem
key={reply.id}
comment={reply}
articleId={articleId}
level={level + 1}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}
export default function CommentSection({ articleId }: CommentSectionProps) {
const [sortBy, setSortBy] = useState<'newest' | 'popular'>('newest');
const { data, isLoading } = useQuery({
queryKey: ['/api/articles', articleId, 'comments', sortBy],
queryFn: async () => {
const response = await apiRequest('GET', `/api/articles/${articleId}/comments?limit=20&sort=${sortBy}`);
return response.json();
},
});
if (isLoading) {
return (
<div className="bg-background p-6">
<h3 className="text-xl font-bold mb-4">Comments</h3>
<div className="space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="animate-pulse flex gap-3">
<div className="w-10 h-10 bg-muted rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted rounded w-32"></div>
<div className="h-12 bg-muted rounded"></div>
</div>
</div>
))}
</div>
</div>
);
}
const comments = data?.comments || [];
const total = data?.total || 0;
return (
<div className="bg-background pb-6" data-testid="section-comments">
{/* Comments Header */}
<div className="px-6 py-4 border-b">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold">{total} Comments</h3>
<Select value={sortBy} onValueChange={(value) => setSortBy(value as 'newest' | 'popular')}>
<SelectTrigger className="w-[140px] h-8 text-sm border-none bg-transparent hover:bg-muted/50">
<ArrowUpDown className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="popular">Popular</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Comment Form */}
<div className="px-6 pt-6">
<CommentForm articleId={articleId} />
</div>
{/* Comments List */}
<div className="px-6">
{comments.length === 0 ? (
<div className="text-center text-muted-foreground py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
<span className="text-2xl">💬</span>
</div>
<p className="text-lg mb-2">No comments yet</p>
<p className="text-sm">Be the first to leave a comment!</p>
</div>
) : (
<div className="divide-y divide-muted/30">
{comments.map((comment: Comment, index: number) => (
<CommentItem key={comment.id} comment={comment} articleId={articleId} />
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,205 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { X, Send, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { formatTimeAgo } from "@/lib/utils";
import type { Comment } from "@shared/schema";
interface CommentsDrawerProps {
articleId: string;
isOpen: boolean;
onClose: () => void;
}
export default function CommentsDrawer({
articleId,
isOpen,
onClose,
}: CommentsDrawerProps) {
const [nickname, setNickname] = useState(() => {
return localStorage.getItem("commentNickname") || "";
});
const [commentContent, setCommentContent] = useState("");
const [isClosing, setIsClosing] = useState(false);
const [shouldRender, setShouldRender] = useState(isOpen);
useEffect(() => {
if (isOpen) {
setShouldRender(true);
setIsClosing(false);
} else if (shouldRender) {
setIsClosing(true);
const timer = setTimeout(() => {
setShouldRender(false);
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}
}, [isOpen, shouldRender]);
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
onClose();
}, 300);
};
const { data: commentsData, isLoading } = useQuery<{
comments: Comment[];
total: number;
}>({
queryKey: ["/api/comments", articleId],
enabled: isOpen && !!articleId,
});
const createComment = useMutation({
mutationFn: async (data: { content: string; nickname: string }) => {
const response = await apiRequest("POST", "/api/comments", {
articleId,
content: data.content,
nickname: data.nickname,
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/comments", articleId] });
queryClient.invalidateQueries({
queryKey: ["/api/articles", articleId, "comment-count"],
});
setCommentContent("");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!commentContent.trim() || !nickname.trim()) return;
localStorage.setItem("commentNickname", nickname);
createComment.mutate({
content: commentContent,
nickname: nickname,
});
};
const comments = commentsData?.comments || [];
const total = commentsData?.total || 0;
if (!shouldRender) return null;
return (
<div
className={`fixed left-0 right-0 z-30 bg-black/30 transition-opacity duration-200 ${
isClosing ? 'opacity-0' : 'opacity-100'
}`}
style={{ top: 0, bottom: '3.2rem' }}
onClick={handleClose}
>
<div
className={`fixed left-0 right-0 bg-background rounded-t-2xl flex flex-col transition-transform duration-300 ${
isClosing ? 'translate-y-full' : 'translate-y-0'
}`}
style={{ bottom: '3.2rem', maxHeight: '60vh' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold" data-testid="text-comments-title">
Comments ({total})
</h2>
<Button
variant="ghost"
size="icon"
onClick={handleClose}
data-testid="button-close-comments"
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Comments List */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : comments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No comments yet. Be the first to comment!</p>
</div>
) : (
comments.map((comment) => (
<Card key={comment.id} className="p-4" data-testid={`comment-${comment.id}`}>
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-medium text-primary">
{comment.nickname.charAt(0).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 mb-1">
<span className="font-medium text-sm" data-testid="text-comment-nickname">
{comment.nickname}
</span>
<span className="text-xs text-muted-foreground" data-testid="text-comment-time">
{formatTimeAgo(comment.createdAt)}
</span>
</div>
<p className="text-sm leading-relaxed" data-testid="text-comment-content">
{comment.content}
</p>
</div>
</div>
</Card>
))
)}
</div>
{/* Comment Input */}
<form
onSubmit={handleSubmit}
className="p-4 border-t bg-background space-y-3"
>
{!nickname && (
<Input
placeholder="Your nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
maxLength={50}
data-testid="input-nickname"
/>
)}
<div className="flex gap-2">
<Textarea
placeholder="Write a comment..."
value={commentContent}
onChange={(e) => setCommentContent(e.target.value)}
className="resize-none min-h-[60px]"
maxLength={500}
data-testid="input-comment"
/>
<Button
type="submit"
size="icon"
disabled={
!commentContent.trim() ||
!nickname.trim() ||
createComment.isPending
}
data-testid="button-submit-comment"
>
{createComment.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,232 @@
import { MoreVertical, Play, Pause, ImageIcon, Loader2 } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { formatTimeAgo } from "@/lib/utils";
import { useTTS } from "@/hooks/useTTS";
import AvatarPreviewDialog from "@/components/AvatarPreviewDialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
interface FeedItemProps {
id: string;
title: string;
summary: string;
thumbnail: string;
publishedAt: Date;
timeAgo?: string; // Server-provided time ago text
outletName: string;
outletAvatar?: string;
viewCount: number;
category: string;
onClick: (id: string) => void;
onOutletClick: (outletId: string) => void;
onPlayClick: (id: string) => void;
outletId: string;
className?: string;
}
function formatViewCount(count: number): string {
if (count < 1000) return count.toString();
if (count < 1000000) return `${Math.floor(count / 100) / 10}K`;
return `${Math.floor(count / 100000) / 10}M`;
}
export default function FeedItem({
id,
title,
summary,
thumbnail,
publishedAt,
timeAgo,
outletName,
outletAvatar,
viewCount,
category,
onClick,
onOutletClick,
onPlayClick,
outletId,
className = "",
}: FeedItemProps) {
const { isPlaying, isPaused, toggle } = useTTS();
const [imageError, setImageError] = useState(false);
const queryClient = useQueryClient();
const { toast } = useToast();
const getInitials = (name: string) => {
return name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
const needsThumbnail = () => {
return !thumbnail ||
thumbnail === '/api/assets/default-article.png' ||
thumbnail.trim() === '' ||
imageError;
};
// Mutation to generate thumbnail for this article
const generateThumbnailMutation = useMutation({
mutationFn: async () => {
const response = await apiRequest('POST', `/api/articles/${id}/generate-thumbnail`, {});
return response.json();
},
onSuccess: (data) => {
// Refetch the feed to update the thumbnail
queryClient.invalidateQueries({ queryKey: ['/api/feed'] });
queryClient.invalidateQueries({ queryKey: ['/api/articles'] });
queryClient.invalidateQueries({ queryKey: ['/api/articles', id] });
setImageError(false);
toast({
title: "Thumbnail Generated",
description: "AI thumbnail generated successfully for this article.",
});
},
onError: (error) => {
console.error('Error generating thumbnail:', error);
toast({
title: "Generation Failed",
description: error instanceof Error ? error.message : "Failed to generate thumbnail. Please try again.",
variant: "destructive",
});
},
});
const handlePlayAudio = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent thumbnail click
onPlayClick(id);
};
const handleGenerateThumbnail = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent thumbnail click
generateThumbnailMutation.mutate();
};
return (
<div className={`bg-background pb-4 ${className}`}>
{/* Thumbnail */}
<div
className="aspect-video relative overflow-hidden rounded-lg mb-3 cursor-pointer group"
onClick={() => onClick(id)}
data-testid={`thumbnail-${id}`}
>
<img
src={thumbnail}
alt={title}
className="w-full h-full object-cover"
onError={() => setImageError(true)}
/>
{/* Generate Thumbnail Button for missing thumbnails */}
{needsThumbnail() && (
<div className="absolute top-2 right-2">
<Button
variant="secondary"
size="icon"
className="h-8 w-8 backdrop-blur-sm opacity-80 hover:opacity-100"
onClick={handleGenerateThumbnail}
disabled={generateThumbnailMutation.isPending}
data-testid={`button-generate-thumbnail-${id}`}
>
{generateThumbnailMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImageIcon className="h-4 w-4" />
)}
</Button>
</div>
)}
{/* Play/Pause Button */}
<div className="absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="secondary"
size="icon"
onClick={handlePlayAudio}
className="h-12 w-12 rounded-full backdrop-blur-sm"
data-testid={`button-play-${id}`}
>
{isPlaying && !isPaused ? (
<Pause className="h-6 w-6" />
) : (
<Play className="h-6 w-6" />
)}
</Button>
</div>
</div>
{/* Content below thumbnail */}
<div className="flex gap-3">
{/* Outlet Avatar */}
<div className="flex-shrink-0">
<AvatarPreviewDialog
avatarSrc={outletAvatar}
avatarAlt={outletName}
fallbackText={getInitials(outletName)}
title={outletName}
description={`${outletName} 프로필`}
>
<Avatar
className="h-9 w-9 hover:ring-2 hover:ring-primary/20 transition-all cursor-pointer"
data-testid={`avatar-${id}`}
onClick={(e) => e.stopPropagation()}
>
<AvatarImage src={outletAvatar} alt={outletName} />
<AvatarFallback className="text-xs font-medium">
{getInitials(outletName)}
</AvatarFallback>
</Avatar>
</AvatarPreviewDialog>
</div>
{/* Title and metadata */}
<div className="flex-1 min-w-0">
<h3
className="font-medium text-sm leading-tight line-clamp-2 mb-1 cursor-pointer hover:text-primary/80 transition-colors"
onClick={() => onClick(id)}
data-testid={`title-${id}`}
>
{title}
</h3>
<div className="flex items-center text-xs text-muted-foreground space-x-1">
<span
className="cursor-pointer hover:text-foreground transition-colors"
onClick={() => onOutletClick(outletId)}
data-testid={`outlet-${id}`}
>
{outletName}
</span>
<span></span>
<span data-testid={`views-${id}`}>
{formatViewCount(viewCount)} views
</span>
<span></span>
<span data-testid={`time-${id}`}>
{timeAgo || formatTimeAgo(publishedAt)}
</span>
</div>
</div>
{/* More options button */}
<div className="flex-shrink-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
data-testid={`more-${id}`}
>
<MoreVertical className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,147 @@
import { useState, useEffect, useRef } from "react";
import { useLocation } from "wouter";
import FeedItem from "./FeedItem";
import ArticlePopup from "./ArticlePopup";
import { useFeed, useIncrementView } from "@/hooks/useApi";
interface FeedListProps {
filter: 'all' | 'people' | 'topics' | 'companies';
className?: string;
}
export default function FeedList({
filter,
className = ""
}: FeedListProps) {
const [, setLocation] = useLocation();
const {
data,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useFeed(filter);
const incrementViewMutation = useIncrementView(filter);
// Intersection observer for infinite scroll
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!loadMoreRef.current || !hasNextPage || isFetchingNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
fetchNextPage();
}
},
{ threshold: 0.1 }
);
observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleArticleClick = (articleId: string) => {
// Increment view count
incrementViewMutation.mutate(articleId);
// Navigate to article page
setLocation(`/article/${articleId}`);
};
const handleOutletClick = (outletId: string) => {
setLocation(`/outlet/${outletId}`);
};
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(null);
const handlePlayClick = (articleId: string) => {
setSelectedArticleId(articleId);
};
const handleClosePopup = () => {
setSelectedArticleId(null);
};
if (isLoading) {
return (
<div className={`space-y-4 p-4 ${className}`}>
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-video bg-muted rounded-lg mb-3" />
<div className="flex gap-3">
<div className="w-9 h-9 bg-muted rounded-full flex-shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted rounded w-3/4" />
<div className="h-3 bg-muted rounded w-1/2" />
</div>
</div>
</div>
))}
</div>
);
}
if (!data?.pages?.length) {
return (
<div className={`flex items-center justify-center py-12 text-center ${className}`}>
<div className="space-y-2">
<p className="text-muted-foreground">No articles available</p>
<p className="text-sm text-muted-foreground">Check back later for new content</p>
</div>
</div>
);
}
return (
<div className={`space-y-6 ${className}`}>
{data.pages.map((page: any, pageIndex: number) => (
<div key={pageIndex} className="space-y-6">
{page?.items?.map((article: any) => (
<FeedItem
key={article.id}
id={article.id}
title={article.title}
summary={article.summary}
thumbnail={article.thumbnail}
publishedAt={new Date(article.publishedAt)}
outletName={article.outletName || "Unknown"}
outletAvatar={article.outletAvatar}
viewCount={article.viewCount || 0}
category={article.category || "general"}
outletId={article.outletId}
onClick={handleArticleClick}
onOutletClick={handleOutletClick}
onPlayClick={handlePlayClick}
/>
))}
</div>
))}
{/* Infinite scroll trigger */}
{hasNextPage && (
<div
ref={loadMoreRef}
className="flex justify-center py-8"
>
{isFetchingNextPage ? (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
<span className="text-sm">Loading more articles...</span>
</div>
) : (
<div className="w-1 h-1" /> // Small trigger element
)}
</div>
)}
{/* Article Popup */}
<ArticlePopup
articleId={selectedArticleId || ''}
isOpen={!!selectedArticleId}
onClose={handleClosePopup}
/>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { Button } from "@/components/ui/button";
interface FilterChipsProps {
activeFilter: 'all' | 'people' | 'topics' | 'companies';
onFilterChange: (filter: 'all' | 'people' | 'topics' | 'companies') => void;
className?: string;
}
const filterOptions = [
{ value: 'all' as const, label: 'All' },
{ value: 'people' as const, label: 'People' },
{ value: 'topics' as const, label: 'Topics' },
{ value: 'companies' as const, label: 'Companies' },
];
export default function FilterChips({
activeFilter,
onFilterChange,
className = "",
}: FilterChipsProps) {
return (
<div className={`flex gap-2 overflow-x-auto scrollbar-hide px-4 py-2 ${className}`}>
{filterOptions.map((filter) => (
<Button
key={filter.value}
variant={activeFilter === filter.value ? "default" : "secondary"}
size="sm"
onClick={() => onFilterChange(filter.value)}
className="whitespace-nowrap flex-shrink-0 h-8"
data-testid={`filter-${filter.value}`}
>
{filter.label}
</Button>
))}
</div>
);
}

View File

@ -0,0 +1,65 @@
import { useState, useEffect } from "react";
import { ChevronLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
interface FloatingBackButtonProps {
onClick: () => void;
show?: boolean;
}
export default function FloatingBackButton({ onClick, show = true }: FloatingBackButtonProps) {
const [isVisible, setIsVisible] = useState(false);
const [isIOS, setIsIOS] = useState(false);
useEffect(() => {
// iOS 감지 (모든 브라우저에서)
const detectIOS = () => {
const userAgent = window.navigator.userAgent;
return /iPad|iPhone|iPod/.test(userAgent);
};
setIsIOS(detectIOS());
const handleScroll = () => {
// 스크롤을 조금이라도 내리면 버튼 표시
const scrolled = window.scrollY > 60; // 헤더 높이보다 조금 더 스크롤했을 때
setIsVisible(scrolled);
};
if (show) {
window.addEventListener('scroll', handleScroll);
handleScroll(); // 초기 상태 설정
}
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [show]);
// iOS가 아니거나 show가 false이면 렌더링하지 않음
if (!isIOS || !show) {
return null;
}
return (
<Button
variant="secondary"
size="icon"
onClick={onClick}
data-testid="floating-back-button"
className={`
fixed top-20 left-4 z-50 h-10 w-10
rounded-full shadow-lg backdrop-blur-sm
transition-all duration-300 ease-in-out
${isVisible
? 'opacity-100 translate-x-0 pointer-events-auto'
: 'opacity-0 -translate-x-8 pointer-events-none'
}
bg-background/90 hover:bg-background/95
border border-border/50
`}
>
<ChevronLeft className="h-5 w-5" />
</Button>
);
}

View File

@ -0,0 +1,69 @@
import { X } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTrigger,
DialogTitle,
DialogHeader,
DialogDescription
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
interface ImagePreviewDialogProps {
children: React.ReactNode;
imageSrc?: string;
imageAlt: string;
title?: string;
}
export default function ImagePreviewDialog({
children,
imageSrc,
imageAlt,
title
}: ImagePreviewDialogProps) {
if (!imageSrc) return <>{children}</>;
return (
<Dialog>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="bg-background rounded-lg shadow-lg max-w-md w-full m-4 max-h-[80vh] overflow-y-auto">
<DialogHeader className="sr-only">
<DialogTitle>
<VisuallyHidden>{title || imageAlt}</VisuallyHidden>
</DialogTitle>
<DialogDescription>
<VisuallyHidden>Profile image preview</VisuallyHidden>
</DialogDescription>
</DialogHeader>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="h-12 w-12 bg-primary/10 rounded-full flex items-center justify-center">
<span className="text-sm font-medium">{title?.slice(0, 2).toUpperCase()}</span>
</div>
<div>
<h2 className="text-lg font-bold" data-testid="text-modal-name">
{title}
</h2>
</div>
</div>
</div>
{/* Image container */}
<div className="flex justify-center">
<img
src={imageSrc}
alt={imageAlt}
className="max-w-full max-h-[60vh] object-contain rounded-lg shadow-lg"
/>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,75 @@
import { Search, UserCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import SettingsDropdown from "@/components/SettingsDropdown";
// Sapiens logo from attached assets
const sapiensLogo = "/api/assets/sapiens-logo.png";
interface MobileHeaderProps {
onSearchClick: () => void;
title?: string;
showBackButton?: boolean;
onBackClick?: () => void;
}
export default function MobileHeader({
onSearchClick,
title = "NewsHub",
showBackButton = false,
onBackClick,
}: MobileHeaderProps) {
return (
<header className="sticky top-0 z-50 bg-background border-b border-border">
<div className="flex items-center justify-between h-10 px-3">
<div className="flex items-center gap-2">
{showBackButton && (
<Button
variant="ghost"
size="icon"
onClick={onBackClick}
data-testid="button-back"
className="h-6 w-6"
>
</Button>
)}
{title === "Sapiens" ? (
<div className="h-4 flex items-center">
<img
src={sapiensLogo}
alt="SAPIENS"
className="h-full w-auto object-contain dark:invert"
/>
</div>
) : (
<h1 className="text-base font-semibold truncate">{title}</h1>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={onSearchClick}
data-testid="button-search"
aria-label="Search"
>
<Search className="h-4 w-4" />
</Button>
<SettingsDropdown />
<Button
variant="ghost"
size="icon"
onClick={() => {}}
data-testid="button-login"
aria-label="Log in"
>
<UserCircle className="h-4 w-4" />
</Button>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,121 @@
import { useState } from "react";
import { X } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
interface OutletCardProps {
id: string;
name: string;
description: string;
category: string;
focusSubject: string;
avatar?: string;
articleCount: number;
onClick: (id: string) => void;
className?: string;
}
export default function OutletCard({
id,
name,
description,
category,
focusSubject,
avatar,
articleCount,
onClick,
className = "",
}: OutletCardProps) {
const [isAvatarZoomed, setIsAvatarZoomed] = useState(false);
const getInitials = (name: string) => {
return name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<>
<Card
className={`p-3 hover-elevate cursor-pointer w-full max-w-full ${className}`}
onClick={() => onClick(id)}
data-testid={`card-outlet-${id}`}
>
<div className="flex items-center gap-3 text-left min-w-0">
<Avatar
className="h-12 w-12 shrink-0 cursor-pointer hover-elevate active-elevate-2"
onClick={(e) => {
e.stopPropagation();
setIsAvatarZoomed(true);
}}
data-testid={`avatar-clickable-${id}`}
>
<AvatarImage
src={avatar}
alt={name}
className={category === 'people' ? 'object-[center_30%]' : ''}
/>
<AvatarFallback className="text-sm font-medium">
{getInitials(name)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h3
className="font-semibold text-base leading-tight truncate mb-1"
data-testid={`text-name-${id}`}
title={name}
>
{name}
</h3>
<p
className="text-sm text-muted-foreground leading-tight truncate"
data-testid={`text-description-${id}`}
title={description}
>
{description}
</p>
</div>
</div>
</Card>
{/* Avatar Zoom Dialog */}
<Dialog open={isAvatarZoomed} onOpenChange={setIsAvatarZoomed}>
<DialogContent className="max-w-3xl p-0 bg-black/95 backdrop-blur-xl border-none shadow-2xl overflow-hidden">
<div className="relative group">
<div className="relative overflow-hidden">
<img
src={avatar}
alt={name}
className="w-full h-auto max-h-[80vh] object-contain animate-in zoom-in-95 fade-in duration-300"
data-testid={`avatar-zoomed-${id}`}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 bg-white/10 hover:bg-white/20 backdrop-blur-sm text-white border border-white/20 rounded-full h-10 w-10 transition-all duration-200 hover:scale-110"
onClick={() => setIsAvatarZoomed(false)}
data-testid={`button-close-avatar-zoom-${id}`}
>
<X className="h-5 w-5" />
</Button>
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<h3 className="text-white font-semibold text-lg">{name}</h3>
<p className="text-white/80 text-sm">{description}</p>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,441 @@
import { useState } from "react";
import { User, X, Search, Info, MoreHorizontal, FileText, List, UserCircle } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import SettingsDropdown from "@/components/SettingsDropdown";
import DOMPurify from "dompurify";
// Sapiens logo from attached assets
const sapiensLogo = "/api/assets/sapiens-logo.png";
interface OutletProfileProps {
name: string;
description: string;
category: string;
focusSubject: string;
avatar?: string;
profileImage?: string;
bio: string;
fullBio?: string[];
wikiProfile?: string;
articleCount?: number;
isSticky?: boolean;
// New props for sticky header functionality
onSearchClick?: () => void;
selectedLanguage?: string;
onLanguageChange?: (language: string) => void;
onLoginClick?: () => void;
articleViewMode?: 'card' | 'list';
onArticleViewModeChange?: (mode: 'card' | 'list') => void;
}
export default function OutletProfile({
name,
description,
category,
avatar,
profileImage,
fullBio,
wikiProfile,
bio,
isSticky = false,
onSearchClick,
selectedLanguage = "en",
onLanguageChange,
onLoginClick,
articleViewMode,
onArticleViewModeChange,
}: OutletProfileProps) {
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const [isAvatarZoomed, setIsAvatarZoomed] = useState(false);
const [viewMode, setViewMode] = useState<'simple' | 'detailed'>('simple'); // simple = 3 bullets, detailed = wiki profile
const getInitials = (name: string) => {
return name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
const sanitizeHtml = (html: string) => {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'ul', 'ol', 'li', 'strong', 'em', 'a'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
ADD_ATTR: ['target', 'rel'],
FORBID_ATTR: ['style', 'onclick', 'onload'],
ALLOW_DATA_ATTR: false
});
};
if (isSticky) {
// Fixed sticky header version with profile button
return (
<>
<div className="bg-background border-b border-border py-3 px-4">
<div className="flex items-center justify-between gap-3 min-w-0">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Avatar
className="h-10 w-10 shrink-0 cursor-pointer hover-elevate active-elevate-2"
onClick={(e) => {
e.stopPropagation();
setIsAvatarZoomed(true);
}}
data-testid="avatar-clickable"
>
<AvatarImage src={avatar} alt={name} />
<AvatarFallback className="text-sm font-medium">
{getInitials(name)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="h-2.5 flex items-center">
<img
src={sapiensLogo}
alt="SAPIENS"
className="h-full w-auto object-contain"
style={{transform: 'translateX(-2px)'}}
/>
</div>
<div className="flex items-center gap-1">
<h2 className="font-semibold text-base truncate leading-tight" data-testid="text-outlet-name">
{name}
</h2>
{/* Info icon next to name */}
{fullBio && fullBio.length > 0 && (
<Button
variant="ghost"
size="icon"
onClick={() => setIsProfileModalOpen(true)}
data-testid="button-info"
className="h-6 w-6 shrink-0"
>
<Info className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
{/* Right side controls: Search, Settings, Login */}
<div className="flex items-center gap-2 shrink-0">
{/* Search button */}
{onSearchClick && (
<Button
variant="ghost"
size="icon"
onClick={onSearchClick}
data-testid="button-search"
aria-label="Search"
>
<Search className="h-4 w-4" />
</Button>
)}
{/* Settings dropdown */}
<SettingsDropdown
viewMode={articleViewMode}
onViewModeChange={onArticleViewModeChange}
/>
{/* Login button */}
<Button
variant="ghost"
size="icon"
onClick={onLoginClick || (() => {})}
data-testid="button-login"
aria-label="Log in"
>
<UserCircle className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* 프로필 모달 - sticky 버전에서도 포함 */}
{isProfileModalOpen && (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/50"
onClick={() => setIsProfileModalOpen(false)}
data-testid="modal-overlay"
>
<div
className={`bg-background rounded-lg shadow-lg w-full m-4 max-h-[80vh] overflow-y-auto ${
viewMode === 'detailed' ? 'max-w-4xl' : 'max-w-md'
}`}
onClick={(e) => e.stopPropagation()}
data-testid="modal-content"
>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Avatar
className="h-12 w-12 cursor-pointer hover-elevate active-elevate-2"
onClick={(e) => {
e.stopPropagation();
setIsAvatarZoomed(true);
}}
data-testid="avatar-clickable-modal"
>
<AvatarImage src={avatar} alt={name} />
<AvatarFallback className="text-sm font-medium">
{getInitials(name)}
</AvatarFallback>
</Avatar>
<div>
<h2 className="text-lg font-bold" data-testid="text-modal-name">
{name}
</h2>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsProfileModalOpen(false)}
data-testid="button-modal-close"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Toggle Buttons */}
<div className="grid grid-cols-2 gap-1 p-1 bg-muted rounded-lg mb-4">
<button
onClick={() => setViewMode('simple')}
className={`flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${
viewMode === 'simple'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
data-testid="button-simple-view"
>
<List className="h-4 w-4" />
Simple
</button>
<button
onClick={() => setViewMode('detailed')}
className={`flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${
viewMode === 'detailed'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
data-testid="button-detailed-view"
>
<FileText className="h-4 w-4" />
Detailed
</button>
</div>
{/* Content based on view mode */}
{viewMode === 'detailed' && wikiProfile ? (
<div
className="prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(wikiProfile) }}
data-testid="wiki-profile-content"
/>
) : fullBio && fullBio.length > 0 && (
<div className="space-y-4">
<div className="space-y-3">
{fullBio.slice(0, 3).map((point, index) => (
<div
key={index}
className="flex items-start gap-3"
data-testid={`bio-point-${index}`}
>
<div className="h-2 w-2 rounded-full bg-primary mt-2 shrink-0" />
<p className="text-sm leading-relaxed text-foreground">
{point}
</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
</>
);
}
// Slim profile version - 가로로 최대한 얇게
return (
<>
<div className="bg-background border-b border-border py-3 px-4">
<div className="flex items-center justify-between gap-3 min-w-0">
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* 프로필 사진 하나만 동그라미에 */}
<Avatar className="h-12 w-12 shrink-0">
<AvatarImage src={avatar} alt={name} />
<AvatarFallback className="text-sm font-medium">
{getInitials(name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="h-3 flex items-center">
<img
src={sapiensLogo}
alt="SAPIENS"
className="h-full w-auto object-contain"
style={{transform: 'translateX(-2px)'}}
/>
</div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold truncate leading-tight" data-testid="text-outlet-name">
{name}
</h1>
{/* Info icon moved next to name for non-sticky version too */}
{fullBio && fullBio.length > 0 && (
<Button
variant="ghost"
size="icon"
onClick={() => setIsProfileModalOpen(true)}
data-testid="button-info-non-sticky"
className="h-6 w-6 shrink-0"
>
<Info className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</div>
</div>
{/* 5bullet point 프로필 팝업 */}
{isProfileModalOpen && (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/50"
onClick={() => setIsProfileModalOpen(false)}
data-testid="modal-overlay"
>
<div
className={`bg-background rounded-lg shadow-lg w-full m-4 max-h-[80vh] overflow-y-auto ${
viewMode === 'detailed' ? 'max-w-4xl' : 'max-w-md'
}`}
onClick={(e) => e.stopPropagation()}
data-testid="modal-content"
>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarImage src={avatar} alt={name} />
<AvatarFallback className="text-sm font-medium">
{getInitials(name)}
</AvatarFallback>
</Avatar>
<div>
<h2 className="text-lg font-bold" data-testid="text-modal-name">
{name}
</h2>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsProfileModalOpen(false)}
data-testid="button-modal-close"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Toggle Buttons */}
<div className="grid grid-cols-2 gap-1 p-1 bg-muted rounded-lg mb-4">
<button
onClick={() => setViewMode('simple')}
className={`flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${
viewMode === 'simple'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
data-testid="button-simple-view-2"
>
<List className="h-4 w-4" />
Simple
</button>
<button
onClick={() => setViewMode('detailed')}
className={`flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${
viewMode === 'detailed'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
data-testid="button-detailed-view-2"
>
<FileText className="h-4 w-4" />
Detailed
</button>
</div>
{/* Content based on view mode */}
{viewMode === 'detailed' && wikiProfile ? (
<div
className="prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(wikiProfile) }}
data-testid="wiki-profile-content-2"
/>
) : fullBio && fullBio.length > 0 && (
<div className="space-y-4">
<div className="space-y-3">
{fullBio.slice(0, 3).map((point, index) => (
<div
key={index}
className="flex items-start gap-3"
data-testid={`bio-point-${index}`}
>
<div className="h-2 w-2 rounded-full bg-primary mt-2 shrink-0" />
<p className="text-sm leading-relaxed text-foreground">
{point}
</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Avatar Zoom Dialog */}
<Dialog open={isAvatarZoomed} onOpenChange={setIsAvatarZoomed}>
<DialogContent className="max-w-3xl p-0 bg-black/95 backdrop-blur-xl border-none shadow-2xl overflow-hidden">
<div className="relative group">
<div className="relative overflow-hidden">
<img
src={avatar || profileImage}
alt={name}
className="w-full h-auto max-h-[80vh] object-contain animate-in zoom-in-95 fade-in duration-300"
data-testid="avatar-zoomed"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 bg-white/10 hover:bg-white/20 backdrop-blur-sm text-white border border-white/20 rounded-full h-10 w-10 transition-all duration-200 hover:scale-110"
onClick={() => setIsAvatarZoomed(false)}
data-testid="button-close-avatar-zoom"
>
<X className="h-5 w-5" />
</Button>
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<h3 className="text-white font-semibold text-lg">{name}</h3>
<p className="text-white/80 text-sm">{description}</p>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,147 @@
import { DollarSign, Calendar } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { formatDistanceToNow } from "date-fns";
import type { PredictionMarket } from "@shared/schema";
interface PredictionMarketCardProps {
market: PredictionMarket;
onClick?: () => void;
}
interface MarketOption {
name: string;
price: number;
image?: string;
}
export default function PredictionMarketCard({ market, onClick }: PredictionMarketCardProps) {
const endDate = new Date(market.endDate);
const timeLeft = formatDistanceToNow(endDate, { addSuffix: true });
const isBinary = market.marketType === "binary";
let options: MarketOption[] = [];
if (!isBinary && market.options) {
try {
options = JSON.parse(market.options);
} catch (e) {
console.error("Failed to parse market options:", e);
}
}
return (
<Card
className="p-4 hover-elevate cursor-pointer"
onClick={onClick}
data-testid={`card-prediction-${market.id}`}
>
<div className="space-y-3">
{/* Header with question and live badge */}
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-sm flex-1">{market.question}</h3>
{market.isLive === 1 && (
<Badge variant="destructive" className="text-xs">
LIVE
</Badge>
)}
</div>
{/* Binary Market (Yes/No) */}
{isBinary && market.yesPrice !== null && market.noPrice !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-1">
<span className="text-2xl font-bold">{market.yesPrice}%</span>
<span className="text-xs text-muted-foreground">YES</span>
</div>
<div className="flex items-center gap-2 flex-1">
<span className="text-2xl font-bold">{market.noPrice}%</span>
<span className="text-xs text-muted-foreground">NO</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={(e) => { e.stopPropagation(); }}
data-testid="button-bet-yes"
>
Yes
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={(e) => { e.stopPropagation(); }}
data-testid="button-bet-no"
>
No
</Button>
</div>
</div>
)}
{/* Multiple Choice Market */}
{!isBinary && options.length > 0 && (
<div className="space-y-2">
{options.map((option, index) => (
<div key={index} className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-1">
{option.image && (
<img
src={option.image}
alt={option.name}
className="w-6 h-6 rounded-full object-cover"
/>
)}
<span className="text-sm font-medium">{option.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-lg font-bold">{option.price}%</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => { e.stopPropagation(); }}
data-testid={`button-yes-${index}`}
>
Yes
</Button>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => { e.stopPropagation(); }}
data-testid={`button-no-${index}`}
>
No
</Button>
</div>
</div>
</div>
))}
</div>
)}
{/* Footer with volume, time, and category */}
<div className="flex items-center gap-3 text-xs text-muted-foreground border-t pt-2">
<div className="flex items-center gap-1">
<DollarSign className="h-3 w-3" />
<span>${(market.totalVolume / 1000).toFixed(0)}k Vol.</span>
</div>
{market.category && (
<span>{market.category}</span>
)}
<div className="flex items-center gap-1 ml-auto">
<Calendar className="h-3 w-3" />
<span>{timeLeft}</span>
</div>
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,231 @@
import { useState, useEffect, useRef } from "react";
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useLocation } from "wouter";
interface SearchResult {
id: string;
title: string;
summary: string;
category?: string;
outletName?: string;
publishedAt?: Date;
name?: string;
description?: string;
focusSubject?: string;
}
interface SearchResults {
articles: SearchResult[];
outlets: SearchResult[];
}
interface SearchOverlayProps {
isOpen: boolean;
onClose: () => void;
onArticleClick: (id: string) => void;
searchResults: SearchResults | SearchResult[] | null;
onSearch: (query: string) => void;
isLoading?: boolean;
}
export default function SearchOverlay({
isOpen,
onClose,
onArticleClick,
searchResults,
onSearch,
isLoading = false,
}: SearchOverlayProps) {
const [query, setQuery] = useState("");
const [isVisible, setIsVisible] = useState(false);
const [shouldAnimate, setShouldAnimate] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [, setLocation] = useLocation();
useEffect(() => {
if (isOpen) {
setIsVisible(true);
setShouldAnimate(true);
// Focus input after animation starts
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 100);
} else {
setShouldAnimate(false);
// Hide after transition completes
const timer = setTimeout(() => setIsVisible(false), 500);
return () => clearTimeout(timer);
}
}, [isOpen]);
useEffect(() => {
const debounceTimer = setTimeout(() => {
if (query.trim()) {
onSearch(query);
}
}, 300);
return () => clearTimeout(debounceTimer);
}, [query, onSearch]);
if (!isVisible) return null;
const handleArticleClick = (article: any) => {
const articleId = article.newsId || article.id;
setLocation(`/article/${articleId}`);
onClose();
};
const handleOutletClick = (id: string) => {
setLocation(`/outlet/${id}`);
onClose();
};
return (
<div className={`absolute top-0 left-0 right-0 bottom-14 z-50 bg-background transform transition-transform duration-500 ease-out flex flex-col ${shouldAnimate ? 'translate-x-0' : 'translate-x-full'}`}>
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-border shrink-0">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search articles..."
className="pl-10 pr-4"
data-testid="input-search"
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
data-testid="button-close-search"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Results - Now with proper flex and height constraints */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 min-h-0">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">Searching...</div>
</div>
) : query.trim() === "" ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">Start typing to search articles and outlets</div>
</div>
) : (() => {
// Handle different search result formats
let articles: SearchResult[] = [];
let outlets: SearchResult[] = [];
if (Array.isArray(searchResults)) {
// Legacy format - assume all are articles
articles = searchResults;
} else if (searchResults && typeof searchResults === 'object') {
// New format with articles and outlets
articles = searchResults.articles || [];
outlets = searchResults.outlets || [];
}
const totalResults = articles.length + outlets.length;
return totalResults === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">No results found</div>
</div>
) : (
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
{totalResults} result{totalResults !== 1 ? 's' : ''} found
</div>
{/* Outlets Section - Now shows first */}
{outlets.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-foreground">Outlets ({outlets.length})</h4>
{outlets.map((result) => (
<Card
key={`outlet-${result.id}`}
className="p-4 hover-elevate cursor-pointer"
onClick={() => handleOutletClick(result.id)}
data-testid={`search-result-outlet-${result.id}`}
>
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm line-clamp-2">
{result.name || result.title}
</h3>
{result.category && (
<Badge variant="secondary" className="text-xs capitalize">
{result.category}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{result.description || result.summary}
</p>
</div>
</Card>
))}
</div>
)}
{/* Articles Section - Now shows second */}
{articles.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-foreground">Articles ({articles.length})</h4>
{articles.map((result: any) => (
<Card
key={`article-${result.id}`}
className="p-3 hover-elevate cursor-pointer"
onClick={() => handleArticleClick(result)}
data-testid={`search-result-article-${result.id}`}
>
<div className="flex gap-3">
{/* Thumbnail on the left */}
<div className="w-16 h-12 shrink-0">
<img
src={result.thumbnail || '/api/assets/default-article.png'}
alt={result.title}
className="w-full h-full object-cover rounded"
/>
</div>
{/* Content on the right */}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-sm line-clamp-2">
{result.title}
</h3>
{result.outletName && (
<Badge variant="outline" className="text-xs">
{result.outletName}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{result.summary}
</p>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
})()}
</div>
</div>
);
}

View File

@ -0,0 +1,115 @@
import { Check, Settings, Globe, Sun, Moon, Monitor, Grid3X3, List } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LANGUAGES } from "@shared/schema";
import { useTheme } from "@/hooks/useTheme";
import { useLanguage } from "@/hooks/useLanguage";
interface SettingsDropdownProps {
viewMode?: 'card' | 'list';
onViewModeChange?: (mode: 'card' | 'list') => void;
}
export default function SettingsDropdown({ viewMode, onViewModeChange }: SettingsDropdownProps = {}) {
const { themeMode, setTheme } = useTheme();
const { selectedLanguage, onLanguageChange } = useLanguage();
const currentLanguage = LANGUAGES.find(lang => lang.code === selectedLanguage) || LANGUAGES[0];
const themes = [
{ mode: 'light' as const, label: 'Light', icon: Sun },
{ mode: 'dark' as const, label: 'Dark', icon: Moon },
{ mode: 'system' as const, label: 'System', icon: Monitor },
];
const viewModes = [
{ mode: 'card' as const, label: 'Card View', icon: Grid3X3 },
{ mode: 'list' as const, label: 'List View', icon: List },
];
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
data-testid="button-settings"
className="h-8 w-8"
>
<Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Settings</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* View Mode Selection - only show if props are provided */}
{viewMode !== undefined && onViewModeChange && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">View Mode</DropdownMenuLabel>
{viewModes.map((mode) => {
const IconComponent = mode.icon;
return (
<DropdownMenuItem
key={mode.mode}
onClick={() => onViewModeChange(mode.mode)}
data-testid={`menuitem-view-${mode.mode}`}
>
<IconComponent className="h-4 w-4 mr-2" />
<span className="flex-1">{mode.label}</span>
{viewMode === mode.mode && (
<Check className="h-4 w-4" />
)}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
</>
)}
{/* Theme Selection */}
<DropdownMenuLabel className="text-xs text-muted-foreground">Theme</DropdownMenuLabel>
{themes.map((theme) => {
const IconComponent = theme.icon;
return (
<DropdownMenuItem
key={theme.mode}
onClick={() => setTheme(theme.mode)}
data-testid={`menuitem-theme-${theme.mode}`}
>
<IconComponent className="h-4 w-4 mr-2" />
<span className="flex-1">{theme.label}</span>
{themeMode === theme.mode && (
<Check className="h-4 w-4" />
)}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
{/* Language Selection */}
<DropdownMenuLabel className="text-xs text-muted-foreground">Language</DropdownMenuLabel>
{LANGUAGES.map((language) => (
<DropdownMenuItem
key={language.code}
onClick={() => onLanguageChange(language.code)}
data-testid={`menuitem-lang-${language.code}`}
>
<Globe className="h-4 w-4 mr-2" />
<span className="flex-1">{language.name}</span>
{selectedLanguage === language.code && (
<Check className="h-4 w-4" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,117 @@
import { useState, useRef, useEffect } from "react";
import { Card } from "@/components/ui/card";
interface CarouselItem {
id: string;
content: React.ReactNode;
}
interface SwipeableCarouselProps {
items: CarouselItem[];
autoScroll?: boolean;
autoScrollDelay?: number;
className?: string;
}
export default function SwipeableCarousel({
items,
autoScroll = true,
autoScrollDelay = 5000,
className = "",
}: SwipeableCarouselProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const carouselRef = useRef<HTMLDivElement>(null);
const startXRef = useRef(0);
const scrollLeftRef = useRef(0);
// Auto scroll functionality
useEffect(() => {
if (!autoScroll || isDragging) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % items.length);
}, autoScrollDelay);
return () => clearInterval(interval);
}, [autoScroll, autoScrollDelay, isDragging, items.length]);
// Handle manual scroll
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true);
const carousel = carouselRef.current;
if (!carousel) return;
startXRef.current = e.pageX - carousel.offsetLeft;
scrollLeftRef.current = carousel.scrollLeft;
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging) return;
e.preventDefault();
const carousel = carouselRef.current;
if (!carousel) return;
const x = e.pageX - carousel.offsetLeft;
const walk = (x - startXRef.current) * 2;
carousel.scrollLeft = scrollLeftRef.current - walk;
};
const handleMouseUp = () => {
setIsDragging(false);
};
// Touch events for mobile
const handleTouchStart = (e: React.TouchEvent) => {
setIsDragging(true);
const carousel = carouselRef.current;
if (!carousel) return;
startXRef.current = e.touches[0].pageX - carousel.offsetLeft;
scrollLeftRef.current = carousel.scrollLeft;
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!isDragging) return;
const carousel = carouselRef.current;
if (!carousel) return;
const x = e.touches[0].pageX - carousel.offsetLeft;
const walk = (x - startXRef.current) * 2;
carousel.scrollLeft = scrollLeftRef.current - walk;
};
const handleTouchEnd = () => {
setIsDragging(false);
};
return (
<div className={`relative ${className}`}>
<div
ref={carouselRef}
className="flex gap-4 overflow-x-auto scrollbar-hide snap-x snap-mandatory px-4"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
data-testid="carousel-container"
style={{ scrollBehavior: isDragging ? 'auto' : 'smooth' }}
>
{items.map((item, index) => (
<div
key={item.id}
className="flex-shrink-0 w-80 snap-start"
data-testid={`carousel-item-${index}`}
>
{item.content}
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
interface TextSizeIconProps {
className?: string;
}
export default function TextSizeIcon({ className = "h-5 w-5" }: TextSizeIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
{/* Small T */}
<line x1="6" y1="7" x2="12" y2="7" />
<line x1="9" y1="7" x2="9" y2="15" />
{/* Large T */}
<line x1="10" y1="10" x2="20" y2="10" />
<line x1="15" y1="10" x2="15" y2="22" />
</svg>
);
}

View File

@ -0,0 +1,204 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Settings, ImageIcon, Loader2, CheckCircle, XCircle } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
interface BatchResult {
success: boolean;
processed: number;
failed: number;
results: Array<{
articleId: string;
title: string;
thumbnailPath: string;
success: boolean;
}>;
errors: Array<{
articleId: string;
title: string;
error: string;
}>;
message: string;
}
export default function ThumbnailGenerator() {
const [isOpen, setIsOpen] = useState(false);
const [batchLimit, setBatchLimit] = useState(10);
const queryClient = useQueryClient();
const { toast } = useToast();
const batchGenerateMutation = useMutation({
mutationFn: async (limit: number): Promise<BatchResult> => {
const response = await apiRequest('POST', '/api/articles/generate-thumbnails', { limit });
return response.json();
},
onSuccess: (data) => {
// Refetch data to show new thumbnails
queryClient.invalidateQueries({ queryKey: ['/api/feed'] });
queryClient.invalidateQueries({ queryKey: ['/api/articles'] });
toast({
title: "Batch Generation Complete",
description: `Generated ${data.processed} thumbnails${data.failed > 0 ? `, ${data.failed} failed` : ''}.`,
variant: data.failed > 0 ? "destructive" : "default",
});
},
onError: (error) => {
console.error('Error in batch generation:', error);
toast({
title: "Batch Generation Failed",
description: error instanceof Error ? error.message : "Failed to generate thumbnails. Please try again.",
variant: "destructive",
});
},
});
const handleBatchGenerate = () => {
batchGenerateMutation.mutate(batchLimit);
};
return (
<>
{/* Floating Admin Button */}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="icon"
className="fixed bottom-4 right-4 h-12 w-12 shadow-lg z-50 bg-background border-border hover:bg-accent"
data-testid="button-thumbnail-generator"
>
<Settings className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
Thumbnail Generator
</DialogTitle>
<DialogDescription>
Generate AI thumbnails for articles that don't have them
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Batch Generation Section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Batch Generation</CardTitle>
<CardDescription>
Generate thumbnails for multiple articles at once
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label htmlFor="batch-limit" className="text-sm font-medium">
Limit:
</label>
<select
id="batch-limit"
value={batchLimit}
onChange={(e) => setBatchLimit(Number(e.target.value))}
className="px-2 py-1 border rounded-md text-sm"
data-testid="select-batch-limit"
>
<option value={5}>5 articles</option>
<option value={10}>10 articles</option>
<option value={15}>15 articles</option>
<option value={20}>20 articles</option>
</select>
</div>
<Button
onClick={handleBatchGenerate}
disabled={batchGenerateMutation.isPending}
className="flex items-center gap-2"
data-testid="button-batch-generate"
>
{batchGenerateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImageIcon className="h-4 w-4" />
)}
Generate Batch
</Button>
</div>
{batchGenerateMutation.isPending && (
<div className="text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Processing articles... This may take a few minutes.
</div>
</div>
)}
</CardContent>
</Card>
{/* Results Section */}
{batchGenerateMutation.data && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
Batch Results
<Badge variant={batchGenerateMutation.data.failed > 0 ? "destructive" : "default"}>
{batchGenerateMutation.data.processed} processed, {batchGenerateMutation.data.failed} failed
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-64 w-full">
<div className="space-y-2">
{/* Successful generations */}
{batchGenerateMutation.data.results.map((result) => (
<div
key={result.articleId}
className="flex items-center gap-3 p-2 bg-green-50 dark:bg-green-950/20 rounded-lg"
>
<CheckCircle className="h-4 w-4 text-green-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{result.title}</p>
<p className="text-xs text-muted-foreground">Generated successfully</p>
</div>
</div>
))}
{/* Failed generations */}
{batchGenerateMutation.data.errors.map((error) => (
<div
key={error.articleId}
className="flex items-center gap-3 p-2 bg-red-50 dark:bg-red-950/20 rounded-lg"
>
<XCircle className="h-4 w-4 text-red-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{error.title}</p>
<p className="text-xs text-red-600">{error.error}</p>
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
)}
<div className="text-xs text-muted-foreground">
<p> Individual articles can be generated using the image icon on each article thumbnail</p>
<p> Only articles without thumbnails or using default placeholders will be processed</p>
<p> Generated thumbnails are automatically saved and displayed</p>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,18 @@
import ArticleCard from '../ArticleCard';
export default function ArticleCardExample() {
return (
<div className="p-4 max-w-sm">
<ArticleCard
id="1"
title="Jacob Robert Steeves to Speak at dAI Conference in Seoul: 'The Future of Decentralized AI'"
summary="Jacob Robert Steeves, co-founder of Bittensor, will be a featured speaker at the dAI Conference in Seoul on September 24, 2025."
thumbnail="/placeholder-image.jpg"
publishedAt={new Date('2024-09-22T09:30:00')}
outletName="Bittensor Focus"
category="People"
onClick={(id) => console.log('Article clicked:', id)}
/>
</div>
);
}

View File

@ -0,0 +1,23 @@
import ArticleDetail from '../ArticleDetail';
export default function ArticleDetailExample() {
return (
<div className="p-4">
<ArticleDetail
id="1"
title="Jacob Robert Steeves to Speak at dAI Conference in Seoul: 'The Future of Decentralized AI'"
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."
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.'"
thumbnail="/placeholder-image.jpg"
publishedAt={new Date('2024-09-22T09:30:00')}
outletName="Bittensor Focus"
category="People"
tags={["Bittensor", "AI", "Blockchain", "Seoul", "Conference"]}
onPreviousArticle={() => console.log('Previous article')}
onNextArticle={() => console.log('Next article')}
hasPrevious={true}
hasNext={true}
/>
</div>
);
}

View File

@ -0,0 +1,18 @@
import CategoryTabs from '../CategoryTabs';
export default function CategoryTabsExample() {
const categories = [
{ id: 'all', name: 'All', count: 15 },
{ id: 'people', name: 'People', count: 6 },
{ id: 'topics', name: 'Topics', count: 4 },
{ id: 'companies', name: 'Companies', count: 5 },
];
return (
<CategoryTabs
categories={categories}
activeCategory="all"
onCategoryChange={(id) => console.log('Category changed to:', id)}
/>
);
}

View File

@ -0,0 +1,12 @@
import MobileHeader from '../MobileHeader';
export default function MobileHeaderExample() {
return (
<MobileHeader
onSearchClick={() => console.log('Search clicked')}
selectedLanguage="en"
onLanguageChange={(lang) => console.log('Language changed to:', lang)}
onProfileClick={() => console.log('Profile clicked')}
/>
);
}

View File

@ -0,0 +1,17 @@
import OutletCard from '../OutletCard';
export default function OutletCardExample() {
return (
<div className="p-4 max-w-md">
<OutletCard
id="jacob-focus"
name="Bittensor Focus"
description="Dedicated coverage of Jacob Robert Steeves and Bittensor's journey in decentralized AI"
category="people"
focusSubject="Jacob Robert Steeves"
articleCount={8}
onClick={(id) => console.log('Outlet clicked:', id)}
/>
</div>
);
}

View File

@ -0,0 +1,29 @@
import OutletProfile from '../OutletProfile';
export default function OutletProfileExample() {
return (
<div className="p-4 max-w-md space-y-4">
<OutletProfile
name="Bittensor Focus"
description="Dedicated coverage of Jacob Robert Steeves and Bittensor's journey in decentralized AI"
category="people"
focusSubject="Jacob Robert Steeves"
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."
articleCount={8}
/>
<div className="border-t pt-4">
<h3 className="font-semibold mb-2">Sticky Header Version:</h3>
<OutletProfile
name="Bittensor Focus"
description="Dedicated coverage of Jacob Robert Steeves and Bittensor's journey in decentralized AI"
category="people"
focusSubject="Jacob Robert Steeves"
bio=""
articleCount={8}
isSticky={true}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,39 @@
import { useState } from 'react';
import SearchOverlay from '../SearchOverlay';
import { Button } from '@/components/ui/button';
export default function SearchOverlayExample() {
const [isOpen, setIsOpen] = useState(false);
const mockResults = [
{
id: '1',
title: 'Jacob Robert Steeves to Speak at dAI Conference in Seoul',
summary: 'Co-founder of Bittensor will discuss decentralized AI and the TAO token economy.',
category: 'People',
outletName: 'Bittensor Focus',
publishedAt: new Date('2024-09-22T09:30:00'),
},
{
id: '2',
title: 'Alpha Sigma Capital Publishes Report on Bittensor',
summary: 'In-depth analysis of the neural internet model and market implications.',
category: 'Topics',
outletName: 'Crypto Insights',
publishedAt: new Date('2024-09-22T11:15:00'),
},
];
return (
<div className="p-4">
<Button onClick={() => setIsOpen(true)}>Open Search</Button>
<SearchOverlay
isOpen={isOpen}
onClose={() => setIsOpen(false)}
onArticleClick={(id) => console.log('Article clicked:', id)}
searchResults={mockResults}
onSearch={(query) => console.log('Search query:', query)}
/>
</div>
);
}

View File

@ -0,0 +1,58 @@
import SwipeableCarousel from '../SwipeableCarousel';
import ArticleCard from '../ArticleCard';
export default function SwipeableCarouselExample() {
const items = [
{
id: '1',
content: (
<ArticleCard
id="1"
title="Jacob Robert Steeves to Speak at dAI Conference in Seoul"
summary="Co-founder of Bittensor will discuss decentralized AI and the TAO token economy."
thumbnail="/placeholder-image.jpg"
publishedAt={new Date('2024-09-22T09:30:00')}
outletName="Bittensor Focus"
category="People"
onClick={(id) => console.log('Article clicked:', id)}
/>
),
},
{
id: '2',
content: (
<ArticleCard
id="2"
title="Alpha Sigma Capital Publishes Report on Bittensor"
summary="In-depth analysis of the neural internet model and market implications."
thumbnail="/placeholder-image.jpg"
publishedAt={new Date('2024-09-22T11:15:00')}
outletName="Crypto Insights"
category="Topics"
onClick={(id) => console.log('Article clicked:', id)}
/>
),
},
{
id: '3',
content: (
<ArticleCard
id="3"
title="TAO Token Prepares for First Halving"
summary="Supply reduction event scheduled for late 2025 could impact value."
thumbnail="/placeholder-image.jpg"
publishedAt={new Date('2024-09-22T14:45:00')}
outletName="AI Weekly"
category="Topics"
onClick={(id) => console.log('Article clicked:', id)}
/>
),
},
];
return (
<div className="py-4">
<SwipeableCarousel items={items} />
</div>
);
}

View File

@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"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",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"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",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"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",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@ -0,0 +1,51 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(`
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
relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full`,
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full object-cover", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,38 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
// Whitespace-nowrap: Badges should never wrap.
"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" +
" hover-elevate " ,
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow-xs",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow-xs",
outline: " border [border-color:var(--badge-outline)] shadow-xs",
},
},
defaultVariants: {
variant: "default",
},
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"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" +
" hover-elevate active-elevate-2",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground border border-primary-border",
destructive:
"bg-destructive text-destructive-foreground border border-destructive-border",
outline:
// Shows the background color of whatever card / sidebar / accent background it is inside of.
// Inherits the current text color.
" border [border-color:var(--button-outline)] shadow-xs active:shadow-none ",
secondary: "border bg-secondary text-secondary-foreground border border-secondary-border ",
// Add a transparent border so that when someone toggles a border on later, it doesn't shift layout/size.
ghost: "border border-transparent",
},
// Heights are set as "min" heights, because sometimes Ai will place large amount of content
// inside buttons. With a min-height they will look appropriate with small amounts of content,
// but will expand to fit large amounts of content.
size: {
default: "min-h-9 px-4 py-2",
sm: "min-h-8 rounded-md px-3 text-xs",
lg: "min-h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,68 @@
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
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",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@ -0,0 +1,85 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"shadcn-card rounded-xl border bg-card border-card-border text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,260 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"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",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"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",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"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",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,151 @@
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<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">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"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",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"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",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"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",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,198 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"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",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"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]",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"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]",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"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",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"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",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"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",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"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",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"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",
className
)}
{...props}
>
{children}
<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">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"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",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"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]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"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]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"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",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"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",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"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",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"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]",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@ -0,0 +1,69 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"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",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
// h-9 to match icon buttons and default buttons.
return (
<input
type={type}
className={cn(
"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",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,256 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return <MenubarPrimitive.RadioGroup {...props} />
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"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",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"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",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"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]",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"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]",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"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",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"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",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"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",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"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"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"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 ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"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)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"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",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@ -0,0 +1,117 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"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]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"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",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,45 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"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",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"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",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"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]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"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",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"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",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"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",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
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",
right:
"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",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<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">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,727 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-[var(--sidebar-width)] flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-[var(--sidebar-width)] p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-[var(--sidebar-width)] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+var(--spacing-4))]"
: "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)]"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[var(--sidebar-width)] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+var(--spacing-4)+2px)]"
: "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
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"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
// Note: Tailwind v3.4 doesn't support "in-" selectors. So the rail won't work perfectly.
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"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",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"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",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"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",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"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",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"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",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"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))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"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",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"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",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"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",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[var(--skeleton-width)] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"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",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"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",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<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" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"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",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"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"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"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",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"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",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"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]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"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",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"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",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"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",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"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",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3 min-w-10",
sm: "h-9 px-2.5 min-w-9",
lg: "h-11 px-5 min-w-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"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]",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,51 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
// Supported languages based on MongoDB collections
export const SUPPORTED_LANGUAGES = {
en: 'English',
ko: '한국어',
ja: '日本語',
zh_cn: '简体中文',
zh_tw: '繁體中文',
de: 'Deutsch',
fr: 'Français',
es: 'Español',
it: 'Italiano',
} as const;
export type LanguageCode = keyof typeof SUPPORTED_LANGUAGES;
interface LanguageContextType {
language: LanguageCode;
setLanguage: (lang: LanguageCode) => void;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<LanguageCode>(() => {
// Get language from localStorage or default to 'en'
const stored = localStorage.getItem('language');
return (stored as LanguageCode) || 'en';
});
const setLanguage = (lang: LanguageCode) => {
console.log('[LanguageContext] Setting language to:', lang);
setLanguageState(lang);
localStorage.setItem('language', lang);
};
return (
<LanguageContext.Provider value={{ language, setLanguage }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
const context = useContext(LanguageContext);
if (context === undefined) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
}

View File

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@ -0,0 +1,191 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

136
client/src/hooks/useApi.ts Normal file
View File

@ -0,0 +1,136 @@
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
import { outletsApi, articlesApi, searchApi, mutations, feedApi } from '@/lib/api';
// Outlets hooks
export function useOutlets(category?: string, language = 'ko') {
return useQuery({
queryKey: ['/outlets', category, language],
queryFn: () => outletsApi.getAll(category, language),
});
}
export function useOutlet(id: string, language = 'ko') {
return useQuery({
queryKey: ['outlet', id, language],
queryFn: () => outletsApi.getById(id, language),
enabled: !!id && id.trim().length > 0,
});
}
// Articles hooks
export function useArticles(language = 'en') {
return useQuery({
queryKey: ['/articles', language],
queryFn: () => articlesApi.getAll(language),
});
}
export function useArticlesByOutlet(outletId: string, language = 'en') {
console.log('[useArticlesByOutlet] Called with:', { outletId, language });
return useQuery({
queryKey: ['/articles', outletId, language],
queryFn: () => {
console.log('[useArticlesByOutlet] queryFn executing with language:', language);
return articlesApi.getByOutlet(outletId, language);
},
enabled: !!outletId,
});
}
export function useFeaturedArticles(limit = 10, language = 'en') {
return useQuery({
queryKey: ['/articles', 'featured', limit, language],
queryFn: () => articlesApi.getFeatured(limit, language),
});
}
export function useArticle(id: string, language = 'en', useNewsId = true) {
return useQuery({
queryKey: ['/articles', id, language, useNewsId],
queryFn: () => articlesApi.getById(id, language, useNewsId),
enabled: !!id,
});
}
// Search hooks
export function useSearch(
query: string,
type: 'all' | 'articles' | 'outlets' = 'all',
language = 'en',
outletId?: string
) {
return useQuery({
queryKey: ['/search', query, type, language, outletId],
queryFn: () => searchApi.search(query, type, language, outletId),
enabled: !!query.trim(),
});
}
// Backward compatibility
export function useSearchArticles(query: string, language = 'en') {
return useQuery({
queryKey: ['/search', query, 'articles', language],
queryFn: () => searchApi.articles(query, language),
enabled: !!query.trim(),
});
}
// Mutation hooks
export function useCreateOutlet() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: mutations.createOutlet,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/outlets'] });
},
});
}
export function useCreateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: mutations.createArticle,
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['/articles'] });
queryClient.invalidateQueries({ queryKey: ['/articles', variables.outletId] });
},
});
}
// YouTube-style feed hooks
export function useFeed(filter: 'all' | 'people' | 'topics' | 'companies' = 'all') {
return useInfiniteQuery({
queryKey: ['/feed', filter],
queryFn: ({ pageParam }) => {
return feedApi.getFeed({
cursor: pageParam,
limit: 10,
filter: filter
});
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage: any) => lastPage?.nextCursor,
});
}
export function useIncrementView(filter?: 'all' | 'people' | 'topics' | 'companies') {
const queryClient = useQueryClient();
return useMutation({
mutationFn: feedApi.incrementView,
onSuccess: (_, articleId) => {
console.log(`[DEBUG] View incremented for article ${articleId}, invalidating cache with filter:`, filter);
// Invalidate the specific filter's feed cache for better precision
if (filter) {
queryClient.invalidateQueries({ queryKey: ['/feed', filter] });
} else {
// Fallback to invalidating all feed caches if filter not provided
queryClient.invalidateQueries({ queryKey: ['/feed'] });
}
// Also invalidate the specific article cache
queryClient.invalidateQueries({ queryKey: ['/articles'] });
},
});
}

View File

@ -0,0 +1,11 @@
import { useLanguage as useLanguageContext } from '@/contexts/LanguageContext';
export function useLanguage() {
const { language: selectedLanguage, setLanguage } = useLanguageContext();
const onLanguageChange = (lang: string) => {
setLanguage(lang as any);
};
return { selectedLanguage, onLanguageChange };
}

253
client/src/hooks/useTTS.ts Normal file
View File

@ -0,0 +1,253 @@
import { useState, useCallback, useRef, useEffect } from 'react';
interface TTSOptions {
voice?: 'nova' | 'alloy' | 'echo' | 'fable' | 'onyx' | 'shimmer';
speed?: number;
volume?: number;
}
export function useTTS(options: TTSOptions = {}) {
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentArticleId = useRef<string | null>(null);
const audioUrlRef = useRef<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const {
voice = 'nova', // Professional female voice - great for news
speed = 1.0,
volume = 1.0
} = options;
const cleanupAudio = useCallback(() => {
// Clean up current audio
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
// Cancel legacy speech synthesis
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
}
// Revoke previous blob URL to prevent memory leaks
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current);
audioUrlRef.current = null;
}
// Cancel any ongoing request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const playArticleAudio = useCallback(async (articleId: string) => {
try {
setIsLoading(true);
// Cancel any ongoing requests and cleanup
cleanupAudio();
// Create new abort controller for this request
const abortController = new AbortController();
abortControllerRef.current = abortController;
// Generate speech using OpenAI TTS API
const response = await fetch(`/api/articles/${articleId}/speech`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
voice,
speed
}),
signal: abortController.signal,
});
if (!response.ok) {
throw new Error('Failed to generate speech');
}
// Check if request was aborted
if (abortController.signal.aborted) {
return;
}
// Get audio blob
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
audioUrlRef.current = audioUrl;
// Create audio element
const audio = new Audio(audioUrl);
audio.volume = volume;
audioRef.current = audio;
currentArticleId.current = articleId;
// Set up event listeners for professional audio control
const handleLoadStart = () => setIsLoading(true);
const handleCanPlayThrough = () => setIsLoading(false);
const handlePlay = () => {
setIsPlaying(true);
setIsPaused(false);
setIsLoading(false);
};
const handlePause = () => setIsPaused(true);
const handleEnded = () => {
setIsPlaying(false);
setIsPaused(false);
cleanupAudio();
};
const handleError = () => {
setIsPlaying(false);
setIsPaused(false);
setIsLoading(false);
console.error('Audio playback error');
cleanupAudio();
};
audio.addEventListener('loadstart', handleLoadStart);
audio.addEventListener('canplaythrough', handleCanPlayThrough);
audio.addEventListener('play', handlePlay);
audio.addEventListener('pause', handlePause);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('error', handleError);
// Start playback
await audio.play();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Audio generation was cancelled');
} else {
console.error('Error playing article audio:', error);
}
setIsPlaying(false);
setIsPaused(false);
setIsLoading(false);
cleanupAudio();
}
}, [voice, speed, volume, cleanupAudio]);
const speak = useCallback((text: string) => {
// For backward compatibility - this would be for article IDs now
console.warn('useTTS.speak() is deprecated. Use playArticleAudio() with article ID instead.');
}, []);
const stop = useCallback(() => {
setIsPlaying(false);
setIsPaused(false);
cleanupAudio();
}, [cleanupAudio]);
const pause = useCallback(() => {
if (audioRef.current && !audioRef.current.paused) {
audioRef.current.pause();
setIsPaused(true);
}
}, []);
const resume = useCallback(() => {
if (audioRef.current && audioRef.current.paused && isPlaying) {
audioRef.current.play();
setIsPaused(false);
}
}, [isPlaying]);
// Legacy function for backward compatibility with text-based speech
const speakText = useCallback((text: string) => {
if (!('speechSynthesis' in window)) {
console.warn('Speech synthesis not supported');
return;
}
// Stop any current speech
cleanupAudio();
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = speed;
utterance.volume = volume;
utterance.onstart = () => {
setIsPlaying(true);
setIsPaused(false);
};
utterance.onend = () => {
setIsPlaying(false);
setIsPaused(false);
};
utterance.onerror = () => {
setIsPlaying(false);
setIsPaused(false);
};
window.speechSynthesis.speak(utterance);
}, [speed, volume, cleanupAudio]);
const toggle = useCallback((textOrId: string) => {
if (isLoading) return; // Don't allow toggle while loading
// Check if this looks like an article ID (short UUID-like string) or text (longer content)
const isArticleId = textOrId.length < 100 && !textOrId.includes(' ');
if (isArticleId) {
// New OpenAI TTS API for full articles
if (currentArticleId.current === textOrId && isPlaying) {
if (isPaused) {
resume();
} else {
pause();
}
} else {
// Play new article or start playing
playArticleAudio(textOrId);
}
} else {
// Legacy text-to-speech for shorter content like feed items
if (isPlaying) {
if (isPaused && 'speechSynthesis' in window) {
window.speechSynthesis.resume();
setIsPaused(false);
} else {
if (audioRef.current) {
pause();
} else if ('speechSynthesis' in window) {
window.speechSynthesis.pause();
setIsPaused(true);
}
}
} else {
speakText(textOrId);
}
}
}, [isPlaying, isPaused, isLoading, playArticleAudio, pause, resume, speakText]);
// Clean up on unmount
useEffect(() => {
return () => {
cleanupAudio();
};
}, [cleanupAudio]);
return {
isPlaying,
isPaused,
isLoading,
speak,
stop,
pause,
resume,
toggle,
playArticleAudio,
isSupported: true // OpenAI TTS is always supported via API
};
}

View File

@ -0,0 +1,73 @@
import { useState, useEffect } from 'react';
type ThemeMode = 'light' | 'dark' | 'system';
type AppliedTheme = 'light' | 'dark';
export function useTheme() {
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
// Check localStorage first, then default to system
const stored = localStorage.getItem('theme') as ThemeMode;
if (stored && ['light', 'dark', 'system'].includes(stored)) {
return stored;
}
return 'system';
});
const [appliedTheme, setAppliedTheme] = useState<AppliedTheme>(() => {
if (themeMode === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return themeMode as AppliedTheme;
});
useEffect(() => {
const updateAppliedTheme = () => {
if (themeMode === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
setAppliedTheme(systemTheme);
} else {
setAppliedTheme(themeMode as AppliedTheme);
}
};
updateAppliedTheme();
// Listen for system theme changes when in system mode
if (themeMode === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', updateAppliedTheme);
return () => {
mediaQuery.removeEventListener('change', updateAppliedTheme);
};
}
}, [themeMode]);
useEffect(() => {
const root = document.documentElement;
// Remove existing theme classes
root.classList.remove('light', 'dark');
// Add new theme class
root.classList.add(appliedTheme);
// Store in localStorage
localStorage.setItem('theme', themeMode);
}, [appliedTheme, themeMode]);
const toggleTheme = () => {
setThemeMode(prev => {
if (prev === 'light') return 'dark';
if (prev === 'dark') return 'system';
return 'light';
});
};
return {
theme: appliedTheme,
themeMode,
setTheme: setThemeMode,
toggleTheme
};
}

339
client/src/index.css Normal file
View File

@ -0,0 +1,339 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* LIGHT MODE */
:root {
--button-outline: rgba(0,0,0, .10);
--badge-outline: rgba(0,0,0, .05);
/* Automatic computation of border around primary / danger buttons */
--opaque-button-border-intensity: -8; /* In terms of percentages */
/* Backgrounds applied on top of other backgrounds when hovered/active */
--elevate-1: rgba(0,0,0, .03);
--elevate-2: rgba(0,0,0, .08);
--background: 0 0% 98%;
--foreground: 222 15% 15%;
--border: 220 13% 91%;
--card: 0 0% 100%;
--card-foreground: 222 15% 15%;
--card-border: 220 13% 91%;
--sidebar: 220 13% 96%;
--sidebar-foreground: 222 15% 15%;
--sidebar-border: 220 13% 93%;
--sidebar-primary: 210 85% 55%;
--sidebar-primary-foreground: 210 15% 95%;
--sidebar-accent: 220 13% 93%;
--sidebar-accent-foreground: 222 15% 15%;
--sidebar-ring: 210 85% 55%;
--popover: 220 13% 93%;
--popover-foreground: 222 15% 15%;
--popover-border: 220 13% 90%;
--primary: 210 85% 55%;
--primary-foreground: 210 15% 95%;
--secondary: 220 13% 90%;
--secondary-foreground: 222 15% 15%;
--muted: 220 13% 94%;
--muted-foreground: 222 15% 45%;
--accent: 220 13% 94%;
--accent-foreground: 222 15% 15%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 15% 95%;
--input: 220 13% 85%;
--ring: 210 85% 55%;
--chart-1: 210 85% 45%;
--chart-2: 142 76% 40%;
--chart-3: 280 65% 45%;
--chart-4: 25 85% 45%;
--chart-5: 340 75% 45%;
--font-sans: Inter, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: .5rem; /* 8px */
--shadow-2xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.05);
--shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.05);
--shadow-sm: 0px 1px 2px 0px rgba(0, 0, 0, 0.05), 0px 1px 3px 0px rgba(0, 0, 0, 0.1);
--shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05), 0px 1px 3px 0px rgba(0, 0, 0, 0.1);
--shadow-md: 0px 2px 4px -1px rgba(0, 0, 0, 0.07), 0px 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0px 4px 6px -2px rgba(0, 0, 0, 0.05), 0px 10px 15px -3px rgba(0, 0, 0, 0.1);
--shadow-xl: 0px 8px 10px -3px rgba(0, 0, 0, 0.1), 0px 20px 25px -5px rgba(0, 0, 0, 0.1);
--shadow-2xl: 0px 25px 50px -12px rgba(0, 0, 0, 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
/* Automatically computed borders - intensity can be controlled by the user by the --opaque-button-border-intensity setting */
/* Fallback for older browsers */
--sidebar-primary-border: hsl(var(--sidebar-primary));
--sidebar-primary-border: hsl(from hsl(var(--sidebar-primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
/* Fallback for older browsers */
--sidebar-accent-border: hsl(var(--sidebar-accent));
--sidebar-accent-border: hsl(from hsl(var(--sidebar-accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
/* Fallback for older browsers */
--primary-border: hsl(var(--primary));
--primary-border: hsl(from hsl(var(--primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
/* Fallback for older browsers */
--secondary-border: hsl(var(--secondary));
--secondary-border: hsl(from hsl(var(--secondary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
/* Fallback for older browsers */
--muted-border: hsl(var(--muted));
--muted-border: hsl(from hsl(var(--muted)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
/* Fallback for older browsers */
--accent-border: hsl(var(--accent));
--accent-border: hsl(from hsl(var(--accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
/* Fallback for older browsers */
--destructive-border: hsl(var(--destructive));
--destructive-border: hsl(from hsl(var(--destructive)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
}
.dark {
--button-outline: rgba(255,255,255, .10);
--badge-outline: rgba(255,255,255, .05);
--opaque-button-border-intensity: 9; /* In terms of percentages */
/* Backgrounds applied on top of other backgrounds when hovered/active */
--elevate-1: rgba(255,255,255, .04);
--elevate-2: rgba(255,255,255, .09);
--background: 222 15% 8%;
--foreground: 210 15% 95%;
--border: 222 15% 18%;
--card: 222 15% 12%;
--card-foreground: 210 15% 95%;
--card-border: 222 15% 20%;
--sidebar: 222 15% 10%;
--sidebar-foreground: 210 15% 95%;
--sidebar-border: 222 15% 15%;
--sidebar-primary: 210 85% 55%;
--sidebar-primary-foreground: 210 15% 95%;
--sidebar-accent: 222 15% 15%;
--sidebar-accent-foreground: 210 15% 95%;
--sidebar-ring: 210 85% 55%;
--popover: 222 15% 15%;
--popover-foreground: 210 15% 95%;
--popover-border: 222 15% 23%;
--primary: 210 85% 55%;
--primary-foreground: 210 15% 95%;
--secondary: 222 15% 20%;
--secondary-foreground: 210 15% 95%;
--muted: 222 15% 14%;
--muted-foreground: 210 15% 65%;
--accent: 222 15% 14%;
--accent-foreground: 210 15% 95%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 15% 95%;
/* 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 */
--input: 222 15% 25%;
--ring: 210 85% 55%;
--chart-1: 210 85% 65%;
--chart-2: 142 76% 60%;
--chart-3: 280 65% 65%;
--chart-4: 25 85% 65%;
--chart-5: 340 75% 65%;
--shadow-2xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
--shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
--shadow-sm: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 0px rgba(0, 0, 0, 0.4);
--shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 0px rgba(0, 0, 0, 0.4);
--shadow-md: 0px 2px 4px -1px rgba(0, 0, 0, 0.4), 0px 4px 6px -1px rgba(0, 0, 0, 0.5);
--shadow-lg: 0px 4px 6px -2px rgba(0, 0, 0, 0.4), 0px 10px 15px -3px rgba(0, 0, 0, 0.5);
--shadow-xl: 0px 8px 10px -3px rgba(0, 0, 0, 0.5), 0px 20px 25px -5px rgba(0, 0, 0, 0.6);
--shadow-2xl: 0px 25px 50px -12px rgba(0, 0, 0, 0.7);
}
@layer base {
* {
@apply border-border;
}
body {
@apply font-sans antialiased bg-background text-foreground;
}
}
/**
* Using the elevate system.
* Automatic contrast adjustment.
*
* <element className="hover-elevate" />
* <element className="active-elevate-2" />
*
* // Using the tailwind utility when a data attribute is "on"
* <element className="toggle-elevate data-[state=on]:toggle-elevated" />
* // Or manually controlling the toggle state
* <element className="toggle-elevate toggle-elevated" />
*
* Elevation systems have to handle many states.
* - not-hovered, vs. hovered vs. active (three mutually exclusive states)
* - toggled or not
* - focused or not (this is not handled with these utilities)
*
* Even without handling focused or not, this is six possible combinations that
* need to be distinguished from eachother visually.
*/
@layer utilities {
/* Custom scrollbar - hidden but functional */
.custom-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.custom-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* Hide ugly search cancel button in Chrome until we can style it properly */
input[type="search"]::-webkit-search-cancel-button {
@apply hidden;
}
/* Placeholder styling for contentEditable div */
[contenteditable][data-placeholder]:empty::before {
content: attr(data-placeholder);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
/* .no-default-hover-elevate/no-default-active-elevate is an escape hatch so consumers of
* buttons/badges can remove the automatic brightness adjustment on interactions
* and program their own. */
.no-default-hover-elevate {}
.no-default-active-elevate {}
/**
* Toggleable backgrounds go behind the content. Hoverable/active goes on top.
* This way they can stack/compound. Both will overlap the parent's borders!
* So borders will be automatically adjusted both on toggle, and hover/active,
* and they will be compounded.
*/
.toggle-elevate::before,
.toggle-elevate-2::before {
content: "";
pointer-events: none;
position: absolute;
inset: 0px;
/*border-radius: inherit; match rounded corners */
border-radius: inherit;
z-index: -1;
/* sits behind content but above backdrop */
}
.toggle-elevate.toggle-elevated::before {
background-color: var(--elevate-2);
}
/* If there's a 1px border, adjust the inset so that it covers that parent's border */
.border.toggle-elevate::before {
inset: -1px;
}
/* Does not work on elements with overflow:hidden! */
.hover-elevate:not(.no-default-hover-elevate),
.active-elevate:not(.no-default-active-elevate),
.hover-elevate-2:not(.no-default-hover-elevate),
.active-elevate-2:not(.no-default-active-elevate) {
position: relative;
z-index: 0;
}
.hover-elevate:not(.no-default-hover-elevate)::after,
.active-elevate:not(.no-default-active-elevate)::after,
.hover-elevate-2:not(.no-default-hover-elevate)::after,
.active-elevate-2:not(.no-default-active-elevate)::after {
content: "";
pointer-events: none;
position: absolute;
inset: 0px;
/*border-radius: inherit; match rounded corners */
border-radius: inherit;
z-index: 999;
/* sits in front of content */
}
.hover-elevate:hover:not(.no-default-hover-elevate)::after,
.active-elevate:active:not(.no-default-active-elevate)::after {
background-color: var(--elevate-1);
}
.hover-elevate-2:hover:not(.no-default-hover-elevate)::after,
.active-elevate-2:active:not(.no-default-active-elevate)::after {
background-color: var(--elevate-2);
}
/* If there's a 1px border, adjust the inset so that it covers that parent's border */
.border.hover-elevate:not(.no-hover-interaction-elevate)::after,
.border.active-elevate:not(.no-active-interaction-elevate)::after,
.border.hover-elevate-2:not(.no-hover-interaction-elevate)::after,
.border.active-elevate-2:not(.no-active-interaction-elevate)::after,
.border.hover-elevate:not(.no-hover-interaction-elevate)::after {
inset: -1px;
}
}

155
client/src/lib/api.ts Normal file
View File

@ -0,0 +1,155 @@
import { queryClient } from './queryClient';
import * as newsapi from './newsapi-client';
const API_BASE = '/api';
// Generic API request helper
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
throw new Error(`API Error: ${response.statusText}`);
}
return response.json();
}
// Outlets API (using news-api client)
export const outletsApi = {
getAll: (category?: string, language = 'ko') =>
newsapi.getOutlets(category, language),
getById: (id: string, language = 'ko') =>
newsapi.getOutletById(id, language),
// Keep create for compatibility but it won't work without server
create: (data: any) =>
Promise.reject(new Error('Create outlet not supported in direct news-api mode')),
};
// Articles API (using news-api client)
export const articlesApi = {
getAll: (language = 'en') =>
newsapi.getLatestArticles(50, language),
getByOutlet: (outletId: string, language = 'en') =>
newsapi.getArticlesByOutlet(outletId, 50, language),
getFeatured: (limit = 10, language = 'en') =>
newsapi.getLatestArticles(limit, language),
getById: (id: string, language = 'en', useNewsId = false) =>
newsapi.getArticleById(id, language, useNewsId),
// Keep create for compatibility but it won't work without server
create: (data: any) =>
Promise.reject(new Error('Create article not supported in direct news-api mode')),
};
// Search API (using server search endpoint)
export const searchApi = {
search: async (
query: string,
type: 'all' | 'articles' | 'outlets' = 'all',
language = 'en',
outletId?: string
) => {
const params = new URLSearchParams({
q: query,
type,
language
});
if (outletId) {
params.append('outletId', outletId);
}
const response = await fetch(`/api/search?${params}`);
if (!response.ok) {
throw new Error('Search failed');
}
return response.json();
},
// Backward compatibility
articles: (query: string, language = 'en') =>
newsapi.searchArticles(query, 20, language),
};
// AI API
export const aiApi = {
generateThumbnail: (description: string, aspectRatio = '16:9') =>
apiRequest('/generate-thumbnail', {
method: 'POST',
body: JSON.stringify({ description, aspectRatio }),
}),
enhanceArticle: (title: string, summary: string, content: string) =>
apiRequest('/enhance-article', {
method: 'POST',
body: JSON.stringify({ title, summary, content }),
}),
generateSummary: (content: string, title?: string) =>
apiRequest('/generate-summary', {
method: 'POST',
body: JSON.stringify({ content, title }),
}),
};
// Stats API
export const statsApi = {
get: () => apiRequest('/stats'),
};
// Feed API for YouTube-style interface
export const feedApi = {
getFeed: (params: {
cursor?: string;
limit?: number;
filter?: 'all' | 'people' | 'topics' | 'companies'
}) => {
const searchParams = new URLSearchParams();
if (params.cursor) searchParams.set('cursor', params.cursor);
if (params.limit) searchParams.set('limit', params.limit.toString());
if (params.filter) searchParams.set('filter', params.filter);
return apiRequest(`/feed?${searchParams.toString()}`);
},
incrementView: (articleId: string) =>
apiRequest(`/articles/${articleId}/view`, {
method: 'POST'
})
};
// Mutation helpers for React Query
export const mutations = {
createOutlet: (data: any) => {
return apiRequest('/outlets', {
method: 'POST',
body: JSON.stringify(data),
}).then((result) => {
queryClient.invalidateQueries({ queryKey: ['/outlets'] });
return result;
});
},
createArticle: (data: any) => {
return apiRequest('/articles', {
method: 'POST',
body: JSON.stringify(data),
}).then((result) => {
queryClient.invalidateQueries({ queryKey: ['/articles'] });
queryClient.invalidateQueries({ queryKey: ['/articles', data.outletId] });
return result;
});
},
};

View File

@ -0,0 +1,120 @@
/**
* News API Client for Frontend
*
* Thin wrapper around Express server API endpoints
*/
const API_BASE = '/api';
export interface Outlet {
id: string;
name: string;
category: 'people' | 'topics' | 'companies';
articles: string[];
image?: string;
description: string;
}
export interface OutletsData {
people: Outlet[];
topics: Outlet[];
companies: Outlet[];
}
/**
* Load outlets from server
*/
export async function loadOutlets(): Promise<OutletsData> {
const response = await fetch(`${API_BASE}/outlets`);
if (!response.ok) {
throw new Error('Failed to load outlets');
}
const outlets = await response.json();
// Group outlets by category
return {
people: outlets.filter((o: Outlet) => o.category === 'people'),
topics: outlets.filter((o: Outlet) => o.category === 'topics'),
companies: outlets.filter((o: Outlet) => o.category === 'companies')
};
}
/**
* Get all outlets or by category
*/
export async function getOutlets(category?: string, language = 'ko'): Promise<Outlet[]> {
const params = new URLSearchParams();
if (category) params.append('category', category);
if (language) params.append('language', language);
const response = await fetch(`${API_BASE}/outlets?${params}`);
if (!response.ok) {
throw new Error('Failed to get outlets');
}
return response.json();
}
/**
* Get outlet by ID
*/
export async function getOutletById(id: string, language = 'ko'): Promise<Outlet | null> {
const params = new URLSearchParams({ language });
const response = await fetch(`${API_BASE}/outlets/${id}?${params}`);
if (!response.ok) {
if (response.status === 404) return null;
throw new Error('Failed to get outlet');
}
return response.json();
}
/**
* Get articles by outlet
*/
export async function getArticlesByOutlet(outletId: string, limit = 50, language = 'en'): Promise<any[]> {
console.log(`[newsapi-client] getArticlesByOutlet called with language: ${language}`);
const response = await fetch(`${API_BASE}/articles?outlet=${outletId}&limit=${limit}&language=${language}`);
if (!response.ok) {
throw new Error('Failed to get articles');
}
return response.json();
}
/**
* Get article by ID or news_id
*/
export async function getArticleById(id: string, language = 'en', useNewsId = false): Promise<any | null> {
console.log(`[newsapi-client] getArticleById called with language: ${language}, useNewsId: ${useNewsId}`);
const params = new URLSearchParams({ language });
if (useNewsId) {
params.append('useNewsId', 'true');
}
const response = await fetch(`${API_BASE}/articles/${id}?${params}`);
if (!response.ok) {
if (response.status === 404) return null;
throw new Error('Failed to get article');
}
return response.json();
}
/**
* Get latest articles
*/
export async function getLatestArticles(limit = 50, language = 'en'): Promise<any[]> {
const response = await fetch(`${API_BASE}/articles?limit=${limit}&language=${language}`);
if (!response.ok) {
throw new Error('Failed to get latest articles');
}
return response.json();
}
/**
* Search articles
*/
export async function searchArticles(query: string, limit = 20, language = 'en'): Promise<any[]> {
const response = await fetch(`${API_BASE}/search?q=${encodeURIComponent(query)}&type=articles&limit=${limit}&language=${language}`);
if (!response.ok) {
throw new Error('Failed to search articles');
}
const result = await response.json();
return result.articles || [];
}

View File

@ -0,0 +1,57 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query";
async function throwIfResNotOk(res: Response) {
if (!res.ok) {
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
}
export async function apiRequest(
method: string,
url: string,
data?: unknown | undefined,
): Promise<Response> {
const res = await fetch(url, {
method,
headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: "include",
});
await throwIfResNotOk(res);
return res;
}
type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: {
on401: UnauthorizedBehavior;
}) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey.join("/") as string, {
credentials: "include",
});
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
return null;
}
await throwIfResNotOk(res);
return await res.json();
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: getQueryFn({ on401: "throw" }),
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
retry: false,
},
mutations: {
retry: false,
},
},
});

19
client/src/lib/utils.ts Normal file
View File

@ -0,0 +1,19 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Format time distance - always returns "X min ago" format
*/
export function formatTimeAgo(date: Date | string): string {
const now = new Date()
const past = new Date(date)
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000)
const diffInMinutes = Math.floor(diffInSeconds / 60)
// Always return in "X min ago" format
return `${diffInMinutes} min ago`
}

5
client/src/main.tsx Normal file
View File

@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);

View File

@ -0,0 +1,337 @@
import { useState, useEffect, useRef } from "react";
import { useRoute, useLocation } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Minus, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { apiRequest, queryClient } from "@/lib/queryClient";
import ArticleDetail from "@/components/ArticleDetail";
import SearchOverlay from "@/components/SearchOverlay";
import BottomTabBar from "@/components/BottomTabBar";
import CommentsDrawer from "@/components/CommentsDrawer";
import OutletProfile from "@/components/OutletProfile";
import PredictionMarketCard from "@/components/PredictionMarketCard";
import { useSearch, useArticle, useOutlet } from "@/hooks/useApi";
import { useToast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
import type { Article, MediaOutlet, PredictionMarket } from "@shared/schema";
export default function ArticlePage() {
const [match, params] = useRoute("/article/:id");
const [, setLocation] = useLocation();
const { language, setLanguage } = useLanguage();
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [showComments, setShowComments] = useState(false);
const [textSize, setTextSize] = useState(5);
const [showTextSizeControls, setShowTextSizeControls] = useState(false);
const [showAllMarkets, setShowAllMarkets] = useState(false);
const [userIdentifier] = useState(() => {
const stored = localStorage.getItem('userIdentifier');
if (stored) return stored;
const newId = `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('userIdentifier', newId);
return newId;
});
const { toast } = useToast();
const textSizeControlsRef = useRef<HTMLDivElement>(null);
const articleId = params?.id || '';
// Get outlet info from URL params (from OutletPage)
const urlParams = new URLSearchParams(window.location.search);
const fromOutletId = urlParams.get('fromOutlet');
// Fetch article from API
const { data: article, isLoading: articleLoading, isError: articleError } = useArticle(articleId, language);
// Fetch outlet information - use fromOutlet if available, otherwise use article's outletId
const effectiveOutletId = fromOutletId || article?.outletId || '';
const { data: outlet, isLoading: outletLoading } = useOutlet(effectiveOutletId, language);
// Search API call - scope to current outlet
const { data: searchResults = { articles: [], outlets: [] } } = useSearch(searchQuery, 'all', language, effectiveOutletId);
// Fetch comment count
const { data: commentCountData } = useQuery<{ count: number }>({
queryKey: ['/api/articles', articleId, 'comment-count'],
enabled: !!articleId
});
// Fetch bookmark status
const { data: bookmarkData } = useQuery<{ isBookmarked: boolean }>({
queryKey: ['/api/bookmarks', articleId, userIdentifier],
enabled: !!articleId && !!userIdentifier
});
// Fetch prediction markets
const { data: predictionMarkets = [] } = useQuery<PredictionMarket[]>({
queryKey: ['/api/prediction-markets/article', articleId, showAllMarkets ? 100 : 3],
queryFn: async () => {
const limit = showAllMarkets ? 100 : 3;
const response = await fetch(`/api/prediction-markets/article/${articleId}?limit=${limit}`);
return response.json();
},
enabled: !!articleId
});
// Toggle bookmark mutation - must be declared before any conditional returns
const toggleBookmark = useMutation({
mutationFn: async () => {
const response = await apiRequest('POST', '/api/bookmarks/toggle', {
articleId,
userIdentifier,
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/bookmarks', articleId, userIdentifier] });
},
});
const commentCount = commentCountData?.count || 0;
const isBookmarked = bookmarkData?.isBookmarked || false;
const isLoading = articleLoading || outletLoading;
const isError = articleError;
// Close text size controls when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (textSizeControlsRef.current && !textSizeControlsRef.current.contains(event.target as Node)) {
setShowTextSizeControls(false);
}
};
if (showTextSizeControls) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [showTextSizeControls]);
if (isLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground">Loading article...</p>
</div>
</div>
);
}
if (isError || !article) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-2">
<h1 className="text-xl font-semibold">Article not found</h1>
<p className="text-muted-foreground">The requested article does not exist.</p>
</div>
</div>
);
}
// Previous/next navigation will be implemented when feed ordering is available
const handlePreviousArticle = () => {};
const handleNextArticle = () => {};
const handleArticleClick = (articleId: string) => {
setLocation(`/article/${articleId}`);
};
const handleOutletClick = (outletId: string) => {
setLocation(`/outlet/${outletId}`);
};
const handleTitleClick = () => {
// Navigate to home with the article's outlet category selected
const category = outlet?.category || 'people';
setLocation(`/?category=${category}`);
};
const handleBackClick = () => {
// Navigate back to the outlet page
if (outlet?.id) {
setLocation(`/outlet/${outlet.id}`);
} else {
window.history.back();
}
};
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const handleCommentClick = () => {
setShowComments(!showComments);
};
const handleShareClick = async () => {
try {
if (navigator.share) {
await navigator.share({
title: article?.title,
text: article?.summary,
url: window.location.href,
});
} else {
await navigator.clipboard.writeText(window.location.href);
toast({
title: "Link copied",
description: "Article link copied to clipboard",
});
}
} catch (error) {
// User cancelled share or clipboard write failed - silently ignore
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Share failed:', error);
}
}
};
const handleBookmarkClick = () => {
toggleBookmark.mutate();
toast({
title: isBookmarked ? "Removed from bookmarks" : "Added to bookmarks",
description: isBookmarked ? "Article removed from your bookmarks" : "Article saved to your bookmarks",
});
};
const handleTextSizeChange = (size: number) => {
setTextSize(size);
};
return (
<div className="flex flex-col h-screen bg-background overflow-hidden">
{/* Fixed Profile Header */}
<div className="flex-none">
<OutletProfile
id={outlet?.id || article?.outletId || 'unknown'}
name={outlet?.name || article?.outletName || 'Unknown Outlet'}
category={outlet?.category || (article as any)?.category || 'topics'}
description={outlet?.description || ''}
image={outlet?.image}
avatar={outlet?.avatar || undefined}
profileImage={outlet?.profileImage || undefined}
fullBio={outlet?.fullBio || undefined}
wikiProfile={outlet?.wikiProfile || undefined}
isSticky={true}
onSearchClick={() => setIsSearchOpen(true)}
selectedLanguage={language}
onLanguageChange={setLanguage}
onLoginClick={() => console.log('Login clicked')}
/>
</div>
{/* Content area with slide transition */}
<div className="flex-1 overflow-hidden relative">
<div className={`absolute inset-0 transition-transform duration-500 ease-out ${isSearchOpen ? '-translate-x-full' : 'translate-x-0'}`}>
{/* Scrollable Content */}
<div className="h-full overflow-y-auto custom-scrollbar pb-14">
<div className="p-4">
<ArticleDetail
id={article.id}
title={article.title}
summary={article.summary}
body={article.body}
thumbnail={article.thumbnail}
publishedAt={new Date(article.publishedAt)}
timeAgo={(article as any).timeAgo}
outletName={outlet?.name || 'Unknown Outlet'}
category={outlet?.category || 'General'}
tags={article.tags || []}
subtopics={(article as any).subtopics || []}
textSize={textSize}
onPreviousArticle={handlePreviousArticle}
onNextArticle={handleNextArticle}
hasPrevious={false}
hasNext={false}
onArticleClick={handleArticleClick}
onOutletClick={handleOutletClick}
/>
</div>
</div>
</div>
{/* Search Overlay positioned to slide in from right */}
<SearchOverlay
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
onArticleClick={(id) => setLocation(`/article/${id}`)}
searchResults={searchResults as any}
onSearch={handleSearch}
/>
</div>
{/* Text Size Controls - shown when text size button clicked */}
{showTextSizeControls && (
<div ref={textSizeControlsRef} className="fixed bottom-20 right-4 z-50 animate-in fade-in slide-in-from-bottom-2 duration-200">
<Card className="p-2 shadow-lg">
<div className="flex flex-col gap-1">
{/* Plus button */}
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => handleTextSizeChange(Math.min(9, textSize + 1))}
disabled={textSize === 9}
data-testid="button-text-size-increase"
>
<Plus className="h-5 w-5" />
</Button>
{/* Minus button */}
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => handleTextSizeChange(Math.max(0, textSize - 1))}
disabled={textSize === 0}
data-testid="button-text-size-decrease"
>
<Minus className="h-5 w-5" />
</Button>
{/* Reset button */}
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => handleTextSizeChange(5)}
data-testid="button-text-size-reset"
>
<RotateCcw className="h-5 w-5" />
</Button>
</div>
</Card>
</div>
)}
{/* Fixed Bottom Navigation Bar */}
<div className="flex-none">
<BottomTabBar
variant="nav"
onTitleClick={handleTitleClick}
onBackClick={handleBackClick}
commentCount={commentCount}
isBookmarked={isBookmarked}
onCommentClick={handleCommentClick}
onShareClick={handleShareClick}
onBookmarkClick={handleBookmarkClick}
onTextSizeClick={() => setShowTextSizeControls(!showTextSizeControls)}
/>
</div>
{/* Comments Drawer */}
<CommentsDrawer
articleId={articleId}
isOpen={showComments}
onClose={() => setShowComments(false)}
/>
</div>
);
}

View File

@ -0,0 +1,129 @@
import { useState, useEffect } from "react";
import { useLocation } from "wouter";
import MobileHeader from "@/components/MobileHeader";
import BottomTabBar from "@/components/BottomTabBar";
import SearchOverlay from "@/components/SearchOverlay";
import OutletCard from "@/components/OutletCard";
import { useSearch, useOutlets } from "@/hooks/useApi";
import { useLanguage } from "@/contexts/LanguageContext";
import type { MediaOutlet } from "@shared/schema";
// Extended type for outlets with article count from API
type MediaOutletWithCount = MediaOutlet & {
articleCount?: number;
};
// Real data now loaded from API
export default function HomePage() {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [, setLocation] = useLocation();
const { language } = useLanguage();
// Get category from URL params or default to 'people'
const getInitialCategory = (): 'people' | 'topics' | 'companies' => {
const params = new URLSearchParams(window.location.search);
const category = params.get('category') as 'people' | 'topics' | 'companies';
return ['people', 'topics', 'companies'].includes(category) ? category : 'people';
};
const [activeFilter, setActiveFilter] = useState<'people' | 'topics' | 'companies'>(getInitialCategory());
// Update URL when category changes
useEffect(() => {
const params = new URLSearchParams(window.location.search);
params.set('category', activeFilter);
const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.replaceState({}, '', newUrl);
}, [activeFilter]);
// Handle browser back/forward buttons
useEffect(() => {
const handlePopState = () => {
setActiveFilter(getInitialCategory());
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
// Search API call
const { data: searchResults = { articles: [], outlets: [] } } = useSearch(searchQuery, 'all', language);
// Fetch outlets for active category with language
const { data: filteredOutlets = [], isLoading: outletsLoading } = useOutlets(activeFilter, language);
const handleSearch = (query: string) => {
setSearchQuery(query);
};
return (
<div className="flex flex-col h-screen bg-background overflow-hidden">
{/* Fixed Header */}
<div className="flex-none">
<MobileHeader
title="Sapiens"
onSearchClick={() => setIsSearchOpen(true)}
/>
</div>
{/* Content area with slide transition */}
<div className="flex-1 overflow-hidden relative">
<div className={`absolute inset-0 flex flex-col transition-transform duration-300 ease-out ${isSearchOpen ? '-translate-x-full' : 'translate-x-0'}`}>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
<section className="px-3 py-4 max-w-full">
{outletsLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading media outlets...</p>
</div>
) : (
<div className="flex flex-col gap-2">
{filteredOutlets.map((outlet) => (
<OutletCard
key={outlet.id}
id={outlet.id}
name={outlet.name}
description={outlet.description}
category={outlet.category}
focusSubject={outlet.focusSubject}
avatar={outlet.avatar || undefined}
articleCount={outlet.articleCount || 0}
onClick={(id) => setLocation(`/outlet/${id}`)}
/>
))}
</div>
)}
</section>
</div>
</div>
{/* Search Overlay positioned to slide in from right */}
<SearchOverlay
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
onArticleClick={(id) => console.log('Navigate to article:', id)}
searchResults={searchResults as any}
onSearch={handleSearch}
/>
</div>
{/* Fixed Bottom Tab Bar */}
<div className="flex-none">
<BottomTabBar
activeTab={activeFilter}
onTabChange={(tab) => {
// Push new state to history for proper back/forward navigation
const params = new URLSearchParams();
params.set('category', tab);
const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.pushState({}, '', newUrl);
setActiveFilter(tab);
}}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,310 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useRoute, useLocation } from "wouter";
import { Loader2 } from "lucide-react";
import OutletProfile from "@/components/OutletProfile";
import BottomTabBar from "@/components/BottomTabBar";
import ArticleCard from "@/components/ArticleCard";
import SearchOverlay from "@/components/SearchOverlay";
import { useOutlet, useArticlesByOutlet, useSearch } from "@/hooks/useApi";
import { useLanguage } from "@/contexts/LanguageContext";
import { queryClient } from "@/lib/queryClient";
// Real data loaded from API
export default function OutletPage() {
const [match, params] = useRoute("/outlet/:id");
const [, setLocation] = useLocation();
const { language, setLanguage } = useLanguage();
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
// Pull-to-refresh state
const [isPulling, setIsPulling] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const touchStartY = useRef(0);
const touchCurrentY = useRef(0);
const containerRef = useRef<HTMLDivElement>(null);
// Fix route parameter extraction - wouter returns params as second element
const outletId = params?.id || '';
// API calls for real data
console.log('[OutletPage] Rendering with language:', language);
const { data: outlet, isLoading: outletLoading, error: outletError } = useOutlet(outletId, language);
const { data: articles = [], isLoading: articlesLoading } = useArticlesByOutlet(outletId, language);
const { data: searchResults = { articles: [], outlets: [] } } = useSearch(searchQuery, 'all', language, outletId);
// Pull-to-refresh handlers - MUST be before any conditional returns
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
// Invalidate and refetch outlet and articles data
await queryClient.invalidateQueries({ queryKey: ['outlet', outletId] });
await queryClient.invalidateQueries({ queryKey: ['articles', outletId] });
// Wait a bit for the refresh to complete
setTimeout(() => {
setIsRefreshing(false);
setIsPulling(false);
setPullDistance(0);
}, 1000);
} catch (error) {
setIsRefreshing(false);
setIsPulling(false);
setPullDistance(0);
}
}, [outletId]);
const handleTouchStart = useCallback((e: TouchEvent) => {
if (containerRef.current && containerRef.current.scrollTop === 0) {
touchStartY.current = e.touches[0].clientY;
setIsPulling(true);
}
}, []);
const handleTouchMove = useCallback((e: TouchEvent) => {
if (!isPulling || isRefreshing) return;
touchCurrentY.current = e.touches[0].clientY;
const distance = touchCurrentY.current - touchStartY.current;
if (distance > 0) {
e.preventDefault();
const pullDist = Math.min(distance * 0.5, 80); // Max pull distance of 80px
setPullDistance(pullDist);
}
}, [isPulling, isRefreshing]);
const handleTouchEnd = useCallback(() => {
if (pullDistance > 60 && !isRefreshing) {
handleRefresh();
} else {
setIsPulling(false);
setPullDistance(0);
}
}, [pullDistance, isRefreshing, handleRefresh]);
// Add touch event listeners
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('touchstart', handleTouchStart, { passive: false });
container.addEventListener('touchmove', handleTouchMove, { passive: false });
container.addEventListener('touchend', handleTouchEnd, { passive: false });
return () => {
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchend', handleTouchEnd);
};
}, [handleTouchStart, handleTouchMove, handleTouchEnd]);
// Type-safe outlet data
const outletData = outlet as any;
const articlesData = articles as any[];
// Show loading state
if (outletLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-2">
<p className="text-muted-foreground">Loading</p>
</div>
</div>
);
}
// Show not found state
if (!outlet) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-2">
<h1 className="text-xl font-semibold">Outlet not found</h1>
<p className="text-muted-foreground">The requested outlet does not exist.</p>
</div>
</div>
);
}
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const handleTitleClick = () => {
// Navigate to home with the outlet's category selected
const category = outletData?.category || 'people';
setLocation(`/?category=${category}`);
};
const handleBackClick = () => {
// Navigate back to home with the outlet's category selected
const category = outletData?.category || 'people';
setLocation(`/?category=${category}`);
};
return (
<div className="flex flex-col h-screen bg-background overflow-hidden">
{/* Fixed Profile Header */}
<div className="flex-none">
<OutletProfile
{...outletData}
wikiProfile={outletData?.wikiProfile}
isSticky={true}
onSearchClick={() => setIsSearchOpen(true)}
selectedLanguage={language}
onLanguageChange={setLanguage}
onLoginClick={() => console.log('Login clicked')}
articleViewMode={viewMode}
onArticleViewModeChange={setViewMode}
/>
</div>
{/* Content area with slide transition */}
<div className="flex-1 overflow-hidden relative">
<div className={`absolute inset-0 transition-transform duration-500 ease-out ${isSearchOpen ? '-translate-x-full' : 'translate-x-0'}`}>
{/* Scrollable Content with pull-to-refresh */}
<div
ref={containerRef}
className="h-full overflow-y-auto custom-scrollbar relative pb-14"
style={{
transform: `translateY(${isPulling || isRefreshing ? pullDistance : 0}px)`,
transition: isPulling ? 'none' : 'transform 0.3s ease-out'
}}
>
{/* Pull-to-refresh indicator */}
{(isPulling || isRefreshing) && (
<div
className="absolute top-0 left-0 right-0 flex items-center justify-center bg-background z-50"
style={{
height: `${Math.max(pullDistance, isRefreshing ? 60 : 0)}px`,
transform: `translateY(-${Math.max(pullDistance, isRefreshing ? 60 : 0)}px)`
}}
>
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
<span className="text-sm">
{isRefreshing ? '새로고침 중...' : pullDistance > 60 ? '놓으면 새로고침' : '아래로 당겨서 새로고침'}
</span>
</div>
</div>
)}
{/* Articles feed */}
<section className="px-4 pt-4 pb-4 space-y-4">
{articlesLoading ? (
<div className="text-center py-8">
<p className="text-muted-foreground">Loading articles...</p>
</div>
) : (
<div className={viewMode === 'list' ? 'space-y-2' : 'space-y-4'}>
{(() => {
// For Palantir outlet, mix ads with articles like YouTube
if (outletId === 'palantir') {
const regularArticles = articlesData.filter((article: any) => article.isScraped !== 2);
const ads = articlesData.filter((article: any) => article.isScraped === 2);
const articlesWithAds = [];
let adIndex = 0;
// Insert articles and ads: add an ad after every 3 articles
for (let i = 0; i < regularArticles.length; i++) {
articlesWithAds.push({
...regularArticles[i],
isAd: false
});
// Insert ad after every 3 articles (and we have ads available)
if ((i + 1) % 3 === 0 && ads.length > 0) {
articlesWithAds.push({
...ads[adIndex % ads.length],
isAd: true
});
adIndex++;
}
}
return articlesWithAds.map((item: any, index: number) => (
<ArticleCard
key={item.id}
id={item.id}
title={item.title}
summary={item.summary}
thumbnail={item.thumbnail}
outletName={outletData?.name || 'Unknown Outlet'}
category={outletData?.category || 'uncategorized'}
publishedAt={new Date(item.publishedAt)}
timeAgo={item.timeAgo}
onClick={(id) => {
if (item.isAd) {
// For ads, open external URL instead of navigating to article
const newWindow = window.open('https://investing.com', '_blank', 'noopener,noreferrer');
if (newWindow) newWindow.opener = null;
} else {
const newsId = item.newsId || id;
setLocation(`/article/${newsId}?fromOutlet=${outletId}`);
}
}}
className="w-full"
isAd={item.isAd}
tags={item.tags || []}
index={index}
variant={viewMode}
/>
));
} else {
// For other outlets, show articles normally
return articlesData.map((article: any, index: number) => (
<ArticleCard
key={article.id}
id={article.id}
title={article.title}
summary={article.summary}
thumbnail={article.thumbnail}
outletName={outletData?.name || 'Unknown Outlet'}
category={outletData?.category || 'uncategorized'}
publishedAt={new Date(article.publishedAt)}
timeAgo={article.timeAgo}
onClick={(id) => {
const newsId = article.newsId || id;
setLocation(`/article/${newsId}?fromOutlet=${outletId}`);
}}
className="w-full"
tags={article.tags || []}
index={index}
variant={viewMode}
/>
));
}
})()}
</div>
)}
{articlesData.length === 0 && !articlesLoading && (
<div className="text-center py-8">
<p className="text-muted-foreground">No articles available yet.</p>
</div>
)}
</section>
</div>
</div>
{/* Search Overlay positioned to slide in from right */}
<SearchOverlay
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
onArticleClick={(id) => setLocation(`/article/${id}`)}
searchResults={searchResults as any}
onSearch={handleSearch}
/>
</div>
{/* Fixed Bottom Navigation Bar */}
<div className="flex-none">
<BottomTabBar variant="nav" onTitleClick={handleTitleClick} onBackClick={handleBackClick} />
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { Card, CardContent } from "@/components/ui/card";
import { AlertCircle } from "lucide-react";
export default function NotFound() {
return (
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md mx-4">
<CardContent className="pt-6">
<div className="flex mb-4 gap-2">
<AlertCircle className="h-8 w-8 text-red-500" />
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
</div>
<p className="mt-4 text-sm text-gray-600">
Did you forget to add the page to the router?
</p>
</CardContent>
</Card>
</div>
);
}