From 456459bb0032a5f96116604267c3b18a93cdf39e Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Tue, 30 Dec 2025 11:15:47 +0000 Subject: [PATCH] Update --- apps/admin/postcss.config.js | 6 + apps/admin/src/components/ProtectedRoute.tsx | 30 + .../src/components/ai/AIPolishButton.tsx | 56 ++ .../src/components/ai/AiGeneratorDialog.tsx | 281 ++++++++++ .../components/content/FixTargetsDialog.tsx | 242 +++++++++ .../admin/src/components/layout/AppLayout.tsx | 24 + .../src/components/layout/Breadcrumbs.tsx | 81 +++ apps/admin/src/components/layout/Sidebar.tsx | 170 ++++++ apps/admin/src/components/ui/avatar.tsx | 47 ++ apps/admin/src/components/ui/badge.tsx | 39 ++ apps/admin/src/components/ui/button.tsx | 55 ++ apps/admin/src/components/ui/card.tsx | 78 +++ apps/admin/src/components/ui/dialog.tsx | 119 ++++ apps/admin/src/components/ui/input.tsx | 24 + apps/admin/src/components/ui/label.tsx | 23 + apps/admin/src/components/ui/select.tsx | 160 ++++++ apps/admin/src/components/ui/separator.tsx | 28 + apps/admin/src/components/ui/skeleton.tsx | 15 + apps/admin/src/components/ui/switch.tsx | 26 + apps/admin/src/components/ui/table.tsx | 116 ++++ apps/admin/src/components/ui/tabs.tsx | 52 ++ apps/admin/src/components/ui/textarea.tsx | 23 + apps/admin/src/contexts/AuthContext.tsx | 163 ++++++ apps/admin/src/index.css | 60 ++ apps/admin/src/lib/openai.ts | 424 +++++++++++++++ apps/admin/src/lib/utils.ts | 6 + apps/admin/src/pages/AuditLogsPage.tsx | 139 +++++ apps/admin/src/pages/DashboardPage.tsx | 271 +++++++++ apps/admin/src/pages/LoginPage.tsx | 93 ++++ apps/admin/src/pages/admins/AdminsPage.tsx | 397 ++++++++++++++ .../src/pages/content/CategoriesPage.tsx | 371 +++++++++++++ apps/admin/src/pages/content/PacksPage.tsx | 481 ++++++++++++++++ .../admin/src/pages/content/QuestionsPage.tsx | 513 ++++++++++++++++++ apps/admin/src/pages/users/MatchChatPage.tsx | 359 ++++++++++++ apps/admin/src/pages/users/UserDetailPage.tsx | 486 +++++++++++++++++ apps/admin/src/pages/users/UsersPage.tsx | 189 +++++++ apps/admin/src/vite-env.d.ts | 11 + apps/admin/tailwind.config.js | 53 ++ .../20251230000001_security_hardening.sql | 175 ++++++ .../20251230000002_admin_grant_premium.sql | 17 + .../20251230110000_contextual_questions.sql | 115 ++++ 41 files changed, 6018 insertions(+) create mode 100644 apps/admin/postcss.config.js create mode 100644 apps/admin/src/components/ProtectedRoute.tsx create mode 100644 apps/admin/src/components/ai/AIPolishButton.tsx create mode 100644 apps/admin/src/components/ai/AiGeneratorDialog.tsx create mode 100644 apps/admin/src/components/content/FixTargetsDialog.tsx create mode 100644 apps/admin/src/components/layout/AppLayout.tsx create mode 100644 apps/admin/src/components/layout/Breadcrumbs.tsx create mode 100644 apps/admin/src/components/layout/Sidebar.tsx create mode 100644 apps/admin/src/components/ui/avatar.tsx create mode 100644 apps/admin/src/components/ui/badge.tsx create mode 100644 apps/admin/src/components/ui/button.tsx create mode 100644 apps/admin/src/components/ui/card.tsx create mode 100644 apps/admin/src/components/ui/dialog.tsx create mode 100644 apps/admin/src/components/ui/input.tsx create mode 100644 apps/admin/src/components/ui/label.tsx create mode 100644 apps/admin/src/components/ui/select.tsx create mode 100644 apps/admin/src/components/ui/separator.tsx create mode 100644 apps/admin/src/components/ui/skeleton.tsx create mode 100644 apps/admin/src/components/ui/switch.tsx create mode 100644 apps/admin/src/components/ui/table.tsx create mode 100644 apps/admin/src/components/ui/tabs.tsx create mode 100644 apps/admin/src/components/ui/textarea.tsx create mode 100644 apps/admin/src/contexts/AuthContext.tsx create mode 100644 apps/admin/src/index.css create mode 100644 apps/admin/src/lib/openai.ts create mode 100644 apps/admin/src/lib/utils.ts create mode 100644 apps/admin/src/pages/AuditLogsPage.tsx create mode 100644 apps/admin/src/pages/DashboardPage.tsx create mode 100644 apps/admin/src/pages/LoginPage.tsx create mode 100644 apps/admin/src/pages/admins/AdminsPage.tsx create mode 100644 apps/admin/src/pages/content/CategoriesPage.tsx create mode 100644 apps/admin/src/pages/content/PacksPage.tsx create mode 100644 apps/admin/src/pages/content/QuestionsPage.tsx create mode 100644 apps/admin/src/pages/users/MatchChatPage.tsx create mode 100644 apps/admin/src/pages/users/UserDetailPage.tsx create mode 100644 apps/admin/src/pages/users/UsersPage.tsx create mode 100644 apps/admin/src/vite-env.d.ts create mode 100644 apps/admin/tailwind.config.js create mode 100644 apps/supabase/migrations/20251230000001_security_hardening.sql create mode 100644 apps/supabase/migrations/20251230000002_admin_grant_premium.sql create mode 100644 apps/supabase/migrations/20251230110000_contextual_questions.sql diff --git a/apps/admin/postcss.config.js b/apps/admin/postcss.config.js new file mode 100644 index 0000000..d41ad63 --- /dev/null +++ b/apps/admin/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/admin/src/components/ProtectedRoute.tsx b/apps/admin/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..2af4462 --- /dev/null +++ b/apps/admin/src/components/ProtectedRoute.tsx @@ -0,0 +1,30 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; +import { Loader2 } from 'lucide-react'; + +interface ProtectedRouteProps { + children: React.ReactNode; + requireSuperAdmin?: boolean; +} + +export function ProtectedRoute({ children, requireSuperAdmin = false }: ProtectedRouteProps) { + const { session, loading, isSuperAdmin } = useAuth(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!session) { + return ; + } + + if (requireSuperAdmin && !isSuperAdmin) { + return ; + } + + return <>{children}; +} diff --git a/apps/admin/src/components/ai/AIPolishButton.tsx b/apps/admin/src/components/ai/AIPolishButton.tsx new file mode 100644 index 0000000..926b502 --- /dev/null +++ b/apps/admin/src/components/ai/AIPolishButton.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Sparkles, Loader2 } from 'lucide-react'; +import { polishContent } from '@/lib/openai'; +import { toast } from 'sonner'; + +interface AIPolishButtonProps { + text: string; + type: 'question' | 'partner_text' | 'pack_name' | 'pack_description' | 'category_name'; + onPolished: (newText: string) => void; + className?: string; + disabled?: boolean; + explicit?: boolean; +} + +export function AIPolishButton({ text, type, onPolished, className, disabled, explicit = false }: AIPolishButtonProps) { + const [loading, setLoading] = useState(false); + + const handlePolish = async (e: React.MouseEvent) => { + e.preventDefault(); // Prevent form submission + if (!text || !text.trim()) { + toast.error('Enter some text first to polish'); + return; + } + + setLoading(true); + try { + const polished = await polishContent(text, type, explicit); + onPolished(polished); + toast.success('Text polished!'); + } catch (error) { + console.error(error); + toast.error('Failed to polish text'); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} diff --git a/apps/admin/src/components/ai/AiGeneratorDialog.tsx b/apps/admin/src/components/ai/AiGeneratorDialog.tsx new file mode 100644 index 0000000..c1bf874 --- /dev/null +++ b/apps/admin/src/components/ai/AiGeneratorDialog.tsx @@ -0,0 +1,281 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Sparkles, Loader2, RefreshCw, Check } from 'lucide-react'; +import { generatePack, generateQuestions, suggestCategories, suggestPacks, type GeneratedPack, type GeneratedQuestion, type GeneratedCategoryIdea, type GeneratedPackIdea } from '@/lib/openai'; +import { Switch } from '@/components/ui/switch'; +import { toast } from 'sonner'; + +interface AiGeneratorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + type: 'pack' | 'questions' | 'category-ideas' | 'category-pack-ideas'; + context?: { + categoryName?: string; + packName?: string; + existingCategories?: string[]; + existingPacks?: string[]; + }; + onGenerated: (result: any) => void; +} + +export function AiGeneratorDialog({ + open, + onOpenChange, + type, + context, + onGenerated, +}: AiGeneratorDialogProps) { + const [loading, setLoading] = useState(false); + const [count, setCount] = useState(10); + const [isExplicit, setIsExplicit] = useState(false); + + // Generated results + const [generatedPack, setGeneratedPack] = useState(null); + const [generatedQuestions, setGeneratedQuestions] = useState([]); + const [generatedIdeas, setGeneratedIdeas] = useState([]); + const [generatedPackIdeas, setGeneratedPackIdeas] = useState([]); + + const handleGenerate = async () => { + setLoading(true); + try { + if (type === 'pack') { + const result = await generatePack(context?.categoryName, isExplicit); + setGeneratedPack(result); + } else if (type === 'questions') { + if (!context?.packName) { + toast.error('Pack name is required'); + return; + } + const result = await generateQuestions(context.packName, count, undefined, isExplicit); + setGeneratedQuestions(result); + } else if (type === 'category-ideas') { + const result = await suggestCategories(context?.existingCategories || [], isExplicit); + setGeneratedIdeas(result); + } else if (type === 'category-pack-ideas') { + if (!context?.categoryName) { + toast.error('Category name is required'); + return; + } + const result = await suggestPacks(context.categoryName, context?.existingPacks || [], isExplicit); + setGeneratedPackIdeas(result); + } + } catch (error) { + console.error('AI generation error:', error); + toast.error('Failed to generate content. Check your OpenAI API key.'); + } finally { + setLoading(false); + } + }; + + const handleUse = () => { + if (type === 'pack' && generatedPack) { + onGenerated(generatedPack); + } else if (type === 'questions' && generatedQuestions.length > 0) { + onGenerated(generatedQuestions); + } + }; + + const handleClose = () => { + // Reset state + setGeneratedPack(null); + setGeneratedQuestions([]); + setGeneratedIdeas([]); + setGeneratedPackIdeas([]); + onOpenChange(false); + }; + + return ( + + + + + + AI {type === 'pack' ? 'Pack' : type === 'questions' ? 'Question' : type === 'category-pack-ideas' ? 'Pack Ideas' : 'Category'} Generator + + + {type === 'pack' + ? 'Generate a creative question pack name and description using AI.' + : type === 'questions' + ? 'Generate multiple questions for this pack using AI.' + : type === 'category-pack-ideas' + ? 'Generate unique pack ideas for this category.' + : 'Get creative category ideas based on your existing ones.'} + + + +
+ {/* Configuration */} + {type === 'questions' && ( +
+ + setCount(parseInt(e.target.value) || 10)} + className="w-32" + /> +
+ )} + + {/* Explicit Toggle */} + {(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack' || type === 'questions') && ( +
+ + +
+ )} + + {/* Context info */} + {context?.categoryName && ( +
+ Category: {context.categoryName} +
+ )} + {context?.packName && ( +
+ Pack: {context.packName} +
+ )} + + {/* Generate button */} + {!generatedPack && generatedQuestions.length === 0 && generatedIdeas.length === 0 && generatedPackIdeas.length === 0 && ( + + )} + + {/* Generated Pack Preview */} + {generatedPack && ( +
+

Generated Pack

+
+

{generatedPack.name}

+

{generatedPack.description}

+
+
+ )} + + {/* Generated Questions Preview */} + {generatedQuestions.length > 0 && ( +
+

Generated Questions ({generatedQuestions.length})

+
+ {generatedQuestions.map((q, i) => ( +
+

{q.text}

+ {q.partner_text && ( +

+ Partner: {q.partner_text} +

+ )} + + {q.intensity} - {['Light', 'Mild', 'Moderate', 'Spicy', 'Very Intense'][q.intensity - 1]} + +
+ ))} +
+
+ )} + + {/* Generated Ideas Preview */} + {generatedIdeas.length > 0 && ( +
+

Suggested Categories

+
+ {generatedIdeas.map((idea, i) => ( +
onGenerated(idea)} + > +
+ {idea.icon} + {idea.name} +
+ +
+ ))} +
+

Click an item to use it

+
+ )} + + {/* Generated Pack Ideas Preview */} + {generatedPackIdeas.length > 0 && ( +
+

Suggested Packs

+
+ {generatedPackIdeas.map((idea, i) => ( +
onGenerated(idea)} + > +
+ {idea.icon} +
+ {idea.name} + {idea.description} +
+
+ +
+ ))} +
+

Click an item to use it

+
+ )} +
+ + + {(generatedPack || generatedQuestions.length > 0 || generatedIdeas.length > 0 || generatedPackIdeas.length > 0) && ( + <> + + {type !== 'category-ideas' && type !== 'category-pack-ideas' && ( + + )} + + )} + {!generatedPack && generatedQuestions.length === 0 && generatedIdeas.length === 0 && generatedPackIdeas.length === 0 && ( + + )} + +
+
+ ); +} diff --git a/apps/admin/src/components/content/FixTargetsDialog.tsx b/apps/admin/src/components/content/FixTargetsDialog.tsx new file mode 100644 index 0000000..dd8df85 --- /dev/null +++ b/apps/admin/src/components/content/FixTargetsDialog.tsx @@ -0,0 +1,242 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Loader2, ShieldCheck, ArrowRight, Check } from 'lucide-react'; +import { analyzeQuestionTargets, TargetAnalysis } from '@/lib/openai'; +import { supabase } from '@/config'; +import { toast } from 'sonner'; + +// Define local compatible interface +interface Question { + id: string; + text: string; + partner_text: string | null; + intensity: number; + allowed_couple_genders: string[] | null; +} + +interface FixTargetsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + questions: Question[]; + onUpdated: () => void; +} + +export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: FixTargetsDialogProps) { + const [analyzing, setAnalyzing] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [step, setStep] = useState<'initial' | 'review'>('initial'); + const [applying, setApplying] = useState(false); + + const handleAnalyze = async () => { + setAnalyzing(true); + try { + const results = await analyzeQuestionTargets(questions); + setSuggestions(results); + // Default select all + setSelectedIds(new Set(results.map(r => r.id))); + setStep('review'); + } catch (error) { + console.error(error); + toast.error('Failed to analyze questions'); + } finally { + setAnalyzing(false); + } + }; + + const handleApply = async () => { + setApplying(true); + try { + const updates = suggestions.filter(s => selectedIds.has(s.id)); + if (updates.length === 0) { + onOpenChange(false); + return; + } + + // Apply updates in parallel + await Promise.all(updates.map(update => + supabase + .from('questions') + .update({ allowed_couple_genders: update.suggested_targets && update.suggested_targets.length > 0 ? update.suggested_targets : null }) + .eq('id', update.id) + )); + + toast.success(`Updated ${updates.length} questions`); + onUpdated(); + handleClose(); + } catch (error) { + console.error(error); + toast.error('Failed to apply updates'); + } finally { + setApplying(false); + } + }; + + const handleClose = () => { + onOpenChange(false); + // Reset state after transition + setTimeout(() => { + setStep('initial'); + setSuggestions([]); + }, 300); + }; + + const toggleSelection = (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelectedIds(next); + }; + + const formatTargets = (targets: string[] | null) => { + if (!targets || targets.length === 0) return 'All'; + return targets.map(t => t.replace('male+male', 'M+M').replace('female+male', 'M+F').replace('female+female', 'F+F')).join(', '); + }; + + return ( + + + + + + Fix Question Targets + + + Use AI to detect anatomical or configuration constraints in your questions. + + + +
+ {step === 'initial' ? ( +
+
+ +
+
+

Ready to analyze {questions.length} questions

+

+ The AI will look for anatomical requirements (e.g., acts requiring specific genitalia) and suggest target restrictions. +

+
+ +
+ ) : ( +
+ {suggestions.length === 0 ? ( +
+ + No issues found! All questions seem to have appropriate targets. +
+ ) : ( +
+ + + + + 0} + onChange={(e) => { + if (e.target.checked) setSelectedIds(new Set(suggestions.map(s => s.id))); + else setSelectedIds(new Set()); + }} + /> + + Question + Current + Suggested + Reason + + + + {suggestions.map((suggestion) => { + const question = questions.find(q => q.id === suggestion.id); + if (!question) return null; + return ( + + + toggleSelection(suggestion.id)} + /> + + +
{question.text}
+ {question.partner_text && ( +
+ Partner: {question.partner_text} +
+ )} +
+ + + {formatTargets(question.allowed_couple_genders)} + + + +
+ + + {formatTargets(suggestion.suggested_targets)} + +
+
+ + {suggestion.reason} + +
+ ); + })} +
+
+
+ )} +
+ )} +
+ + + {step === 'review' && ( + <> + + + + )} + +
+
+ ); +} diff --git a/apps/admin/src/components/layout/AppLayout.tsx b/apps/admin/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..25ef03e --- /dev/null +++ b/apps/admin/src/components/layout/AppLayout.tsx @@ -0,0 +1,24 @@ +import { Outlet } from 'react-router-dom'; +import { Sidebar } from './Sidebar'; +import { Breadcrumbs } from './Breadcrumbs'; +import { Toaster } from 'sonner'; + +export function AppLayout() { + return ( +
+ +
+ {/* Header */} +
+ +
+ + {/* Main content */} +
+ +
+
+ +
+ ); +} diff --git a/apps/admin/src/components/layout/Breadcrumbs.tsx b/apps/admin/src/components/layout/Breadcrumbs.tsx new file mode 100644 index 0000000..ff451af --- /dev/null +++ b/apps/admin/src/components/layout/Breadcrumbs.tsx @@ -0,0 +1,81 @@ +import { Link, useLocation, useParams } from 'react-router-dom'; +import { ChevronRight, Home } from 'lucide-react'; + +interface BreadcrumbItem { + label: string; + href?: string; +} + +// Generate breadcrumbs based on current path and params +function useBreadcrumbs(): BreadcrumbItem[] { + const location = useLocation(); + const params = useParams(); + + const pathSegments = location.pathname.split('/').filter(Boolean); + const breadcrumbs: BreadcrumbItem[] = []; + + let currentPath = ''; + for (const segment of pathSegments) { + currentPath += `/${segment}`; + + // Map segments to readable labels + if (segment === 'categories') { + breadcrumbs.push({ label: 'Categories', href: '/categories' }); + } else if (segment === 'packs' && params.categoryId) { + breadcrumbs.push({ label: 'Packs', href: currentPath }); + } else if (segment === 'questions' && params.packId) { + breadcrumbs.push({ label: 'Questions', href: currentPath }); + } else if (segment === 'users') { + breadcrumbs.push({ label: 'Users', href: '/users' }); + } else if (segment === 'matches' && params.userId) { + breadcrumbs.push({ label: 'Match Chat' }); + } else if (segment === 'audit-logs') { + breadcrumbs.push({ label: 'Audit Logs', href: '/audit-logs' }); + } else if (segment === 'new') { + breadcrumbs.push({ label: 'Create New' }); + } else if (segment === 'edit') { + breadcrumbs.push({ label: 'Edit' }); + } + // Skip UUID segments as they're handled with their parent + } + + // Remove href from last item (current page) + if (breadcrumbs.length > 0) { + breadcrumbs[breadcrumbs.length - 1].href = undefined; + } + + return breadcrumbs; +} + +export function Breadcrumbs() { + const breadcrumbs = useBreadcrumbs(); + + if (breadcrumbs.length === 0) return null; + + return ( + + ); +} diff --git a/apps/admin/src/components/layout/Sidebar.tsx b/apps/admin/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..5a9694f --- /dev/null +++ b/apps/admin/src/components/layout/Sidebar.tsx @@ -0,0 +1,170 @@ +import { Link, useLocation } from 'react-router-dom'; +import { cn } from '@/lib/utils'; +import { useAuth } from '@/contexts/AuthContext'; +import { + LayoutGrid, + Users, + ClipboardList, + ChevronLeft, + ChevronRight, + LogOut, + Sparkles, + Shield, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { useState } from 'react'; + +interface NavItem { + label: string; + href: string; + icon: React.ReactNode; + requireSuperAdmin?: boolean; +} + +const contentNav: NavItem[] = [ + { + label: 'Categories', + href: '/categories', + icon: , + }, +]; + +const usersNav: NavItem[] = [ + { + label: 'Users', + href: '/users', + icon: , + requireSuperAdmin: true, + }, +]; + +const systemNav: NavItem[] = [ + { + label: 'Admins', + href: '/admins', + icon: , + requireSuperAdmin: true, + }, + { + label: 'Audit Logs', + href: '/audit-logs', + icon: , + requireSuperAdmin: true, + }, +]; + +export function Sidebar() { + const location = useLocation(); + const { user, permissions, logout, isSuperAdmin } = useAuth(); + const [collapsed, setCollapsed] = useState(false); + + const NavLink = ({ item }: { item: NavItem }) => { + if (item.requireSuperAdmin && !isSuperAdmin) return null; + + const isActive = location.pathname.startsWith(item.href); + + return ( + + {item.icon} + {!collapsed && {item.label}} + + ); + }; + + const NavSection = ({ title, items }: { title: string; items: NavItem[] }) => { + const visibleItems = items.filter(item => !item.requireSuperAdmin || isSuperAdmin); + if (visibleItems.length === 0) return null; + + return ( +
+ {!collapsed && ( +

+ {title} +

+ )} + {visibleItems.map(item => ( + + ))} +
+ ); + }; + + return ( +
+ {/* Logo */} +
+ {!collapsed && ( +
+ + Sauci Admin +
+ )} + +
+ + {/* Navigation */} +
+ + + +
+ + + + {/* User info */} +
+
+ + + {user?.email?.charAt(0).toUpperCase() || 'A'} + + + {!collapsed && ( +
+

{user?.email}

+

+ {permissions?.role?.replace('_', ' ')} +

+
+ )} +
+ +
+
+ ); +} diff --git a/apps/admin/src/components/ui/avatar.tsx b/apps/admin/src/components/ui/avatar.tsx new file mode 100644 index 0000000..c466fd9 --- /dev/null +++ b/apps/admin/src/components/ui/avatar.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/admin/src/components/ui/badge.tsx b/apps/admin/src/components/ui/badge.tsx new file mode 100644 index 0000000..e914e79 --- /dev/null +++ b/apps/admin/src/components/ui/badge.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full 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", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + success: + "border-transparent bg-green-500 text-white hover:bg-green-500/80", + warning: + "border-transparent bg-yellow-500 text-white hover:bg-yellow-500/80", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps { } + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/apps/admin/src/components/ui/button.tsx b/apps/admin/src/components/ui/button.tsx new file mode 100644 index 0000000..48bdda9 --- /dev/null +++ b/apps/admin/src/components/ui/button.tsx @@ -0,0 +1,55 @@ +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 whitespace-nowrap rounded-md text-sm font-medium 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", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/apps/admin/src/components/ui/card.tsx b/apps/admin/src/components/ui/card.tsx new file mode 100644 index 0000000..12432ec --- /dev/null +++ b/apps/admin/src/components/ui/card.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/admin/src/components/ui/dialog.tsx b/apps/admin/src/components/ui/dialog.tsx new file mode 100644 index 0000000..58628ac --- /dev/null +++ b/apps/admin/src/components/ui/dialog.tsx @@ -0,0 +1,119 @@ +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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/admin/src/components/ui/input.tsx b/apps/admin/src/components/ui/input.tsx new file mode 100644 index 0000000..c638960 --- /dev/null +++ b/apps/admin/src/components/ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export interface InputProps + extends React.InputHTMLAttributes { } + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = "Input"; + +export { Input }; diff --git a/apps/admin/src/components/ui/label.tsx b/apps/admin/src/components/ui/label.tsx new file mode 100644 index 0000000..2296887 --- /dev/null +++ b/apps/admin/src/components/ui/label.tsx @@ -0,0 +1,23 @@ +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, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/admin/src/components/ui/select.tsx b/apps/admin/src/components/ui/select.tsx new file mode 100644 index 0000000..7b3d187 --- /dev/null +++ b/apps/admin/src/components/ui/select.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/apps/admin/src/components/ui/separator.tsx b/apps/admin/src/components/ui/separator.tsx new file mode 100644 index 0000000..67005c0 --- /dev/null +++ b/apps/admin/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "@/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/admin/src/components/ui/skeleton.tsx b/apps/admin/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..c5c1274 --- /dev/null +++ b/apps/admin/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/apps/admin/src/components/ui/switch.tsx b/apps/admin/src/components/ui/switch.tsx new file mode 100644 index 0000000..7d2fec7 --- /dev/null +++ b/apps/admin/src/components/ui/switch.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import { cn } from "@/lib/utils"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/apps/admin/src/components/ui/table.tsx b/apps/admin/src/components/ui/table.tsx new file mode 100644 index 0000000..d6eafce --- /dev/null +++ b/apps/admin/src/components/ui/table.tsx @@ -0,0 +1,116 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/apps/admin/src/components/ui/tabs.tsx b/apps/admin/src/components/ui/tabs.tsx new file mode 100644 index 0000000..7a5693f --- /dev/null +++ b/apps/admin/src/components/ui/tabs.tsx @@ -0,0 +1,52 @@ +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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/admin/src/components/ui/textarea.tsx b/apps/admin/src/components/ui/textarea.tsx new file mode 100644 index 0000000..d2759ca --- /dev/null +++ b/apps/admin/src/components/ui/textarea.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export interface TextareaProps + extends React.TextareaHTMLAttributes { } + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +