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

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 }