diff --git a/apps/admin/src/components/RealtimeStatusIndicator.tsx b/apps/admin/src/components/RealtimeStatusIndicator.tsx new file mode 100644 index 0000000..3b375b3 --- /dev/null +++ b/apps/admin/src/components/RealtimeStatusIndicator.tsx @@ -0,0 +1,32 @@ +import type { SubscriptionStatus } from '@/hooks/useRealtimeSubscription'; + +interface RealtimeStatusIndicatorProps { + status: SubscriptionStatus; + showLabel?: boolean; +} + +const statusConfig: Record = { + 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 ( +
+ + {status === 'SUBSCRIBED' && ( + + )} + + + {showLabel && {config.label}} +
+ ); +} diff --git a/apps/admin/src/components/content/ExtractTopicsDialog.tsx b/apps/admin/src/components/content/ExtractTopicsDialog.tsx new file mode 100644 index 0000000..98bb119 --- /dev/null +++ b/apps/admin/src/components/content/ExtractTopicsDialog.tsx @@ -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([]); + const [reasoning, setReasoning] = useState(''); + const [selectedTopics, setSelectedTopics] = useState>(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 ( + + + + + + Extract Topics + + + Use AI to identify topics and kinks for "{pack.name}" + + + +
+ {step === 'initial' ? ( +
+
+ +
+
+

Analyze pack for topics

+

+ 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. +

+
+ +
+ ) : ( +
+ {topics.length === 0 ? ( +
+ + No topics could be identified for this pack. +
+ ) : ( + <> +
+

Suggested Topics

+
+ {topics.map((topic) => ( + toggleSelection(topic.name)} + > + {selectedTopics.has(topic.name) ? ( + + ) : null} + {topic.name} + {topic.isNew && ( + + )} + + ))} +
+
+ +
+ Existing topic + + +
+ + New topic + +
+
+ + {reasoning && ( +
+

AI Reasoning

+

+ {reasoning} +

+
+ )} + + )} +
+ )} +
+ + + {step === 'review' && ( + <> + + + + )} + + +
+ ); +} diff --git a/apps/admin/src/components/content/FixTargetsDialog.tsx b/apps/admin/src/components/content/FixTargetsDialog.tsx index f4fa9ff..741f752 100644 --- a/apps/admin/src/components/content/FixTargetsDialog.tsx +++ b/apps/admin/src/components/content/FixTargetsDialog.tsx @@ -170,8 +170,8 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
{step === 'initial' ? (
-
- +
+

Ready to analyze {questions.length} questions

@@ -247,18 +247,18 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F {formatTargets(question.allowed_couple_genders)} - + {formatTargets(suggestion.suggested_targets)}
- + {formatInitiator(question.target_user_genders)} - + {formatInitiator(suggestion.suggested_initiator)}
diff --git a/apps/admin/src/components/content/FixTextDialog.tsx b/apps/admin/src/components/content/FixTextDialog.tsx index 104c524..3e689ef 100644 --- a/apps/admin/src/components/content/FixTextDialog.tsx +++ b/apps/admin/src/components/content/FixTextDialog.tsx @@ -113,8 +113,8 @@ export function FixTextDialog({ open, onOpenChange, questions, isExplicit = fals
{step === 'initial' ? (
-
- +
+

Ready to analyze {questions.length} questions

@@ -189,9 +189,9 @@ export function FixTextDialog({ open, onOpenChange, questions, isExplicit = fals -
{suggestion.suggested_text}
+
{suggestion.suggested_text}
{suggestion.suggested_partner_text && ( -
+
Partner: {suggestion.suggested_partner_text}
)} diff --git a/apps/admin/src/components/content/ReviewQuestionsDialog.tsx b/apps/admin/src/components/content/ReviewQuestionsDialog.tsx index e92aa05..2f27744 100644 --- a/apps/admin/src/components/content/ReviewQuestionsDialog.tsx +++ b/apps/admin/src/components/content/ReviewQuestionsDialog.tsx @@ -252,8 +252,8 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
{step === 'initial' ? (
-
- +
+

Ready to analyze {questions.length} questions

@@ -262,13 +262,13 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici

-
- - Text Phrasing +
+ + Text Phrasing
-
- - Audience Targeting +
+ + Audience Targeting