mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update
This commit is contained in:
1 parent
a15089f607
commit
456459bb00
41 files changed
+6018
No files matched your search
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 || [];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+11
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
-- Security Hardening Migration
|
||||
-- Fixes: Weak invite codes, overly permissive RLS, password settings
|
||||
|
||||
-- ============================================
|
||||
-- 1. Strengthen invite code generation
|
||||
-- Use crypto-secure random with alphanumeric chars (8 chars retained for UX)
|
||||
-- ============================================
|
||||
|
||||
-- Create a function to generate secure invite codes
|
||||
CREATE OR REPLACE FUNCTION generate_secure_invite_code()
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
chars TEXT := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; -- Removed confusing chars: I, O, 0, 1
|
||||
result TEXT := '';
|
||||
i INTEGER;
|
||||
rand_bytes BYTEA;
|
||||
BEGIN
|
||||
-- Get 8 cryptographically secure random bytes
|
||||
rand_bytes := gen_random_bytes(8);
|
||||
|
||||
FOR i IN 1..8 LOOP
|
||||
result := result || substr(chars, (get_byte(rand_bytes, i-1) % 32) + 1, 1);
|
||||
END LOOP;
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Update the default for new couples to use secure generation
|
||||
ALTER TABLE public.couples
|
||||
ALTER COLUMN invite_code SET DEFAULT generate_secure_invite_code();
|
||||
|
||||
-- Add rate limiting tracking table for invite code attempts
|
||||
CREATE TABLE IF NOT EXISTS public.invite_code_attempts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ip_hash TEXT NOT NULL, -- Hashed IP for privacy
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
attempted_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
was_successful BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Index for efficient rate limiting queries
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_attempts_user_time
|
||||
ON public.invite_code_attempts(user_id, attempted_at);
|
||||
|
||||
-- Enable RLS on attempts table
|
||||
ALTER TABLE public.invite_code_attempts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Only allow inserts from authenticated users
|
||||
CREATE POLICY "Authenticated users can log attempts"
|
||||
ON public.invite_code_attempts
|
||||
FOR INSERT
|
||||
WITH CHECK (auth.role() = 'authenticated');
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 2. Fix overly permissive couples RLS policy
|
||||
-- ============================================
|
||||
|
||||
-- Drop the overly permissive policy
|
||||
DROP POLICY IF EXISTS "Users can view couple by invite code" ON public.couples;
|
||||
|
||||
-- Replace with a more restrictive policy
|
||||
-- Users can only view their own couple OR look up a couple via RPC (not direct table access)
|
||||
CREATE POLICY "Users can only view their own couple"
|
||||
ON public.couples FOR SELECT
|
||||
USING (
|
||||
id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
|
||||
);
|
||||
|
||||
-- Create a secure RPC function for invite code lookup
|
||||
-- This only returns success/failure, not couple data
|
||||
CREATE OR REPLACE FUNCTION public.verify_invite_code(code TEXT)
|
||||
RETURNS JSONB AS $$
|
||||
DECLARE
|
||||
couple_record RECORD;
|
||||
member_count INTEGER;
|
||||
BEGIN
|
||||
-- Normalize the code
|
||||
code := UPPER(TRIM(code));
|
||||
|
||||
-- Check if code exists
|
||||
SELECT id INTO couple_record
|
||||
FROM public.couples
|
||||
WHERE UPPER(invite_code) = code;
|
||||
|
||||
IF couple_record.id IS NULL THEN
|
||||
RETURN jsonb_build_object('valid', false, 'error', 'Invalid invite code');
|
||||
END IF;
|
||||
|
||||
-- Check member count
|
||||
SELECT COUNT(*) INTO member_count
|
||||
FROM public.profiles
|
||||
WHERE couple_id = couple_record.id;
|
||||
|
||||
IF member_count >= 2 THEN
|
||||
RETURN jsonb_build_object('valid', false, 'error', 'This couple already has two partners');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('valid', true, 'couple_id', couple_record.id);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Grant execute permission to authenticated users
|
||||
GRANT EXECUTE ON FUNCTION public.verify_invite_code(TEXT) TO authenticated;
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 3. Optional: Add password policy comment
|
||||
-- (Password length is enforced client-side, but should also be in Supabase Auth config)
|
||||
-- ============================================
|
||||
|
||||
COMMENT ON SCHEMA public IS 'Password policy: Minimum 8 characters required. Configure in Supabase Dashboard > Auth > Policies';
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 4. Protect push tokens from being accessed by other users
|
||||
-- Push tokens should only be accessible via service role for notifications
|
||||
-- ============================================
|
||||
|
||||
-- Create a secure view for partner profile access that excludes sensitive fields
|
||||
CREATE OR REPLACE VIEW public.partner_profiles AS
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
avatar_url,
|
||||
is_premium,
|
||||
couple_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
show_explicit_content
|
||||
-- Explicitly excluding: push_token
|
||||
FROM public.profiles;
|
||||
|
||||
-- Allow authenticated users to query the view
|
||||
GRANT SELECT ON public.partner_profiles TO authenticated;
|
||||
|
||||
-- Add comment explaining the security rationale
|
||||
COMMENT ON VIEW public.partner_profiles IS
|
||||
'Secure view of profiles excluding push_token. Use this view when displaying partner information. Push tokens are only accessible via service role for sending notifications.';
|
||||
|
||||
-- Create a function to safely get partner info without exposing push_token
|
||||
CREATE OR REPLACE FUNCTION public.get_partner_profile()
|
||||
RETURNS JSONB AS $$
|
||||
DECLARE
|
||||
current_couple_id UUID;
|
||||
partner_data JSONB;
|
||||
BEGIN
|
||||
-- Get current user's couple_id
|
||||
SELECT couple_id INTO current_couple_id
|
||||
FROM public.profiles
|
||||
WHERE id = auth.uid();
|
||||
|
||||
IF current_couple_id IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- Get partner profile without sensitive fields
|
||||
SELECT jsonb_build_object(
|
||||
'id', p.id,
|
||||
'name', p.name,
|
||||
'avatar_url', p.avatar_url,
|
||||
'is_premium', p.is_premium,
|
||||
'couple_id', p.couple_id,
|
||||
'created_at', p.created_at
|
||||
) INTO partner_data
|
||||
FROM public.profiles p
|
||||
WHERE p.couple_id = current_couple_id
|
||||
AND p.id != auth.uid();
|
||||
|
||||
RETURN partner_data;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_partner_profile() TO authenticated;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Allow super admins to manage subscriptions manually
|
||||
-- This enables the "Grant Premium" feature in the admin dashboard
|
||||
|
||||
-- Allow super admins to insert new manual subscriptions
|
||||
CREATE POLICY "Super admins can insert subscriptions"
|
||||
ON public.subscriptions FOR INSERT
|
||||
WITH CHECK (public.is_super_admin());
|
||||
|
||||
-- Allow super admins to update subscriptions (e.g. extending expiry)
|
||||
CREATE POLICY "Super admins can update subscriptions"
|
||||
ON public.subscriptions FOR UPDATE
|
||||
USING (public.is_super_admin());
|
||||
|
||||
-- Allow super admins to delete subscriptions (e.g. revoking access)
|
||||
CREATE POLICY "Super admins can delete subscriptions"
|
||||
ON public.subscriptions FOR DELETE
|
||||
USING (public.is_super_admin());
|
||||
@@ -0,0 +1,115 @@
|
||||
|
||||
-- Ensure gender column exists on profiles (if not already present)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'profiles' AND column_name = 'gender') THEN
|
||||
ALTER TABLE public.profiles ADD COLUMN gender TEXT CHECK (gender IN ('male', 'female', 'non-binary', 'prefer-not-to-say'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add allowed_couple_genders to questions
|
||||
ALTER TABLE public.questions
|
||||
ADD COLUMN IF NOT EXISTS allowed_couple_genders TEXT[] DEFAULT NULL;
|
||||
|
||||
COMMENT ON COLUMN public.questions.allowed_couple_genders IS 'Array of allowed gender pairings (e.g., ["female+male", "female+female"]). Null means all allowed.';
|
||||
|
||||
-- Update get_recommended_questions to respect gender context
|
||||
DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
text TEXT,
|
||||
pack_id UUID,
|
||||
intensity INT,
|
||||
partner_answered BOOLEAN,
|
||||
allowed_couple_genders TEXT[]
|
||||
) LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_couple_id UUID;
|
||||
v_user_gender TEXT;
|
||||
v_partner_gender TEXT;
|
||||
v_couple_type TEXT;
|
||||
BEGIN
|
||||
v_user_id := auth.uid();
|
||||
|
||||
-- Get user info including gender
|
||||
SELECT p.couple_id, p.gender INTO v_couple_id, v_user_gender
|
||||
FROM public.profiles p
|
||||
WHERE p.id = v_user_id;
|
||||
|
||||
IF v_couple_id IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Get partner gender
|
||||
SELECT p.gender INTO v_partner_gender
|
||||
FROM public.profiles p
|
||||
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
|
||||
LIMIT 1;
|
||||
|
||||
-- Determine couple type
|
||||
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
|
||||
-- Sort genders to create a consistent type string
|
||||
-- We assume binary genders for the primary logic as requested: male/male, male/female, female/female
|
||||
IF v_user_gender < v_partner_gender THEN
|
||||
v_couple_type := v_user_gender || '+' || v_partner_gender;
|
||||
ELSE
|
||||
v_couple_type := v_partner_gender || '+' || v_user_gender;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
WITH user_config_exists AS (
|
||||
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
|
||||
),
|
||||
active_packs AS (
|
||||
-- Case 0: Specific pack requested
|
||||
SELECT target_pack_id AS pack_id
|
||||
WHERE target_pack_id IS NOT NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Case 1: No specific pack, config exists -> use enabled packs
|
||||
SELECT cp.pack_id
|
||||
FROM public.couple_packs cp
|
||||
WHERE target_pack_id IS NULL
|
||||
AND cp.couple_id = v_couple_id AND cp.enabled = TRUE
|
||||
AND EXISTS (SELECT 1 FROM user_config_exists)
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Case 2: No specific pack, no config -> use all public packs
|
||||
SELECT p.id AS pack_id
|
||||
FROM public.question_packs p
|
||||
WHERE target_pack_id IS NULL
|
||||
AND p.is_public = TRUE
|
||||
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
|
||||
)
|
||||
SELECT
|
||||
q.id,
|
||||
q.text,
|
||||
q.pack_id,
|
||||
q.intensity,
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.responses r
|
||||
WHERE r.question_id = q.id
|
||||
AND r.couple_id = v_couple_id
|
||||
AND r.user_id != v_user_id
|
||||
) AS partner_answered,
|
||||
q.allowed_couple_genders
|
||||
FROM public.questions q
|
||||
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
|
||||
AND (
|
||||
q.allowed_couple_genders IS NULL
|
||||
OR v_couple_type IS NULL
|
||||
OR v_couple_type = ANY(q.allowed_couple_genders)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.responses r
|
||||
WHERE r.question_id = q.id
|
||||
AND r.user_id = v_user_id
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
Reference in new issue
Block a user