This commit is contained in:
jjbrunton committed 2025-12-30 11:15:47 +00:00
1 parent a15089f607
commit 456459bb00
41 files changed
+6018

No files matched your search

@@ -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 (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!session) {
return <Navigate to="/login" replace />;
}
if (requireSuperAdmin && !isSuperAdmin) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
@@ -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 (
<Button
variant="ghost"
size="icon"
className={className}
onClick={handlePolish}
disabled={loading || disabled || !text}
type="button"
title="Polish with AI"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4 text-purple-500" />
)}
</Button>
);
}
@@ -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<GeneratedPack | null>(null);
const [generatedQuestions, setGeneratedQuestions] = useState<GeneratedQuestion[]>([]);
const [generatedIdeas, setGeneratedIdeas] = useState<GeneratedCategoryIdea[]>([]);
const [generatedPackIdeas, setGeneratedPackIdeas] = useState<GeneratedPackIdea[]>([]);
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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI {type === 'pack' ? 'Pack' : type === 'questions' ? 'Question' : type === 'category-pack-ideas' ? 'Pack Ideas' : 'Category'} Generator
</DialogTitle>
<DialogDescription>
{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.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Configuration */}
{type === 'questions' && (
<div className="space-y-2">
<Label htmlFor="count">Number of Questions</Label>
<Input
id="count"
type="number"
min={1}
max={50}
value={count}
onChange={(e) => setCount(parseInt(e.target.value) || 10)}
className="w-32"
/>
</div>
)}
{/* Explicit Toggle */}
{(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack' || type === 'questions') && (
<div className="flex items-center space-x-2">
<Switch
id="explicit-mode"
checked={isExplicit}
onCheckedChange={setIsExplicit}
/>
<Label htmlFor="explicit-mode">Suggest Explicit Ideas</Label>
</div>
)}
{/* Context info */}
{context?.categoryName && (
<div className="rounded-md bg-muted p-3 text-sm">
<strong>Category:</strong> {context.categoryName}
</div>
)}
{context?.packName && (
<div className="rounded-md bg-muted p-3 text-sm">
<strong>Pack:</strong> {context.packName}
</div>
)}
{/* Generate button */}
{!generatedPack && generatedQuestions.length === 0 && generatedIdeas.length === 0 && generatedPackIdeas.length === 0 && (
<Button onClick={handleGenerate} disabled={loading} className="w-full">
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Generate with AI
</>
)}
</Button>
)}
{/* Generated Pack Preview */}
{generatedPack && (
<div className="space-y-3">
<h4 className="font-medium">Generated Pack</h4>
<div className="rounded-lg border bg-card p-4 space-y-2">
<p className="font-semibold text-lg">{generatedPack.name}</p>
<p className="text-muted-foreground">{generatedPack.description}</p>
</div>
</div>
)}
{/* Generated Questions Preview */}
{generatedQuestions.length > 0 && (
<div className="space-y-3">
<h4 className="font-medium">Generated Questions ({generatedQuestions.length})</h4>
<div className="space-y-2 max-h-64 overflow-y-auto">
{generatedQuestions.map((q, i) => (
<div key={i} className="rounded-lg border bg-card p-3 text-sm">
<p>{q.text}</p>
{q.partner_text && (
<p className="text-muted-foreground text-xs mt-1">
Partner: {q.partner_text}
</p>
)}
<span className="inline-block mt-1 text-xs px-2 py-0.5 rounded bg-muted">
{q.intensity} - {['Light', 'Mild', 'Moderate', 'Spicy', 'Very Intense'][q.intensity - 1]}
</span>
</div>
))}
</div>
</div>
)}
{/* Generated Ideas Preview */}
{generatedIdeas.length > 0 && (
<div className="space-y-3">
<h4 className="font-medium">Suggested Categories</h4>
<div className="grid grid-cols-1 gap-2">
{generatedIdeas.map((idea, i) => (
<div
key={i}
className="flex items-center justify-between rounded-lg border bg-card p-3 hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => onGenerated(idea)}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{idea.icon}</span>
<span className="font-medium">{idea.name}</span>
</div>
<Button size="sm" variant="ghost">Use</Button>
</div>
))}
</div>
<p className="text-xs text-muted-foreground text-center">Click an item to use it</p>
</div>
)}
{/* Generated Pack Ideas Preview */}
{generatedPackIdeas.length > 0 && (
<div className="space-y-3">
<h4 className="font-medium">Suggested Packs</h4>
<div className="grid grid-cols-1 gap-2">
{generatedPackIdeas.map((idea, i) => (
<div
key={i}
className="flex items-center justify-between rounded-lg border bg-card p-3 hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => onGenerated(idea)}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{idea.icon}</span>
<div>
<span className="font-medium block">{idea.name}</span>
<span className="text-xs text-muted-foreground">{idea.description}</span>
</div>
</div>
<Button size="sm" variant="ghost">Use</Button>
</div>
))}
</div>
<p className="text-xs text-muted-foreground text-center">Click an item to use it</p>
</div>
)}
</div>
<DialogFooter>
{(generatedPack || generatedQuestions.length > 0 || generatedIdeas.length > 0 || generatedPackIdeas.length > 0) && (
<>
<Button variant="outline" onClick={handleGenerate} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Regenerate
</Button>
{type !== 'category-ideas' && type !== 'category-pack-ideas' && (
<Button onClick={handleUse}>
<Check className="mr-2 h-4 w-4" />
Use This
</Button>
)}
</>
)}
{!generatedPack && generatedQuestions.length === 0 && generatedIdeas.length === 0 && generatedPackIdeas.length === 0 && (
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<TargetAnalysis[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-indigo-500" />
Fix Question Targets
</DialogTitle>
<DialogDescription>
Use AI to detect anatomical or configuration constraints in your questions.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
{step === 'initial' ? (
<div className="flex flex-col items-center justify-center py-12 text-center space-y-4">
<div className="bg-indigo-50 p-4 rounded-full">
<ShieldCheck className="h-12 w-12 text-indigo-500" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
<p className="text-muted-foreground max-w-md mx-auto">
The AI will look for anatomical requirements (e.g., acts requiring specific genitalia) and suggest target restrictions.
</p>
</div>
<Button onClick={handleAnalyze} disabled={analyzing} size="lg">
{analyzing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyzing...
</>
) : (
'Start Analysis'
)}
</Button>
</div>
) : (
<div className="space-y-4">
{suggestions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Check className="h-8 w-8 mx-auto mb-2 text-green-500" />
No issues found! All questions seem to have appropriate targets.
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 accent-primary"
checked={selectedIds.size === suggestions.length && suggestions.length > 0}
onChange={(e) => {
if (e.target.checked) setSelectedIds(new Set(suggestions.map(s => s.id)));
else setSelectedIds(new Set());
}}
/>
</TableHead>
<TableHead>Question</TableHead>
<TableHead>Current</TableHead>
<TableHead>Suggested</TableHead>
<TableHead>Reason</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suggestions.map((suggestion) => {
const question = questions.find(q => q.id === suggestion.id);
if (!question) return null;
return (
<TableRow key={suggestion.id}>
<TableCell>
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 accent-primary"
checked={selectedIds.has(suggestion.id)}
onChange={() => toggleSelection(suggestion.id)}
/>
</TableCell>
<TableCell className="max-w-xs">
<div className="line-clamp-2 text-sm font-medium">{question.text}</div>
{question.partner_text && (
<div className="text-xs text-muted-foreground mt-1 line-clamp-1">
Partner: {question.partner_text}
</div>
)}
</TableCell>
<TableCell className="text-xs whitespace-nowrap">
<Badge variant="outline" className="text-muted-foreground">
{formatTargets(question.allowed_couple_genders)}
</Badge>
</TableCell>
<TableCell className="text-xs whitespace-nowrap">
<div className="flex items-center gap-1">
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge variant="secondary" className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100">
{formatTargets(suggestion.suggested_targets)}
</Badge>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{suggestion.reason}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
)}
</div>
<DialogFooter>
{step === 'review' && (
<>
<Button variant="outline" onClick={() => setStep('initial')}>
Back
</Button>
<Button onClick={handleApply} disabled={applying || suggestions.length === 0 || selectedIds.size === 0}>
{applying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Applying...
</>
) : (
`Apply ${selectedIds.size} Fixes`
)}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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 (
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<header className="flex h-16 items-center border-b px-6">
<Breadcrumbs />
</header>
{/* Main content */}
<main className="flex-1 overflow-auto p-6">
<Outlet />
</main>
</div>
<Toaster position="top-right" richColors />
</div>
);
}
@@ -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 (
<nav className="flex items-center space-x-1 text-sm text-muted-foreground">
<Link
to="/"
className="flex items-center hover:text-foreground transition-colors"
>
<Home className="h-4 w-4" />
</Link>
{breadcrumbs.map((item, index) => (
<div key={index} className="flex items-center">
<ChevronRight className="h-4 w-4 mx-1" />
{item.href ? (
<Link
to={item.href}
className="hover:text-foreground transition-colors"
>
{item.label}
</Link>
) : (
<span className="text-foreground font-medium">{item.label}</span>
)}
</div>
))}
</nav>
);
}
@@ -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: <LayoutGrid className="h-5 w-5" />,
},
];
const usersNav: NavItem[] = [
{
label: 'Users',
href: '/users',
icon: <Users className="h-5 w-5" />,
requireSuperAdmin: true,
},
];
const systemNav: NavItem[] = [
{
label: 'Admins',
href: '/admins',
icon: <Shield className="h-5 w-5" />,
requireSuperAdmin: true,
},
{
label: 'Audit Logs',
href: '/audit-logs',
icon: <ClipboardList className="h-5 w-5" />,
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 (
<Link
to={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
collapsed && "justify-center px-2"
)}
>
{item.icon}
{!collapsed && <span>{item.label}</span>}
</Link>
);
};
const NavSection = ({ title, items }: { title: string; items: NavItem[] }) => {
const visibleItems = items.filter(item => !item.requireSuperAdmin || isSuperAdmin);
if (visibleItems.length === 0) return null;
return (
<div className="space-y-1">
{!collapsed && (
<h4 className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</h4>
)}
{visibleItems.map(item => (
<NavLink key={item.href} item={item} />
))}
</div>
);
};
return (
<div
className={cn(
"flex h-screen flex-col border-r bg-card transition-all duration-300",
collapsed ? "w-16" : "w-64"
)}
>
{/* Logo */}
<div className="flex h-16 items-center justify-between border-b px-4">
{!collapsed && (
<div className="flex items-center gap-2">
<Sparkles className="h-6 w-6 text-primary" />
<span className="text-lg font-bold">Sauci Admin</span>
</div>
)}
<Button
variant="ghost"
size="icon"
onClick={() => setCollapsed(!collapsed)}
className={cn(collapsed && "mx-auto")}
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
</div>
{/* Navigation */}
<div className="flex-1 space-y-6 overflow-auto p-4">
<NavSection title="Content" items={contentNav} />
<NavSection title="Users" items={usersNav} />
<NavSection title="System" items={systemNav} />
</div>
<Separator />
{/* User info */}
<div className={cn("p-4", collapsed && "flex flex-col items-center")}>
<div className={cn("flex items-center gap-3", collapsed && "flex-col")}>
<Avatar className="h-8 w-8">
<AvatarFallback>
{user?.email?.charAt(0).toUpperCase() || 'A'}
</AvatarFallback>
</Avatar>
{!collapsed && (
<div className="flex-1 overflow-hidden">
<p className="truncate text-sm font-medium">{user?.email}</p>
<p className="text-xs text-muted-foreground capitalize">
{permissions?.role?.replace('_', ' ')}
</p>
</div>
)}
</div>
<Button
variant="ghost"
size={collapsed ? "icon" : "sm"}
className={cn("mt-3", collapsed ? "w-8 h-8" : "w-full")}
onClick={logout}
>
<LogOut className="h-4 w-4" />
{!collapsed && <span className="ml-2">Logout</span>}
</Button>
</div>
</div>
);
}
+47
View File
@@ -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<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"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", 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 };
+39
View File
@@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+55
View File
@@ -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<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 };
+78
View File
@@ -0,0 +1,78 @@
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(
"rounded-lg border bg-card 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<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
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 };
+119
View File
@@ -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<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,
};
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> { }
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium 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",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
+23
View File
@@ -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<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 };
+160
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-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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-96 min-w-[8rem] overflow-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",
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,
}
@@ -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<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 };
+15
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 };
+26
View File
@@ -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<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 };
+116
View File
@@ -0,0 +1,116 @@
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,
};
+52
View File
@@ -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<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 };
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ 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-sm 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",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };
+163
View File
@@ -0,0 +1,163 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { supabase } from '../config';
import type { User, Session } from '@supabase/supabase-js';
// Define admin roles
export type AdminRole = 'pack_creator' | 'super_admin';
// Permission structure
export interface AdminPermissions {
role: AdminRole;
permissions: Array<{ action: string; resource: string }>;
}
// Define permissions by role
const ROLE_PERMISSIONS: Record<AdminRole, Array<{ action: string; resource: string }>> = {
pack_creator: [
{ action: 'list', resource: 'categories' },
{ action: 'create', resource: 'categories' },
{ action: 'edit', resource: 'categories' },
{ action: 'list', resource: 'question_packs' },
{ action: 'create', resource: 'question_packs' },
{ action: 'edit', resource: 'question_packs' },
{ action: 'delete', resource: 'question_packs' },
{ action: 'list', resource: 'questions' },
{ action: 'create', resource: 'questions' },
{ action: 'edit', resource: 'questions' },
{ action: 'delete', resource: 'questions' },
],
super_admin: [
// Full access to all resources
{ action: '*', resource: '*' },
],
};
interface AuthContextType {
user: User | null;
session: Session | null;
permissions: AdminPermissions | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
canAccess: (resource: string, action: string) => boolean;
isSuperAdmin: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const PERMISSIONS_KEY = 'admin_permissions';
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [permissions, setPermissions] = useState<AdminPermissions | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
// Restore permissions from localStorage
const storedPermissions = localStorage.getItem(PERMISSIONS_KEY);
if (storedPermissions) {
setPermissions(JSON.parse(storedPermissions));
}
setLoading(false);
});
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
setUser(session?.user ?? null);
if (!session) {
setPermissions(null);
localStorage.removeItem(PERMISSIONS_KEY);
}
}
);
return () => subscription.unsubscribe();
}, []);
const login = async (email: string, password: string) => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
throw new Error(error.message);
}
// Fetch admin role
const { data: adminData, error: adminError } = await supabase
.from('admin_users')
.select('role')
.eq('user_id', data.user.id)
.single();
if (adminError || !adminData) {
await supabase.auth.signOut();
throw new Error('Access denied. You are not an administrator.');
}
// Store permissions
const perms: AdminPermissions = {
role: adminData.role as AdminRole,
permissions: ROLE_PERMISSIONS[adminData.role as AdminRole],
};
localStorage.setItem(PERMISSIONS_KEY, JSON.stringify(perms));
setPermissions(perms);
};
const logout = async () => {
localStorage.removeItem(PERMISSIONS_KEY);
setPermissions(null);
await supabase.auth.signOut();
};
const canAccess = (resource: string, action: string): boolean => {
if (!permissions) return false;
// Check for wildcard (super_admin)
if (permissions.permissions.some(p => p.action === '*' && p.resource === '*')) {
return true;
}
// Check specific permission
return permissions.permissions.some(
p => p.resource === resource && (p.action === action || p.action === '*')
);
};
const isSuperAdmin = permissions?.role === 'super_admin';
return (
<AuthContext.Provider value={{
user,
session,
permissions,
loading,
login,
logout,
canAccess,
isSuperAdmin,
}}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
+60
View File
@@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 346.8 77.2% 49.8%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 346.8 77.2% 49.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
+424
View File
@@ -0,0 +1,424 @@
import OpenAI from 'openai';
// Note: In production, this should be called from a backend/edge function
// to avoid exposing the API key
const getOpenAI = () => {
const openRouterKey = import.meta.env.VITE_OPENROUTER_API_KEY;
const openAIKey = import.meta.env.VITE_OPENAI_API_KEY;
if (openRouterKey) {
return new OpenAI({
apiKey: openRouterKey,
baseURL: 'https://openrouter.ai/api/v1',
dangerouslyAllowBrowser: true,
});
}
if (!openAIKey) {
throw new Error('VITE_OPENAI_API_KEY or VITE_OPENROUTER_API_KEY is missing');
}
return new OpenAI({
apiKey: openAIKey,
dangerouslyAllowBrowser: true,
});
};
const getModel = () => {
return import.meta.env.VITE_AI_MODEL || 'gpt-4o-mini';
};
export interface GeneratedPack {
name: string;
description: string;
}
export interface GeneratedQuestion {
text: string;
partner_text?: string;
intensity: number;
}
export async function generatePack(categoryName?: string, explicit: boolean = false): Promise<GeneratedPack> {
const openai = getOpenAI();
const explicitInstruction = explicit
? 'Include bold, spicy, and uncensored themes (NSFW is allowed).'
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
const prompt = categoryName
? `Generate a creative question pack for a couples' intimacy/connection app in the category "${categoryName}".
The pack should help couples explore their relationship and desires together.
${explicitInstruction}
Return a JSON object with:
- name: A catchy, engaging pack name (3-6 words)
- description: A brief, enticing description (1-2 sentences)
Make it romantic, playful, and relationship-focused.`
: `Generate a creative question pack for a couples' intimacy/connection app.
The pack should help couples explore their relationship and desires together.
${explicitInstruction}
Return a JSON object with:
- name: A catchy, engaging pack name (3-6 words)
- description: A brief, enticing description (1-2 sentences)
Make it romantic, playful, and relationship-focused.`;
const response = await openai.chat.completions.create({
model: getModel(),
messages: [
{
role: 'system',
content: 'You are a creative content writer for a couples relationship app. Generate engaging, tasteful content that helps couples connect. Always respond with valid JSON only.',
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.8,
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
return JSON.parse(content) as GeneratedPack;
}
export async function generateQuestions(
packName: string,
count: number = 10,
intensity?: number,
explicit: boolean = false
): Promise<GeneratedQuestion[]> {
const openai = getOpenAI();
const intensityGuide = intensity
? `All questions should be at intensity level ${intensity} (${['Light & Playful', 'Mildly Intimate', 'Moderately Intimate', 'Spicy & Bold', 'Very Intense'][intensity - 1]}).`
: 'Vary the intensity levels from 1 (light/playful) to 5 (very intimate/bold) across the questions.';
const explicitInstruction = explicit
? 'CRITICAL: The user has requested EXPLICIT, NSFW content. You MUST include graphic, crude, and direct sexual terminology. Do not euphemize or be vague. This is for an adult-only app. Failure to be explicitly descriptive is a failure of the task. Be bold, spicy, and uncensored.'
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
const prompt = `Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".
${intensityGuide}
${explicitInstruction}
IMPORTANT: The app uses a swipe-based interface (Like/Dislike/Maybe).
Cards should be "Proposals" relative to a specific action, rather than interview questions.
For each "question", strictly decide if it is a SYMMETRIC activity (shared) or ASYMMETRIC action (one-way).
1. SYMMETRIC Activities (Single Card):
- Use for shared experiences where both partners do the same thing together.
- Phrasing: Direct action proposal or simple "Would you like to...".
- GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together".
- BAD: "Have you ever thought about...", "Do you think we should...", "How about we...".
- Structure:
- text: The action proposal (e.g., "Watch a steamy movie together").
- partner_text: null.
2. ASYMMETRIC Actions (Two-Part Card):
- Use for actions where one partner does something TO the other.
- text (The Doer): Active command/proposal (e.g., "Tie your partner up", "Give your partner a massage").
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Be tied up by your partner", "Receive a massage from your partner").
- GOOD: "Spank your partner" / "Be spanked by your partner".
- BAD: "Would you like to spank me?" (Ambiguous who "me" is).
Required JSON structure:
- text: The question/proposal text (see above).
- partner_text: (optional) The partner-facing text for two-part cards.
- intensity: A number 1-5 indicating how intimate/bold the question is
Return a JSON object with a "questions" array containing these question objects.
Mix distinct symmetric and asymmetric proposals.`;
const response = await openai.chat.completions.create({
model: getModel(),
messages: [
{
role: 'system',
content: explicit
? 'You are an uninhibited content writer for an adult couples app. Generate raw, explicit, and direct content that explores kinks and desires. Do not filter for politeness. Always respond with valid JSON only.'
: 'You are a creative content writer for a couples relationship app. Generate engaging, tasteful questions that help couples explore their desires and deepen their connection. Always respond with valid JSON only.',
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
const parsed = JSON.parse(content);
return parsed.questions as GeneratedQuestion[];
}
export async function generateCategory(): Promise<{ name: string; description: string; icon: string }> {
const openai = getOpenAI();
const prompt = `Generate a unique category for organizing question packs in a couples' intimacy/connection app.
Return a JSON object with:
- name: Category name (1-3 words, e.g., "Romance", "Adventure", "Communication")
- description: Brief description of what packs in this category explore (1 sentence)
- icon: A single emoji that represents this category
Be creative and think of categories that help couples explore different aspects of their relationship.`;
const response = await openai.chat.completions.create({
model: getModel(),
messages: [
{
role: 'system',
content: 'You are a creative content organizer for a couples relationship app. Generate engaging category ideas. Always respond with valid JSON only.',
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
return JSON.parse(content);
}
export interface GeneratedCategoryIdea {
name: string;
icon: string;
}
export async function suggestCategories(
existingCategories: string[],
explicit: boolean
): Promise<GeneratedCategoryIdea[]> {
const openai = getOpenAI();
const existingList = existingCategories.length > 0
? existingCategories.join(', ')
: 'None';
const explicitInstruction = explicit
? 'Include bold, spicy, and explicitly intimate categories (NSFW is allowed).'
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
const prompt = `Here are the current categories in our couples' question app: ${existingList}.
Suggest 5 NEW, UNIQUE category ideas that differ from the existing ones.
${explicitInstruction}
Return a JSON object with an "ideas" array containing 5 objects, where each object has:
- name: Category name (1-3 words)
- icon: A single descriptive emoji
Focus on diverse topics like communications, future planning, fun, conflict resolution, intimacy, etc.`;
const response = await openai.chat.completions.create({
model: getModel(),
messages: [
{
role: 'system',
content: 'You are a creative content strategist for a couples relationship app. Generate engaging, diverse category ideas. Always respond with valid JSON only.',
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
const parsed = JSON.parse(content);
return parsed.ideas as GeneratedCategoryIdea[];
}
export interface GeneratedPackIdea {
name: string;
description: string;
icon: string;
}
export async function suggestPacks(
categoryName: string,
existingPacks: string[],
explicit: boolean
): Promise<GeneratedPackIdea[]> {
const openai = getOpenAI();
const existingList = existingPacks.length > 0
? existingPacks.join(', ')
: 'None';
const explicitInstruction = explicit
? 'Include bold, spicy, and uncensored pack ideas (NSFW is allowed).'
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
const prompt = `We are building question packs for the category "${categoryName}" in a couples' app.
Existing packs in this category: ${existingList}.
Suggest 5 NEW, UNIQUE pack ideas that fit this category and differ from existing ones.
${explicitInstruction}
Return a JSON object with an "ideas" array containing 5 objects, where each object has:
- name: Pack name (catchy, 3-6 words)
- description: Brief description (1-2 sentences)
- icon: A single descriptive emoji
Make them engaging and specific to "${categoryName}".`;
const response = await openai.chat.completions.create({
model: getModel(),
messages: [
{
role: 'system',
content: 'You are a creative content strategist for a couples relationship app. Generate engaging pack ideas. Always respond with valid JSON only.',
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.9,
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
const parsed = JSON.parse(content);
return parsed.ideas as GeneratedPackIdea[];
}
export async function polishContent(
text: string,
type: 'question' | 'partner_text' | 'pack_name' | 'pack_description' | 'category_name',
explicit: boolean = false
): Promise<string> {
const openai = getOpenAI();
const explicitInstruction = explicit
? 'Do not censor yourself. If the content is already explicit, keep it that way or enhance usage of bolder terms if appropriate.'
: 'Keep it clean, romantic, and playful. Do NOT add explicit content if it was not there.';
const contextMap = {
question: 'a question for a couples app',
partner_text: 'the partner-facing text for a two-part question',
pack_name: 'a title for a question pack',
pack_description: 'a description for a question pack',
category_name: 'a category name',
};
let additionalRules = '';
if (type === 'question' || type === 'partner_text') {
additionalRules = `
IMPORTANT RULES - READ CAREFULLY:
1. STYLE: Cards should be "Proposals" relative to a specific action, rather than interview questions.
- GOOD: "Have a candlelit dinner with your partner", "Give your partner a massage".
- BAD: "Do you want to have...", "Have you ever thought about...".
2. ACCURACY: Do NOT rewrite the text effectively changing the action.
- "Want a candlelit dinner?" -> "Have a candlelit dinner with your partner"
- DO NOT change it to something unrelated like "Go to the movies".
- Polish the phrasing, grammar, and tone, but keep the core action IDENTICAL.
3. TONE: Engaging, romantic, or spicy (depending on context), but direct.
`;
}
const prompt = `Please polish, improve, and tidy up the following text, which is used as ${contextMap[type] || 'text in the app'}.
Original text: "${text}"
${explicitInstruction}
${additionalRules}
Make it concise, engaging, and grammatically correct.
Maintain the original intent and meaning.
Return a JSON object with:
- polished: The improved text string`;
const response = await openai.chat.completions.create({
model: getModel(),
messages: [
{
role: 'system',
content: 'You are a professional editor for a couples app. Improve the copy while maintaining the tone. Always respond with valid JSON only.',
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.7,
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
const parsed = JSON.parse(content);
return parsed.polished;
}
export interface TargetAnalysis {
id: string;
suggested_targets: string[] | null;
reason: string;
}
export async function analyzeQuestionTargets(
questions: { id: string; text: string; partner_text?: string | null; allowed_couple_genders: string[] | null }[]
): Promise<TargetAnalysis[]> {
const openai = getOpenAI();
const simplifiedQuestions = questions.map(q => ({
id: q.id,
text: q.text,
partner_text: q.partner_text,
current_targets: q.allowed_couple_genders
}));
const prompt = `Analyze the following questions for a couples app and suggest the appropriate "allowed_couple_genders".
Target Options: 'male+male', 'female+male', 'female+female'.
Goal: Identify anatomical or gender-role constraints.
- Default to ALL (null or all 3) if the action is gender-neutral/universal (e.g. kissing, massage, generic sex, anal).
- Limit ONLY if there is a clear anatomical requirement (e.g. genitalia specific) or strict gendered language (he/she/him/her).
Examples:
- "Blowjob" -> Needs penis -> Exclude F+F -> ['male+male', 'female+male']
- "Lick pussy" -> Needs vagina -> Exclude M+M -> ['female+male', 'female+female']
- "Titjob" -> Needs breasts -> Exclude M+M (typically) -> ['female+male', 'female+female']
Return a JSON object with an "analysis" array containing objects for questions where you have a recommendation.
Include a "reason" string explaining why limits were added (or removed).
Structure:
{
"analysis": [
{ "id": "...", "suggested_targets": ["male+male", "female+male"], "reason": "Requires penis" }
]
}
Questions:
${JSON.stringify(simplifiedQuestions)}
`;
const response = await openai.chat.completions.create({
model: getModel(),
messages: [
{
role: 'system',
content: 'You are a content analyzer for a couples app. You understand human anatomy and sexual acts. Return valid JSON.'
},
{ role: 'user', content: prompt }
],
response_format: { type: 'json_object' },
temperature: 0.5,
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
const parsed = JSON.parse(content);
return parsed.analysis || [];
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+139
View File
@@ -0,0 +1,139 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/config';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { format } from 'date-fns';
interface AuditLog {
id: string;
user_id: string;
action: string;
table_name: string;
record_id: string | null;
old_data: Record<string, unknown> | null;
new_data: Record<string, unknown> | null;
created_at: string;
user_email?: string;
}
const actionColors: Record<string, string> = {
INSERT: 'bg-green-500',
UPDATE: 'bg-blue-500',
DELETE: 'bg-red-500',
};
export function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchLogs = async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from('audit_logs')
.select('*')
.order('created_at', { ascending: false })
.limit(100);
if (error) throw error;
setLogs(data || []);
} catch (error) {
console.error('Failed to load audit logs:', error);
} finally {
setLoading(false);
}
};
fetchLogs();
}, []);
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-96" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">Audit Logs</h1>
<p className="text-muted-foreground">
Track all administrative actions and data changes
</p>
</div>
{/* Logs Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-40">Time</TableHead>
<TableHead className="w-24">Action</TableHead>
<TableHead>Table</TableHead>
<TableHead>Record ID</TableHead>
<TableHead>Changes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No audit logs found
</TableCell>
</TableRow>
) : (
logs.map((log) => (
<TableRow key={log.id}>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(log.created_at), 'MMM d, h:mm a')}
</TableCell>
<TableCell>
<Badge className={`${actionColors[log.action] || 'bg-gray-500'} text-white`}>
{log.action}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm">
{log.table_name}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{log.record_id ? log.record_id.substring(0, 8) + '...' : '—'}
</TableCell>
<TableCell className="max-w-md">
{log.action === 'DELETE' && log.old_data && (
<code className="text-xs bg-red-50 text-red-700 px-2 py-1 rounded">
Deleted: {JSON.stringify(log.old_data).substring(0, 100)}...
</code>
)}
{log.action === 'INSERT' && log.new_data && (
<code className="text-xs bg-green-50 text-green-700 px-2 py-1 rounded">
Created: {JSON.stringify(log.new_data).substring(0, 100)}...
</code>
)}
{log.action === 'UPDATE' && (
<code className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">
Updated record
</code>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { supabase } from '@/config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts';
import { format, subDays, startOfDay, endOfDay } from 'date-fns';
export function DashboardPage() {
const { isSuperAdmin } = useAuth();
const [stats, setStats] = useState({
categories: 0,
packs: 0,
questions: 0,
users: 0,
});
const [messageStats, setMessageStats] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchStats = async () => {
try {
// Fetch counts
const [
{ count: categoriesCount },
{ count: packsCount },
{ count: questionsCount },
{ count: usersCount },
] = await Promise.all([
supabase.from('categories').select('*', { count: 'exact', head: true }),
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
supabase.from('questions').select('*', { count: 'exact', head: true }),
supabase.from('profiles').select('*', { count: 'exact', head: true }),
]);
setStats({
categories: categoriesCount || 0,
packs: packsCount || 0,
questions: questionsCount || 0,
users: usersCount || 0,
});
// Fetch message history for chart
const endDate = endOfDay(new Date());
const startDate = subDays(startOfDay(new Date()), 6); // Last 7 days including today
const { data: messages } = await supabase
.from('messages')
.select('created_at')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString());
// Process messages for chart
const daysMap = new Map();
// Initialize last 7 days with 0
for (let i = 0; i < 7; i++) {
const date = subDays(new Date(), 6 - i);
const key = format(date, 'MMM d');
daysMap.set(key, 0);
}
messages?.forEach(msg => {
const date = new Date(msg.created_at);
const key = format(date, 'MMM d');
if (daysMap.has(key)) {
daysMap.set(key, daysMap.get(key) + 1);
}
});
const chartData = Array.from(daysMap.entries()).map(([date, count]) => ({
date,
count
}));
setMessageStats(chartData);
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
const contentCards = [
{
title: 'Categories',
description: 'Manage question categories and organize your packs',
icon: <LayoutGrid className="h-8 w-8" />,
href: '/categories',
color: 'bg-rose-500',
},
];
const adminCards = [
{
title: 'Users',
description: 'View user profiles, responses, and chat history',
icon: <Users className="h-8 w-8" />,
href: '/users',
color: 'bg-blue-500',
},
{
title: 'Audit Logs',
description: 'Track all admin actions and changes',
icon: <ClipboardList className="h-8 w-8" />,
href: '/audit-logs',
color: 'bg-amber-500',
},
];
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome back! Manage your Sauci content and users.
</p>
</div>
{/* Content Management */}
<section>
<h2 className="text-xl font-semibold mb-4">Content Management</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{contentCards.map((card) => (
<Card key={card.href} className="group hover:shadow-md transition-shadow">
<CardHeader>
<div className={`w-14 h-14 rounded-lg ${card.color} text-white flex items-center justify-center mb-2`}>
{card.icon}
</div>
<CardTitle>{card.title}</CardTitle>
<CardDescription>{card.description}</CardDescription>
</CardHeader>
<CardContent>
<Link to={card.href}>
<Button variant="ghost" className="group-hover:translate-x-1 transition-transform">
Open
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</CardContent>
</Card>
))}
</div>
</section>
{/* Super Admin Only */}
{isSuperAdmin && (
<section>
<h2 className="text-xl font-semibold mb-4">Administration</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{adminCards.map((card) => (
<Card key={card.href} className="group hover:shadow-md transition-shadow">
<CardHeader>
<div className={`w-14 h-14 rounded-lg ${card.color} text-white flex items-center justify-center mb-2`}>
{card.icon}
</div>
<CardTitle>{card.title}</CardTitle>
<CardDescription>{card.description}</CardDescription>
</CardHeader>
<CardContent>
<Link to={card.href}>
<Button variant="ghost" className="group-hover:translate-x-1 transition-transform">
Open
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</CardContent>
</Card>
))}
</div>
</section>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
{/* Quick Stats - Takes up 4 columns */}
<Card className="col-span-4">
<CardHeader>
<CardTitle>Quick Stats</CardTitle>
<CardDescription>Overview of your platform's content and users</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Categories</p>
<div className="text-2xl font-bold">
{loading ? <Skeleton className="h-8 w-16" /> : stats.categories}
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Packs</p>
<div className="text-2xl font-bold">
{loading ? <Skeleton className="h-8 w-16" /> : stats.packs}
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Questions</p>
<div className="text-2xl font-bold">
{loading ? <Skeleton className="h-8 w-16" /> : stats.questions}
</div>
</div>
{isSuperAdmin && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Active Users</p>
<div className="text-2xl font-bold">
{loading ? <Skeleton className="h-8 w-16" /> : stats.users}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Message Activity Chart - Takes up 3 columns */}
<Card className="col-span-3">
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-primary" />
<CardTitle>Message Activity</CardTitle>
</div>
<CardDescription>Messages sent over the last 7 days</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[200px]">
{loading ? (
<Skeleton className="h-full w-full" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={messageStats}>
<XAxis
dataKey="date"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
cursor={{ fill: 'transparent' }}
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
<Bar
dataKey="count"
fill="currentColor"
radius={[4, 4, 0, 0]}
className="fill-primary"
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Sparkles, Loader2 } from 'lucide-react';
export function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-pink-100 dark:from-gray-900 dark:to-gray-800 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Sparkles className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">Sauci Admin</CardTitle>
<CardDescription>
Sign in to access the admin dashboard
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
'Sign In'
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+397
View File
@@ -0,0 +1,397 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/config';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Search, Shield, Trash2, UserPlus } from 'lucide-react';
import { format } from 'date-fns';
import { useAuth, AdminRole } from '@/contexts/AuthContext';
import { toast } from 'sonner';
interface AdminUser {
id: string; // admin_users PK
user_id: string;
role: AdminRole;
created_at: string;
profile?: {
name: string | null;
email: string | null;
avatar_url: string | null;
};
}
interface Profile {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
}
export function AdminsPage() {
const { user: currentUser } = useAuth();
const [admins, setAdmins] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
// Add Admin State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [userSearch, setUserSearch] = useState('');
const [searchResults, setSearchResults] = useState<Profile[]>([]);
const [searchingUsers, setSearchingUsers] = useState(false);
const [selectedUser, setSelectedUser] = useState<Profile | null>(null);
const [selectedRole, setSelectedRole] = useState<AdminRole>('pack_creator');
const [isSubmitting, setIsSubmitting] = useState(false);
// Initial load
useEffect(() => {
fetchAdmins();
}, []);
// Search users for adding
useEffect(() => {
const searchUsers = async () => {
if (!userSearch.trim()) {
setSearchResults([]);
return;
}
setSearchingUsers(true);
try {
// Determine which users are already admins to exclude them
const adminUserIds = new Set(admins.map(a => a.user_id));
const { data, error } = await supabase
.from('profiles')
.select('id, name, email, avatar_url')
.or(`email.ilike.%${userSearch}%,name.ilike.%${userSearch}%`)
.limit(5);
if (error) throw error;
// Filter out existing admins
const availableUsers = (data || []).filter(u => !adminUserIds.has(u.id));
setSearchResults(availableUsers);
} catch (error) {
console.error('Search failed:', error);
} finally {
setSearchingUsers(false);
}
};
const debounce = setTimeout(searchUsers, 500);
return () => clearTimeout(debounce);
}, [userSearch, admins]);
const fetchAdmins = async () => {
setLoading(true);
try {
// First fetch admin users
const { data: adminData, error: adminError } = await supabase
.from('admin_users')
.select('*')
.order('created_at', { ascending: false });
if (adminError) throw adminError;
// Then fetch profiles for these users
// We do this manually to avoid relying on foreign key relationships if they aren't perfect
if (adminData && adminData.length > 0) {
const userIds = adminData.map(a => a.user_id);
const { data: profileData, error: profileError } = await supabase
.from('profiles')
.select('id, name, email, avatar_url')
.in('id', userIds);
if (profileError) throw profileError;
// Merge data
const joinedAdmins = adminData.map(admin => ({
...admin,
profile: profileData?.find(p => p.id === admin.user_id)
}));
setAdmins(joinedAdmins);
} else {
setAdmins([]);
}
} catch (error: any) {
console.error('Failed to load admins:', error);
toast.error("Failed to load admin users");
} finally {
setLoading(false);
}
};
const handleAddAdmin = async () => {
if (!selectedUser) return;
setIsSubmitting(true);
try {
const { error } = await supabase
.from('admin_users')
.insert({
user_id: selectedUser.id,
role: selectedRole
});
if (error) throw error;
toast.success(`${selectedUser.name || selectedUser.email} added as ${selectedRole.replace('_', ' ')}`);
setIsAddDialogOpen(false);
setSelectedUser(null);
setUserSearch('');
fetchAdmins();
} catch (error: any) {
console.error('Failed to add admin:', error);
toast.error(error.message || "Failed to add admin");
} finally {
setIsSubmitting(false);
}
};
const handleRemoveAdmin = async (admin: AdminUser) => {
if (!confirm(`Are you sure you want to remove access for ${admin.profile?.name || admin.profile?.email || 'this user'}?`)) {
return;
}
try {
const { error } = await supabase
.from('admin_users')
.delete()
.eq('user_id', admin.user_id);
if (error) throw error;
toast.success("Admin access revoked");
fetchAdmins();
} catch (error: any) {
toast.error("Failed to remove admin");
}
};
const filteredAdmins = admins.filter(admin => {
if (!search) return true;
const query = search.toLowerCase();
return (
admin.profile?.name?.toLowerCase().includes(query) ||
admin.profile?.email?.toLowerCase().includes(query) ||
admin.role.includes(query)
);
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Admin Management</h1>
<p className="text-muted-foreground">
Manage system administrators and their roles
</p>
</div>
<div className="flex items-center gap-4">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search admins..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="h-4 w-4 mr-2" />
Add Admin
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add Administrator</DialogTitle>
<DialogDescription>
Search for an existing user to grant admin access.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Select User</Label>
{selectedUser ? (
<div className="flex items-center justify-between p-2 border rounded-md">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={selectedUser.avatar_url || undefined} />
<AvatarFallback>{selectedUser.name?.charAt(0) || 'U'}</AvatarFallback>
</Avatar>
<div className="text-sm">
<div className="font-medium">{selectedUser.name || 'Unnamed'}</div>
<div className="text-xs text-muted-foreground">{selectedUser.email}</div>
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => setSelectedUser(null)}>Change</Button>
</div>
) : (
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name or email"
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
className="pl-8"
/>
{userSearch && (
<div className="absolute top-full left-0 right-0 z-10 mt-1 max-h-48 overflow-auto rounded-md border bg-popover shadow-md">
{searchingUsers && <div className="p-2 text-sm text-center text-muted-foreground">Searching...</div>}
{!searchingUsers && searchResults.length === 0 && (
<div className="p-2 text-sm text-center text-muted-foreground">No eligible users found</div>
)}
{searchResults.map(user => (
<div
key={user.id}
className="flex items-center gap-2 p-2 hover:bg-accent cursor-pointer text-sm"
onClick={() => setSelectedUser(user)}
>
<Avatar className="h-6 w-6">
<AvatarImage src={user.avatar_url || undefined} />
<AvatarFallback>{user.name?.charAt(0) || 'U'}</AvatarFallback>
</Avatar>
<div className="flex-1 overflow-hidden">
<div className="truncate font-medium">{user.name || 'Unnamed'}</div>
<div className="truncate text-xs text-muted-foreground">{user.email}</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
<div className="grid gap-2">
<Label>Role</Label>
<Select value={selectedRole} onValueChange={(v) => setSelectedRole(v as AdminRole)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pack_creator">Pack Creator</SelectItem>
<SelectItem value="super_admin">Super Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>Cancel</Button>
<Button onClick={handleAddAdmin} disabled={!selectedUser || isSubmitting}>
{isSubmitting ? 'Adding...' : 'Add Access'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Admin User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Added On</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell>
</TableRow>
) : filteredAdmins.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No admin users found.
</TableCell>
</TableRow>
) : (
filteredAdmins.map((admin) => (
<TableRow key={admin.user_id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={admin.profile?.avatar_url || undefined} />
<AvatarFallback>
{admin.profile?.name?.charAt(0).toUpperCase() || 'A'}
</AvatarFallback>
</Avatar>
<span className="font-medium">
{admin.profile?.name || 'Unknown User'}
</span>
{admin.user_id === currentUser?.id && (
<Badge variant="outline" className="ml-2">You</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{admin.profile?.email || '—'}
</TableCell>
<TableCell>
<Badge variant={admin.role === 'super_admin' ? 'default' : 'secondary'}>
<Shield className="h-3 w-3 mr-1" />
{admin.role.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{admin.created_at ? format(new Date(admin.created_at), 'MMM d, yyyy') : '—'}
</TableCell>
<TableCell>
{admin.user_id !== currentUser?.id && (
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRemoveAdmin(admin)}
title="Revoke Access"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
@@ -0,0 +1,371 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { supabase } from '@/config';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Plus, Package, Pencil, Trash2, Sparkles, Loader2, Lightbulb } from 'lucide-react';
import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { AIPolishButton } from '@/components/ai/AIPolishButton';
interface Category {
id: string;
name: string;
description: string | null;
icon: string | null;
sort_order: number | null;
created_at: string | null;
pack_count?: number;
}
export function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [saving, setSaving] = useState(false);
const [aiIdeasOpen, setAiIdeasOpen] = useState(false);
// Form state
const [formData, setFormData] = useState({
name: '',
description: '',
icon: '📚',
});
const fetchCategories = async () => {
setLoading(true);
try {
// Fetch categories with pack count
const { data: cats, error } = await supabase
.from('categories')
.select('*')
.order('sort_order', { ascending: true });
if (error) throw error;
// Fetch pack counts per category
const { data: packs } = await supabase
.from('question_packs')
.select('category_id');
const packCounts: Record<string, number> = {};
packs?.forEach(p => {
if (p.category_id) {
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1;
}
});
setCategories(
(cats || []).map(c => ({
...c,
pack_count: packCounts[c.id] || 0,
}))
);
} catch (error) {
toast.error('Failed to load categories');
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCategories();
}, []);
const openCreateDialog = () => {
setEditingCategory(null);
setFormData({ name: '', description: '', icon: '📚' });
setDialogOpen(true);
};
const openEditDialog = (category: Category) => {
setEditingCategory(category);
setFormData({
name: category.name,
description: category.description || '',
icon: category.icon || '📚',
});
setDialogOpen(true);
};
const handleAiIdea = (idea: any) => {
setAiIdeasOpen(false);
setEditingCategory(null);
setFormData({
name: idea.name,
description: '',
icon: idea.icon,
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!formData.name.trim()) {
toast.error('Name is required');
return;
}
setSaving(true);
try {
if (editingCategory) {
// Update
const { error } = await supabase
.from('categories')
.update({
name: formData.name,
description: formData.description || null,
icon: formData.icon || null,
})
.eq('id', editingCategory.id);
if (error) throw error;
toast.success('Category updated');
} else {
// Create
const { error } = await supabase
.from('categories')
.insert({
name: formData.name,
description: formData.description || null,
icon: formData.icon || null,
sort_order: categories.length,
});
if (error) throw error;
toast.success('Category created');
}
setDialogOpen(false);
fetchCategories();
} catch (error) {
toast.error('Failed to save category');
console.error(error);
} finally {
setSaving(false);
}
};
const handleDelete = async (category: Category) => {
if (!confirm(`Delete category "${category.name}"? This will not delete associated packs.`)) {
return;
}
try {
const { error } = await supabase
.from('categories')
.delete()
.eq('id', category.id);
if (error) throw error;
toast.success('Category deleted');
fetchCategories();
} catch (error) {
toast.error('Failed to delete category');
console.error(error);
}
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-10 w-40" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-48" />
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Categories</h1>
<p className="text-muted-foreground">
Organize your question packs into categories
</p>
</div>
<div className="flex gap-2">
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Category
</Button>
</DialogTrigger>
<Button variant="outline" onClick={() => setAiIdeasOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
Ideas
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingCategory ? 'Edit Category' : 'Create Category'}
</DialogTitle>
<DialogDescription>
{editingCategory
? 'Update the category details below.'
: 'Add a new category to organize your question packs.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="icon">Icon (emoji)</Label>
<Input
id="icon"
value={formData.icon}
onChange={(e) => setFormData(d => ({ ...d, icon: e.target.value }))}
placeholder="📚"
className="w-20 text-center text-2xl"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="name">Name</Label>
<AIPolishButton
text={formData.name}
type="category_name"
onPolished={(val) => setFormData(d => ({ ...d, name: val }))}
/>
</div>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData(d => ({ ...d, name: e.target.value }))}
placeholder="e.g., Romance"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="description">Description</Label>
<AIPolishButton
text={formData.description}
type="pack_description"
onPolished={(val) => setFormData(d => ({ ...d, description: val }))}
/>
</div>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData(d => ({ ...d, description: e.target.value }))}
placeholder="Describe this category..."
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{/* Categories Grid */}
{categories.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">No categories yet</h3>
<p className="text-muted-foreground mb-4">
Create your first category to get started
</p>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Category
</Button>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{categories.map((category) => (
<Card key={category.id} className="group hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="text-4xl mb-2">{category.icon || '📚'}</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(category)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(category)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<CardTitle>{category.name}</CardTitle>
<CardDescription className="line-clamp-2">
{category.description || 'No description'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
</span>
<Link to={`/categories/${category.id}/packs`}>
<Button variant="outline" size="sm">
View Packs
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* AI Dialog */}
<AiGeneratorDialog
open={aiIdeasOpen}
onOpenChange={setAiIdeasOpen}
type="category-ideas"
context={{
existingCategories: categories.map(c => c.name),
}}
onGenerated={handleAiIdea}
/>
</div>
);
}
+481
View File
@@ -0,0 +1,481 @@
import { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { supabase } from '@/config';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Loader2, Crown, Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { AIPolishButton } from '@/components/ai/AIPolishButton';
interface Category {
id: string;
name: string;
icon: string | null;
}
interface QuestionPack {
id: string;
name: string;
description: string | null;
icon: string | null;
is_premium: boolean;
is_public: boolean;
sort_order: number | null;
category_id: string | null;
created_at: string | null;
question_count?: number;
}
export function PacksPage() {
const { categoryId } = useParams<{ categoryId: string }>();
const [category, setCategory] = useState<Category | null>(null);
const [packs, setPacks] = useState<QuestionPack[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [aiDialogOpen, setAiDialogOpen] = useState(false);
const [editingPack, setEditingPack] = useState<QuestionPack | null>(null);
const [saving, setSaving] = useState(false);
// Form state
const [formData, setFormData] = useState({
name: '',
description: '',
icon: '💕',
is_premium: false,
is_public: true,
});
const fetchData = async () => {
setLoading(true);
try {
// Fetch category info
if (categoryId) {
const { data: cat } = await supabase
.from('categories')
.select('id, name, icon')
.eq('id', categoryId)
.single();
setCategory(cat);
}
// Fetch packs
let query = supabase
.from('question_packs')
.select('*')
.order('sort_order', { ascending: true });
if (categoryId) {
query = query.eq('category_id', categoryId);
}
const { data: packData, error } = await query;
if (error) throw error;
// Fetch question counts
const packIds = (packData || []).map(p => p.id);
const { data: questions } = await supabase
.from('questions')
.select('pack_id')
.in('pack_id', packIds);
const questionCounts: Record<string, number> = {};
questions?.forEach(q => {
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1;
});
setPacks(
(packData || []).map(p => ({
...p,
question_count: questionCounts[p.id] || 0,
}))
);
} catch (error) {
toast.error('Failed to load packs');
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [categoryId]);
const openCreateDialog = () => {
setEditingPack(null);
setFormData({
name: '',
description: '',
icon: '💕',
is_premium: false,
is_public: true,
});
setDialogOpen(true);
};
const openEditDialog = (pack: QuestionPack) => {
setEditingPack(pack);
setFormData({
name: pack.name,
description: pack.description || '',
icon: pack.icon || '💕',
is_premium: pack.is_premium,
is_public: pack.is_public,
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!formData.name.trim()) {
toast.error('Name is required');
return;
}
setSaving(true);
try {
if (editingPack) {
const { error } = await supabase
.from('question_packs')
.update({
name: formData.name,
description: formData.description || null,
icon: formData.icon || null,
is_premium: formData.is_premium,
is_public: formData.is_public,
})
.eq('id', editingPack.id);
if (error) throw error;
toast.success('Pack updated');
} else {
const { error } = await supabase
.from('question_packs')
.insert({
name: formData.name,
description: formData.description || null,
icon: formData.icon || null,
is_premium: formData.is_premium,
is_public: formData.is_public,
category_id: categoryId || null,
sort_order: packs.length,
});
if (error) throw error;
toast.success('Pack created');
}
setDialogOpen(false);
fetchData();
} catch (error) {
toast.error('Failed to save pack');
console.error(error);
} finally {
setSaving(false);
}
};
const handleDelete = async (pack: QuestionPack) => {
if (!confirm(`Delete pack "${pack.name}"? This will delete all questions in this pack.`)) {
return;
}
try {
const { error } = await supabase
.from('question_packs')
.delete()
.eq('id', pack.id);
if (error) throw error;
toast.success('Pack deleted');
fetchData();
} catch (error) {
toast.error('Failed to delete pack');
console.error(error);
}
};
const handleAiGenerated = (result: any) => {
// Handle pack generation
if (result.description) {
setFormData({
...formData,
name: result.name,
description: result.description,
icon: result.icon || '💕',
});
setAiDialogOpen(false);
setDialogOpen(true);
}
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-10 w-40" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-48" />
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
{category && (
<>
<span className="text-3xl">{category.icon}</span>
{category.name}:
</>
)}
Question Packs
</h1>
<p className="text-muted-foreground">
{category
? `Manage packs in the ${category.name} category`
: 'Manage all question packs'}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setAiDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
Ideas
</Button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Pack
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingPack ? 'Edit Pack' : 'Create Pack'}
</DialogTitle>
<DialogDescription>
{editingPack
? 'Update the pack details below.'
: 'Add a new question pack to this category.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="icon">Icon (emoji)</Label>
<Input
id="icon"
value={formData.icon}
onChange={(e) => setFormData(d => ({ ...d, icon: e.target.value }))}
placeholder="💕"
className="w-20 text-center text-2xl"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="name">Name</Label>
<AIPolishButton
text={formData.name}
type="pack_name"
onPolished={(val) => setFormData(d => ({ ...d, name: val }))}
/>
</div>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData(d => ({ ...d, name: e.target.value }))}
placeholder="e.g., 36 Questions to Fall in Love"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="description">Description</Label>
<AIPolishButton
text={formData.description}
type="pack_description"
onPolished={(val) => setFormData(d => ({ ...d, description: val }))}
/>
</div>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData(d => ({ ...d, description: e.target.value }))}
placeholder="Describe this pack..."
rows={3}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Premium Pack</Label>
<p className="text-sm text-muted-foreground">
Only available to premium users
</p>
</div>
<Switch
checked={formData.is_premium}
onCheckedChange={(checked) =>
setFormData(d => ({ ...d, is_premium: checked }))
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Public</Label>
<p className="text-sm text-muted-foreground">
Visible to all users by default
</p>
</div>
<Switch
checked={formData.is_public}
onCheckedChange={(checked) =>
setFormData(d => ({ ...d, is_public: checked }))
}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{/* AI Generator Dialog */}
{/* AI Generator Dialog */}
<AiGeneratorDialog
open={aiDialogOpen}
onOpenChange={setAiDialogOpen}
type="category-pack-ideas"
context={{
categoryName: category?.name,
existingPacks: packs.map(p => p.name)
}}
onGenerated={handleAiGenerated}
/>
{/* Packs Grid */}
{packs.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<MessageCircle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">No packs yet</h3>
<p className="text-muted-foreground mb-4">
Create your first question pack to get started
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setAiDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
Ideas
</Button>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Pack
</Button>
</div>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{packs.map((pack) => (
<Card key={pack.id} className="group hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="text-4xl mb-2">{pack.icon || '💕'}</div>
<div className="flex items-center gap-1">
{pack.is_premium && (
<Badge variant="default" className="bg-amber-500">
<Crown className="h-3 w-3 mr-1" />
Premium
</Badge>
)}
{!pack.is_public && (
<Badge variant="secondary">
<EyeOff className="h-3 w-3 mr-1" />
Hidden
</Badge>
)}
</div>
</div>
<div className="flex items-start justify-between">
<CardTitle className="flex-1">{pack.name}</CardTitle>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(pack)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(pack)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<CardDescription className="line-clamp-2">
{pack.description || 'No description'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{pack.question_count} question{pack.question_count !== 1 ? 's' : ''}
</span>
<Link to={`/packs/${pack.id}/questions`}>
<Button variant="outline" size="sm">
Manage Questions
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,513 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { supabase } from '@/config';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Card } from '@/components/ui/card';
import { Plus, Pencil, Trash2, Sparkles, Loader2, Users, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { AIPolishButton } from '@/components/ai/AIPolishButton';
import { FixTargetsDialog } from '@/components/content/FixTargetsDialog';
interface QuestionPack {
id: string;
name: string;
icon: string | null;
}
interface Question {
id: string;
pack_id: string;
text: string;
partner_text: string | null;
intensity: number;
allowed_couple_genders: string[] | null;
created_at: string | null;
}
const intensityLabels = ['', 'Light', 'Mild', 'Medium', 'Spicy', 'Intense'];
const intensityColors = ['', 'bg-green-500', 'bg-lime-500', 'bg-yellow-500', 'bg-orange-500', 'bg-red-500'];
export function QuestionsPage() {
const { packId } = useParams<{ packId: string }>();
const [pack, setPack] = useState<QuestionPack | null>(null);
const [questions, setQuestions] = useState<Question[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [aiDialogOpen, setAiDialogOpen] = useState(false);
const [fixTargetsOpen, setFixTargetsOpen] = useState(false);
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
const [saving, setSaving] = useState(false);
// Form state
const [formData, setFormData] = useState<{
text: string;
partner_text: string;
intensity: number;
allowed_couple_genders: string[];
}>({
text: '',
partner_text: '',
intensity: 1,
allowed_couple_genders: [],
});
const fetchData = async () => {
if (!packId) return;
setLoading(true);
try {
// Fetch pack info
const { data: packData } = await supabase
.from('question_packs')
.select('id, name, icon')
.eq('id', packId)
.single();
setPack(packData);
// Fetch questions
const { data: questionData, error } = await supabase
.from('questions')
.select('*')
.eq('pack_id', packId)
.order('created_at', { ascending: true });
if (error) throw error;
setQuestions(questionData || []);
} catch (error) {
toast.error('Failed to load questions');
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [packId]);
const openCreateDialog = () => {
setEditingQuestion(null);
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [] });
setDialogOpen(true);
};
const openEditDialog = (question: Question) => {
setEditingQuestion(question);
setFormData({
text: question.text,
partner_text: question.partner_text || '',
intensity: question.intensity,
allowed_couple_genders: question.allowed_couple_genders || [],
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!formData.text.trim()) {
toast.error('Question text is required');
return;
}
setSaving(true);
try {
if (editingQuestion) {
const { error } = await supabase
.from('questions')
.update({
text: formData.text,
partner_text: formData.partner_text || null,
intensity: formData.intensity,
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
})
.eq('id', editingQuestion.id);
if (error) throw error;
toast.success('Question updated');
} else {
const { error } = await supabase
.from('questions')
.insert({
pack_id: packId,
text: formData.text,
partner_text: formData.partner_text || null,
intensity: formData.intensity,
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
});
if (error) throw error;
toast.success('Question created');
}
setDialogOpen(false);
fetchData();
} catch (error) {
toast.error('Failed to save question');
console.error(error);
} finally {
setSaving(false);
}
};
const handleDelete = async (question: Question) => {
if (!confirm('Delete this question? This cannot be undone.')) {
return;
}
try {
const { error } = await supabase
.from('questions')
.delete()
.eq('id', question.id);
if (error) throw error;
toast.success('Question deleted');
fetchData();
} catch (error) {
toast.error('Failed to delete question');
console.error(error);
}
};
const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number }>) => {
// Bulk insert AI-generated questions
const insertQuestions = async () => {
try {
const { error } = await supabase
.from('questions')
.insert(
generatedQuestions.map(q => ({
pack_id: packId,
text: q.text,
partner_text: q.partner_text || null,
intensity: q.intensity,
}))
);
if (error) throw error;
toast.success(`${generatedQuestions.length} questions added`);
fetchData();
} catch (error) {
toast.error('Failed to add questions');
console.error(error);
}
};
insertQuestions();
setAiDialogOpen(false);
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-10 w-40" />
</div>
<Skeleton className="h-96" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
{pack && (
<>
<span className="text-3xl">{pack.icon}</span>
{pack.name}
</>
)}
</h1>
<p className="text-muted-foreground">
{questions.length} question{questions.length !== 1 ? 's' : ''} in this pack
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setFixTargetsOpen(true)}>
<ShieldCheck className="mr-2 h-4 w-4" />
Fix Targets
</Button>
<Button variant="outline" onClick={() => setAiDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
AI Generate
</Button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Question
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{editingQuestion ? 'Edit Question' : 'Add Question'}
</DialogTitle>
<DialogDescription>
{editingQuestion
? 'Update the question details below.'
: 'Add a new question to this pack.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="text">Question Text</Label>
<AIPolishButton
text={formData.text}
type="question"
onPolished={(val) => setFormData(d => ({ ...d, text: val }))}
/>
</div>
<Textarea
id="text"
value={formData.text}
onChange={(e) => setFormData(d => ({ ...d, text: e.target.value }))}
placeholder="e.g., Would you try..."
rows={3}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label htmlFor="partner_text">Partner Text (optional)</Label>
<Badge variant="secondary" className="text-xs">
<Users className="h-3 w-3 mr-1" />
Two-Part
</Badge>
</div>
<AIPolishButton
text={formData.partner_text}
type="partner_text"
onPolished={(val) => setFormData(d => ({ ...d, partner_text: val }))}
/>
</div>
<Textarea
id="partner_text"
value={formData.partner_text}
onChange={(e) => setFormData(d => ({ ...d, partner_text: e.target.value }))}
placeholder="Alternative text shown to the partner (for role-specific questions)"
rows={2}
/>
<p className="text-xs text-muted-foreground">
If set, the partner will see this text instead. Useful for "Would you..." / "Would your partner..." style questions.
</p>
</div>
<div className="space-y-2">
<Label>Intensity Level</Label>
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map((level) => (
<Button
key={level}
type="button"
variant={formData.intensity === level ? 'default' : 'outline'}
size="sm"
onClick={() => setFormData(d => ({ ...d, intensity: level }))}
className={formData.intensity === level ? intensityColors[level] : ''}
>
{level} - {intensityLabels[level]}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label>Target Couples (optional)</Label>
<div className="flex flex-wrap gap-2">
{[
{ id: 'male+male', label: 'Male + Male' },
{ id: 'female+male', label: 'Male + Female' },
{ id: 'female+female', label: 'Female + Female' },
].map((type) => {
const isSelected = formData.allowed_couple_genders.includes(type.id);
return (
<Button
key={type.id}
type="button"
variant={isSelected ? 'default' : 'outline'}
size="sm"
onClick={() => {
setFormData(d => ({
...d,
allowed_couple_genders: isSelected
? d.allowed_couple_genders.filter(id => id !== type.id)
: [...d.allowed_couple_genders, type.id]
}));
}}
>
{type.label}
</Button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Select specific couple types to restrict this question. Leave all unselected to allow for everyone.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{/* AI Generator Dialog */}
<AiGeneratorDialog
open={aiDialogOpen}
onOpenChange={setAiDialogOpen}
type="questions"
context={{ packName: pack?.name }}
onGenerated={handleAiGenerated}
/>
<FixTargetsDialog
open={fixTargetsOpen}
onOpenChange={setFixTargetsOpen}
questions={questions}
onUpdated={fetchData}
/>
{/* Questions Table */}
{
questions.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<Sparkles className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">No questions yet</h3>
<p className="text-muted-foreground mb-4">
Add questions manually or generate them with AI
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setAiDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
AI Generate
</Button>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Question
</Button>
</div>
</Card>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Question</TableHead>
<TableHead className="w-32">Partner Text</TableHead>
<TableHead className="w-24">Targets</TableHead>
<TableHead className="w-24">Intensity</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questions.map((question, index) => (
<TableRow key={question.id}>
<TableCell className="font-medium text-muted-foreground">
{index + 1}
</TableCell>
<TableCell>
<span className="line-clamp-2">{question.text}</span>
</TableCell>
<TableCell>
{question.partner_text ? (
<Badge variant="secondary" className="text-xs">
<Users className="h-3 w-3 mr-1" />
Two-Part
</Badge>
) : (
<span className="text-muted-foreground text-xs">—</span>
)}
</TableCell>
<TableCell>
{question.allowed_couple_genders && question.allowed_couple_genders.length > 0 ? (
<div className="flex flex-wrap gap-1">
{question.allowed_couple_genders.map(g => (
<Badge key={g} variant="outline" className="text-[10px] px-1 py-0 h-5">
{g.replace('male+male', 'M+M').replace('female+male', 'M+F').replace('female+female', 'F+F')}
</Badge>
))}
</div>
) : (
<span className="text-muted-foreground text-xs">All</span>
)}
</TableCell>
<TableCell>
<Badge className={`${intensityColors[question.intensity]} text-white`}>
{intensityLabels[question.intensity]}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(question)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(question)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
</div >
);
}
@@ -0,0 +1,359 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { supabase } from '@/config';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArrowLeft, Check, CheckCheck, Eye } from 'lucide-react';
import { format, isToday, isYesterday } from 'date-fns';
import { cn } from '@/lib/utils';
interface Profile {
id: string;
name: string | null;
avatar_url: string | null;
}
interface Match {
id: string;
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
question: {
id: string;
text: string;
};
}
interface Message {
id: string;
user_id: string;
content: string | null;
media_path: string | null;
created_at: string;
read_at: string | null;
media_viewed_at: string | null;
}
const matchTypeLabels = {
yes_yes: 'Both Yes!',
yes_maybe: 'Yes + Maybe',
maybe_maybe: 'Both Maybe',
};
function formatMessageTime(date: Date): string {
if (isToday(date)) {
return format(date, 'h:mm a');
} else if (isYesterday(date)) {
return 'Yesterday ' + format(date, 'h:mm a');
}
return format(date, 'MMM d, h:mm a');
}
function ChatImage({ mediaPath }: { mediaPath: string }) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!mediaPath) return;
const getUrl = async () => {
try {
// Handle legacy full URLs
let path = mediaPath;
if (path.startsWith('http')) {
const parts = path.split('/chat-media/');
if (parts.length > 1) {
path = decodeURIComponent(parts[1]);
}
}
const { data, error } = await supabase.storage
.from('chat-media')
.createSignedUrl(path, 3600);
if (error) {
console.error('Error creating signed URL:', error);
setError(error.message);
return;
}
if (data?.signedUrl) {
setUrl(data.signedUrl);
}
} catch (err) {
console.error('Unexpected error loading image:', err);
setError('Failed to load');
}
};
getUrl();
}, [mediaPath]);
if (error) {
return (
<div className="h-32 w-32 rounded-lg bg-muted flex flex-col items-center justify-center border border-red-200 p-2">
<span className="text-xs text-red-500 text-center">Failed to load</span>
<span className="text-[10px] text-muted-foreground mt-1 text-center truncate w-full" title={error}>
{error}
</span>
</div>
);
}
if (!url) return <Skeleton className="h-48 w-48 rounded-lg" />;
return (
<img
src={url}
alt="Shared media"
className="max-w-xs rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => window.open(url, '_blank')}
/>
);
}
export function MatchChatPage() {
const { userId, matchId } = useParams<{ userId: string; matchId: string }>();
const [user, setUser] = useState<Profile | null>(null);
const [partner, setPartner] = useState<Profile | null>(null);
const [match, setMatch] = useState<Match | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
if (!userId || !matchId) return;
setLoading(true);
try {
// Fetch user profile
const { data: userData } = await supabase
.from('profiles')
.select('id, name, avatar_url, couple_id')
.eq('id', userId)
.single();
setUser(userData);
// Fetch partner
if (userData?.couple_id) {
const { data: partnerData } = await supabase
.from('profiles')
.select('id, name, avatar_url')
.eq('couple_id', userData.couple_id)
.neq('id', userId)
.single();
setPartner(partnerData);
}
// Fetch match with question
const { data: matchData } = await supabase
.from('matches')
.select(`
id, match_type,
question:questions (id, text)
`)
.eq('id', matchId)
.single();
setMatch(matchData as unknown as Match);
// Fetch messages
const { data: messageData } = await supabase
.from('messages')
.select('*')
.eq('match_id', matchId)
.order('created_at', { ascending: true });
setMessages(messageData || []);
} catch (error) {
console.error('Failed to load chat data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, [userId, matchId]);
const getProfile = (senderId: string): Profile | null => {
if (senderId === user?.id) return user;
if (senderId === partner?.id) return partner;
return null;
};
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-20" />
<Skeleton className="h-96" />
</div>
);
}
if (!match || !user) {
return (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-lg font-medium">Chat not found</p>
<Link to={`/users/${userId}`}>
<Button variant="link">Back to user</Button>
</Link>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link to={`/users/${userId}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div className="flex-1">
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold">Match Chat</h1>
<Badge variant="secondary">
{matchTypeLabels[match.match_type]}
</Badge>
</div>
<p className="text-sm text-muted-foreground line-clamp-1">
{match.question?.text}
</p>
</div>
</div>
{/* Participants */}
<Card>
<CardHeader className="py-4">
<CardTitle className="text-sm font-medium text-muted-foreground">
Participants
</CardTitle>
</CardHeader>
<CardContent className="flex gap-6 pb-4">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={user.avatar_url || undefined} />
<AvatarFallback>
{user.name?.charAt(0).toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{user.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground">Current User</p>
</div>
</div>
{partner && (
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={partner.avatar_url || undefined} />
<AvatarFallback>
{partner.name?.charAt(0).toUpperCase() || 'P'}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{partner.name || 'Unnamed'}</p>
<Link to={`/users/${partner.id}`} className="text-xs text-primary hover:underline">
View Profile
</Link>
</div>
</div>
)}
</CardContent>
</Card>
{/* Messages */}
<Card className="min-h-[400px]">
<CardHeader className="py-4 border-b">
<CardTitle className="text-sm font-medium">
Messages ({messages.length})
</CardTitle>
</CardHeader>
<CardContent className="p-4">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p>No messages in this chat yet</p>
</div>
) : (
<div className="space-y-4">
{messages.map((message) => {
const sender = getProfile(message.user_id);
const isUserMessage = message.user_id === userId;
return (
<div
key={message.id}
className={cn(
"flex gap-3",
isUserMessage ? "flex-row-reverse" : ""
)}
>
<Avatar className="h-8 w-8 shrink-0">
<AvatarImage src={sender?.avatar_url || undefined} />
<AvatarFallback className="text-xs">
{sender?.name?.charAt(0).toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className={cn(
"max-w-[70%] space-y-1",
isUserMessage ? "items-end" : "items-start"
)}>
<div
className={cn(
"rounded-2xl px-4 py-2",
isUserMessage
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-muted rounded-tl-sm"
)}
>
{message.content && (
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
)}
{message.media_path && (
<div className="mt-2">
<div className="relative group">
<ChatImage mediaPath={message.media_path} />
{message.media_viewed_at && (
<Badge
variant="secondary"
className="absolute bottom-2 right-2 text-xs"
>
<Eye className="h-3 w-3 mr-1" />
Viewed
</Badge>
)}
</div>
</div>
)}
</div>
<div className={cn(
"flex items-center gap-1 text-xs text-muted-foreground px-1",
isUserMessage ? "justify-end" : ""
)}>
<span>{formatMessageTime(new Date(message.created_at))}</span>
{isUserMessage && (
message.read_at ? (
<CheckCheck className="h-3 w-3 text-primary" />
) : (
<Check className="h-3 w-3" />
)
)}
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,486 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { supabase } from '@/config';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift } from 'lucide-react';
import { format } from 'date-fns';
interface Profile {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
is_premium: boolean | null;
couple_id: string | null;
created_at: string | null;
}
interface Partner {
id: string;
name: string | null;
}
interface Response {
id: string;
answer: 'yes' | 'no' | 'maybe';
created_at: string | null;
question: {
id: string;
text: string;
pack: {
name: string;
};
};
}
interface Match {
id: string;
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
is_new: boolean;
created_at: string | null;
question: {
id: string;
text: string;
};
message_count?: number;
}
const answerIcons = {
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
maybe: <Minus className="h-4 w-4 text-yellow-500" />,
};
const matchTypeLabels = {
yes_yes: 'Both Yes!',
yes_maybe: 'Yes + Maybe',
maybe_maybe: 'Both Maybe',
};
export function UserDetailPage() {
const { userId } = useParams<{ userId: string }>();
const [profile, setProfile] = useState<Profile | null>(null);
const [partner, setPartner] = useState<Partner | null>(null);
const [responses, setResponses] = useState<Response[]>([]);
const [matches, setMatches] = useState<Match[]>([]);
const [loading, setLoading] = useState(true);
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [expiryType, setExpiryType] = useState('forever');
const [customDate, setCustomDate] = useState('');
const [upgrading, setUpgrading] = useState(false);
const handleUpgrade = async () => {
if (!profile || !userId) return;
setUpgrading(true);
try {
let expiresAt = null;
const now = new Date();
if (expiryType === '1_month') {
const date = new Date(now);
date.setMonth(date.getMonth() + 1);
expiresAt = date.toISOString();
} else if (expiryType === '1_year') {
const date = new Date(now);
date.setFullYear(date.getFullYear() + 1);
expiresAt = date.toISOString();
} else if (expiryType === 'custom' && customDate) {
// Ensure custom date is in the future
const date = new Date(customDate);
if (date <= now) {
alert('Expiry date must be in the future');
setUpgrading(false);
return;
}
expiresAt = date.toISOString();
}
const { error } = await supabase
.from('subscriptions')
.insert({
user_id: userId,
revenuecat_app_user_id: 'admin_grant',
product_id: 'admin_premium',
status: 'active',
store: 'manual',
purchased_at: new Date().toISOString(),
expires_at: expiresAt,
});
if (error) throw error;
// Refresh profile to get updated is_premium status
// The trigger sync_premium_on_subscription_change will update the profile
// We might need a small delay or just wait for the select
const { data: updatedProfile } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
if (updatedProfile) {
setProfile(updatedProfile);
}
setUpgradeOpen(false);
// Optionally add toast here if available in context
alert('User upgraded to premium successfully!');
} catch (err) {
console.error('Failed to upgrade:', err);
alert('Failed to upgrade user. See console for details.');
} finally {
setUpgrading(false);
}
};
useEffect(() => {
const fetchData = async () => {
if (!userId) return;
setLoading(true);
try {
// Fetch profile
const { data: profileData } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
setProfile(profileData);
// Fetch partner if coupled
if (profileData?.couple_id) {
const { data: partnerData } = await supabase
.from('profiles')
.select('id, name')
.eq('couple_id', profileData.couple_id)
.neq('id', userId)
.single();
setPartner(partnerData);
}
// Fetch responses with question and pack info
const { data: responseData } = await supabase
.from('responses')
.select(`
id, answer, created_at,
question:questions (
id, text,
pack:question_packs (name)
)
`)
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(50);
setResponses((responseData || []) as unknown as Response[]);
// Fetch matches if coupled
if (profileData?.couple_id) {
const { data: matchData } = await supabase
.from('matches')
.select(`
id, match_type, is_new, created_at,
question:questions (id, text)
`)
.eq('couple_id', profileData.couple_id)
.order('created_at', { ascending: false })
.limit(50);
// Get message counts for each match
const matchIds = (matchData || []).map(m => m.id);
const { data: messages } = await supabase
.from('messages')
.select('match_id')
.in('match_id', matchIds);
const messageCounts: Record<string, number> = {};
messages?.forEach(m => {
messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1;
});
setMatches(
(matchData || []).map(m => ({
...m,
message_count: messageCounts[m.id] || 0,
})) as unknown as Match[]
);
}
} catch (error) {
console.error('Failed to load user data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, [userId]);
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-32" />
<Skeleton className="h-96" />
</div>
);
}
if (!profile) {
return (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-lg font-medium">User not found</p>
<Link to="/users">
<Button variant="link">Back to users</Button>
</Link>
</div>
);
}
return (
<div className="space-y-6">
{/* Profile Header */}
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-6">
<Avatar className="h-20 w-20">
<AvatarImage src={profile.avatar_url || undefined} />
<AvatarFallback className="text-2xl">
{profile.name?.charAt(0).toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold">
{profile.name || 'Unnamed User'}
</h1>
{profile.is_premium && (
<Badge className="bg-amber-500">
<Crown className="h-3 w-3 mr-1" />
Premium
</Badge>
)}
{!profile.is_premium && (
<Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="ml-2 gap-1 h-7">
<Gift className="h-3.5 w-3.5" />
Gift Premium
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Gift Premium Access</DialogTitle>
<DialogDescription>
Manually upgrade this user to premium status.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="expiry-type">Duration</Label>
<Select value={expiryType} onValueChange={setExpiryType}>
<SelectTrigger id="expiry-type">
<SelectValue placeholder="Select duration" />
</SelectTrigger>
<SelectContent>
<SelectItem value="forever">Lifetime (No Expiry)</SelectItem>
<SelectItem value="1_month">1 Month</SelectItem>
<SelectItem value="1_year">1 Year</SelectItem>
<SelectItem value="custom">Custom Date</SelectItem>
</SelectContent>
</Select>
</div>
{expiryType === 'custom' && (
<div className="grid gap-2">
<Label htmlFor="custom-date">Expiry Date</Label>
<Input
id="custom-date"
type="datetime-local"
value={customDate}
onChange={(e) => setCustomDate(e.target.value)}
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUpgradeOpen(false)}>
Cancel
</Button>
<Button onClick={handleUpgrade} disabled={upgrading}>
{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
<p className="text-muted-foreground mb-4">
{profile.email || 'No email'}
</p>
<div className="flex gap-6 text-sm">
<div>
<span className="text-muted-foreground">Joined: </span>
<span>
{profile.created_at
? format(new Date(profile.created_at), 'MMMM d, yyyy')
: '—'}
</span>
</div>
{partner ? (
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Paired with: </span>
<Link to={`/users/${partner.id}`} className="text-primary hover:underline">
{partner.name || 'Unnamed Partner'}
</Link>
</div>
) : (
<div className="text-muted-foreground">Not paired</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Tabs */}
<Tabs defaultValue="responses">
<TabsList>
<TabsTrigger value="responses">
Responses ({responses.length})
</TabsTrigger>
<TabsTrigger value="matches" disabled={!profile.couple_id}>
Matches ({matches.length})
</TabsTrigger>
</TabsList>
<TabsContent value="responses" className="mt-4">
{responses.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">No responses yet</p>
</Card>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Question</TableHead>
<TableHead>Pack</TableHead>
<TableHead className="w-24">Answer</TableHead>
<TableHead className="w-32">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{responses.map((r) => (
<TableRow key={r.id}>
<TableCell className="max-w-md">
<span className="line-clamp-2">{r.question.text}</span>
</TableCell>
<TableCell className="text-muted-foreground">
{r.question.pack?.name || '—'}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{answerIcons[r.answer]}
<span className="capitalize">{r.answer}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.created_at
? format(new Date(r.created_at), 'MMM d, yyyy')
: '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
<TabsContent value="matches" className="mt-4">
{matches.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">No matches yet</p>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{matches.map((match) => (
<Card key={match.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<Badge variant="secondary">
{matchTypeLabels[match.match_type]}
</Badge>
{match.is_new && (
<Badge variant="default" className="text-xs">New</Badge>
)}
</div>
<CardDescription className="line-clamp-2 mt-2">
{match.question?.text || 'Unknown question'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<MessageCircle className="h-4 w-4" />
{match.message_count} message{match.message_count !== 1 ? 's' : ''}
</div>
<Link to={`/users/${userId}/matches/${match.id}`}>
<Button variant="ghost" size="sm">
View Chat
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { supabase } from '@/config';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, Crown, Users, ChevronRight } from 'lucide-react';
import { format } from 'date-fns';
interface Profile {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
is_premium: boolean | null;
couple_id: string | null;
created_at: string | null;
}
export function UsersPage() {
const [profiles, setProfiles] = useState<Profile[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]);
useEffect(() => {
const fetchProfiles = async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from('profiles')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
setProfiles(data || []);
setFilteredProfiles(data || []);
} catch (error) {
console.error('Failed to load profiles:', error);
} finally {
setLoading(false);
}
};
fetchProfiles();
}, []);
useEffect(() => {
if (!search.trim()) {
setFilteredProfiles(profiles);
return;
}
const query = search.toLowerCase();
setFilteredProfiles(
profiles.filter(
p =>
p.name?.toLowerCase().includes(query) ||
p.email?.toLowerCase().includes(query) ||
p.id.toLowerCase().includes(query)
)
);
}, [search, profiles]);
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-10 w-64" />
</div>
<Skeleton className="h-96" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">
{profiles.length} registered user{profiles.length !== 1 ? 's' : ''}
</p>
</div>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
{/* Users Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
<TableHead>Couple</TableHead>
<TableHead>Joined</TableHead>
<TableHead className="w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredProfiles.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
{search ? 'No users found matching your search.' : 'No users yet.'}
</TableCell>
</TableRow>
) : (
filteredProfiles.map((profile) => (
<TableRow key={profile.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={profile.avatar_url || undefined} />
<AvatarFallback>
{profile.name?.charAt(0).toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<span className="font-medium">
{profile.name || 'Unnamed User'}
</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{profile.email || '—'}
</TableCell>
<TableCell>
{profile.is_premium ? (
<Badge className="bg-amber-500">
<Crown className="h-3 w-3 mr-1" />
Premium
</Badge>
) : (
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
Free
</Badge>
)}
</TableCell>
<TableCell>
{profile.couple_id ? (
<Badge variant="secondary">
<Users className="h-3 w-3 mr-1" />
Paired
</Badge>
) : (
<span className="text-muted-foreground text-sm">—</span>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{profile.created_at
? format(new Date(profile.created_at), 'MMM d, yyyy')
: '—'}
</TableCell>
<TableCell>
<Link to={`/users/${profile.id}`}>
<Button variant="ghost" size="icon">
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_ANON_KEY: string;
readonly VITE_OPENAI_API_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}