mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
Update
This commit is contained in:
1 parent
d18f9d32b0
commit
f7eaf0adcf
24 files changed
+1826
-393
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import type { SubscriptionStatus } from '@/hooks/useRealtimeSubscription';
|
||||
|
||||
interface RealtimeStatusIndicatorProps {
|
||||
status: SubscriptionStatus;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
const statusConfig: Record<SubscriptionStatus, { color: string; pulseColor: string; label: string }> = {
|
||||
SUBSCRIBED: { color: 'bg-green-500', pulseColor: 'bg-green-400', label: 'Live' },
|
||||
SUBSCRIBING: { color: 'bg-yellow-500', pulseColor: 'bg-yellow-400', label: 'Connecting...' },
|
||||
CHANNEL_ERROR: { color: 'bg-red-500', pulseColor: 'bg-red-400', label: 'Error' },
|
||||
TIMED_OUT: { color: 'bg-orange-500', pulseColor: 'bg-orange-400', label: 'Timed out' },
|
||||
CLOSED: { color: 'bg-gray-500', pulseColor: 'bg-gray-400', label: 'Disconnected' },
|
||||
};
|
||||
|
||||
export function RealtimeStatusIndicator({ status, showLabel = false }: RealtimeStatusIndicatorProps) {
|
||||
const config = statusConfig[status];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
{status === 'SUBSCRIBED' && (
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full animate-ping rounded-full ${config.pulseColor} opacity-75`}
|
||||
/>
|
||||
)}
|
||||
<span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${config.color}`} />
|
||||
</span>
|
||||
{showLabel && <span className="text-xs text-muted-foreground">{config.label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
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 { Loader2, Tags, Check, Plus, X } from 'lucide-react';
|
||||
import { extractTopicsFromPack, ExtractedTopic } from '@/lib/openai';
|
||||
import { supabase } from '@/config';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ExtractTopicsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pack: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: ExtractTopicsDialogProps) {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [topics, setTopics] = useState<ExtractedTopic[]>([]);
|
||||
const [reasoning, setReasoning] = useState('');
|
||||
const [selectedTopics, setSelectedTopics] = useState<Set<string>>(new Set());
|
||||
const [step, setStep] = useState<'initial' | 'review'>('initial');
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
// Fetch questions for this pack
|
||||
const { data: questions, error: questionsError } = await supabase
|
||||
.from('questions')
|
||||
.select('text')
|
||||
.eq('pack_id', pack.id);
|
||||
|
||||
if (questionsError) throw questionsError;
|
||||
|
||||
if (!questions || questions.length === 0) {
|
||||
toast.error('This pack has no questions to analyze');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch existing topics
|
||||
const { data: existingTopics, error: topicsError } = await supabase
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
|
||||
if (topicsError) throw topicsError;
|
||||
|
||||
const result = await extractTopicsFromPack(
|
||||
pack.name,
|
||||
pack.description,
|
||||
questions.map(q => q.text),
|
||||
existingTopics || []
|
||||
);
|
||||
|
||||
setTopics(result.topics);
|
||||
setReasoning(result.reasoning);
|
||||
// Default select all
|
||||
setSelectedTopics(new Set(result.topics.map(t => t.name)));
|
||||
setStep('review');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Failed to analyze pack');
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
try {
|
||||
const selectedList = topics.filter(t => selectedTopics.has(t.name));
|
||||
if (selectedList.length === 0) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// First, create any new topics
|
||||
const newTopics = selectedList.filter(t => t.isNew);
|
||||
const existingTopicIds: string[] = selectedList
|
||||
.filter(t => !t.isNew && t.existingTopicId)
|
||||
.map(t => t.existingTopicId!);
|
||||
|
||||
if (newTopics.length > 0) {
|
||||
const { data: createdTopics, error: createError } = await supabase
|
||||
.from('topics')
|
||||
.insert(newTopics.map(t => ({ name: t.name })))
|
||||
.select('id');
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
if (createdTopics) {
|
||||
existingTopicIds.push(...createdTopics.map(t => t.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove existing pack_topics for this pack
|
||||
await supabase
|
||||
.from('pack_topics')
|
||||
.delete()
|
||||
.eq('pack_id', pack.id);
|
||||
|
||||
// Insert new pack_topics relationships
|
||||
if (existingTopicIds.length > 0) {
|
||||
const { error: linkError } = await supabase
|
||||
.from('pack_topics')
|
||||
.insert(existingTopicIds.map(topicId => ({
|
||||
pack_id: pack.id,
|
||||
topic_id: topicId,
|
||||
})));
|
||||
|
||||
if (linkError) throw linkError;
|
||||
}
|
||||
|
||||
toast.success(`Added ${selectedList.length} topic${selectedList.length !== 1 ? 's' : ''} to pack`);
|
||||
onUpdated();
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Failed to apply topics');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
// Reset state after transition
|
||||
setTimeout(() => {
|
||||
setStep('initial');
|
||||
setTopics([]);
|
||||
setReasoning('');
|
||||
setSelectedTopics(new Set());
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const toggleSelection = (name: string) => {
|
||||
const next = new Set(selectedTopics);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
setSelectedTopics(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Tags className="h-5 w-5 text-blue-500" />
|
||||
Extract Topics
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Use AI to identify topics and kinks for "{pack.name}"
|
||||
</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-blue-500/20 p-4 rounded-full">
|
||||
<Tags className="h-12 w-12 text-blue-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">Analyze pack for topics</h3>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
The AI will analyze the questions in this pack and suggest relevant topics
|
||||
like bondage, exhibitionism, romance, etc. It will prefer matching existing
|
||||
topics when possible.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-blue-600 hover:bg-blue-700">
|
||||
{analyzing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
'Start Analysis'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{topics.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<X className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
|
||||
No topics could be identified for this pack.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Suggested Topics</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topics.map((topic) => (
|
||||
<Badge
|
||||
key={topic.name}
|
||||
variant={selectedTopics.has(topic.name) ? 'default' : 'outline'}
|
||||
className={`cursor-pointer text-sm py-1.5 px-3 ${
|
||||
selectedTopics.has(topic.name)
|
||||
? topic.isNew
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
onClick={() => toggleSelection(topic.name)}
|
||||
>
|
||||
{selectedTopics.has(topic.name) ? (
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
) : null}
|
||||
{topic.name}
|
||||
{topic.isNew && (
|
||||
<Plus className="h-3 w-3 ml-1" />
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-blue-600" />
|
||||
Existing topic
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-green-600" />
|
||||
<Plus className="h-3 w-3" />
|
||||
New topic
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reasoning && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">AI Reasoning</h4>
|
||||
<p className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||
{reasoning}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{step === 'review' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setStep('initial')}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={applying || topics.length === 0 || selectedTopics.size === 0}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{applying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Applying...
|
||||
</>
|
||||
) : (
|
||||
`Apply ${selectedTopics.size} Topic${selectedTopics.size !== 1 ? 's' : ''}`
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -170,8 +170,8 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
|
||||
<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 className="bg-indigo-500/20 p-4 rounded-full">
|
||||
<ShieldCheck className="h-12 w-12 text-indigo-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
|
||||
@@ -247,18 +247,18 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
|
||||
{formatTargets(question.allowed_couple_genders)}
|
||||
</Badge>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100">
|
||||
<Badge variant="secondary" className="bg-indigo-500/30 text-indigo-300 hover:bg-indigo-500/30">
|
||||
{formatTargets(suggestion.suggested_targets)}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-muted-foreground border-orange-300">
|
||||
<Badge variant="outline" className="text-muted-foreground border-orange-500/50">
|
||||
{formatInitiator(question.target_user_genders)}
|
||||
</Badge>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="bg-orange-100 text-orange-700 hover:bg-orange-100">
|
||||
<Badge variant="secondary" className="bg-orange-500/30 text-orange-300 hover:bg-orange-500/30">
|
||||
{formatInitiator(suggestion.suggested_initiator)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -113,8 +113,8 @@ export function FixTextDialog({ open, onOpenChange, questions, isExplicit = fals
|
||||
<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-purple-50 p-4 rounded-full">
|
||||
<Wand2 className="h-12 w-12 text-purple-500" />
|
||||
<div className="bg-purple-500/20 p-4 rounded-full">
|
||||
<Wand2 className="h-12 w-12 text-purple-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
|
||||
@@ -189,9 +189,9 @@ export function FixTextDialog({ open, onOpenChange, questions, isExplicit = fals
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs">
|
||||
<div className="text-sm font-medium text-purple-700">{suggestion.suggested_text}</div>
|
||||
<div className="text-sm font-medium text-purple-300">{suggestion.suggested_text}</div>
|
||||
{suggestion.suggested_partner_text && (
|
||||
<div className="text-xs text-purple-600 mt-1 italic">
|
||||
<div className="text-xs text-purple-400 mt-1 italic">
|
||||
Partner: {suggestion.suggested_partner_text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -252,8 +252,8 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
<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-6">
|
||||
<div className="bg-amber-50 p-4 rounded-full">
|
||||
<Sparkles className="h-12 w-12 text-amber-500" />
|
||||
<div className="bg-amber-500/20 p-4 rounded-full">
|
||||
<Sparkles className="h-12 w-12 text-amber-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
|
||||
@@ -262,13 +262,13 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2 bg-purple-50 px-4 py-2 rounded-lg">
|
||||
<Wand2 className="h-5 w-5 text-purple-500" />
|
||||
<span className="text-sm font-medium">Text Phrasing</span>
|
||||
<div className="flex items-center gap-2 bg-purple-500/20 px-4 py-2 rounded-lg">
|
||||
<Wand2 className="h-5 w-5 text-purple-400" />
|
||||
<span className="text-sm font-medium text-purple-300">Text Phrasing</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-indigo-50 px-4 py-2 rounded-lg">
|
||||
<ShieldCheck className="h-5 w-5 text-indigo-500" />
|
||||
<span className="text-sm font-medium">Audience Targeting</span>
|
||||
<div className="flex items-center gap-2 bg-indigo-500/20 px-4 py-2 rounded-lg">
|
||||
<ShieldCheck className="h-5 w-5 text-indigo-400" />
|
||||
<span className="text-sm font-medium text-indigo-300">Audience Targeting</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-amber-600 hover:bg-amber-700">
|
||||
@@ -366,7 +366,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
{suggestion.textSuggestion ? (
|
||||
<div
|
||||
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTextIds.has(suggestion.questionId)
|
||||
? 'bg-purple-50 border-purple-300'
|
||||
? 'bg-purple-500/20 border-purple-500/50'
|
||||
: 'bg-muted/30 border-transparent hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => toggleTextSelection(suggestion.questionId)}
|
||||
@@ -378,11 +378,11 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-purple-700 font-medium">
|
||||
<div className="text-sm text-purple-300 font-medium">
|
||||
{suggestion.textSuggestion.suggested_text}
|
||||
</div>
|
||||
{suggestion.textSuggestion.suggested_partner_text && (
|
||||
<div className="text-xs text-purple-600 mt-1 italic">
|
||||
<div className="text-xs text-purple-400 mt-1 italic">
|
||||
Partner: {suggestion.textSuggestion.suggested_partner_text}
|
||||
</div>
|
||||
)}
|
||||
@@ -405,7 +405,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
{suggestion.targetSuggestion ? (
|
||||
<div
|
||||
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTargetIds.has(suggestion.questionId)
|
||||
? 'bg-indigo-50 border-indigo-300'
|
||||
? 'bg-indigo-500/20 border-indigo-500/50'
|
||||
: 'bg-muted/30 border-transparent hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => toggleTargetSelection(suggestion.questionId)}
|
||||
@@ -423,17 +423,17 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
{formatTargets(suggestion.question.allowed_couple_genders)}
|
||||
</Badge>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<Badge className="text-[10px] bg-indigo-100 text-indigo-700 hover:bg-indigo-100">
|
||||
<Badge className="text-[10px] bg-indigo-500/30 text-indigo-300 hover:bg-indigo-500/30">
|
||||
{formatTargets(suggestion.targetSuggestion.suggested_targets)}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Initiator */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground border-orange-300">
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground border-orange-500/50">
|
||||
{formatInitiator(suggestion.question.target_user_genders)}
|
||||
</Badge>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<Badge className="text-[10px] bg-orange-100 text-orange-700 hover:bg-orange-100">
|
||||
<Badge className="text-[10px] bg-orange-500/30 text-orange-300 hover:bg-orange-500/30">
|
||||
{formatInitiator(suggestion.targetSuggestion.suggested_initiator)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user