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
456459bb00
commit
5b97bc6f56
60 files changed
+1440
-1974
No files matched your search
@@ -2,4 +2,12 @@ VITE_SUPABASE_URL=your_supabase_url
|
||||
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
VITE_OPENAI_API_KEY=your_openai_api_key
|
||||
VITE_OPENROUTER_API_KEY=your_openrouter_api_key
|
||||
|
||||
# AI Model Configuration
|
||||
# VITE_AI_MODEL is the fallback for all operations
|
||||
VITE_AI_MODEL=openai/gpt-4o-mini
|
||||
|
||||
# Granular model overrides (optional - each falls back to VITE_AI_MODEL)
|
||||
# VITE_AI_MODEL_GENERATE - for pack/question/category generation
|
||||
# VITE_AI_MODEL_FIX - for fix targets and fix text analysis
|
||||
# VITE_AI_MODEL_POLISH - for polishing/tidying up text
|
||||
@@ -11,9 +11,13 @@ services:
|
||||
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
|
||||
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
|
||||
- VITE_AI_MODEL=${VITE_AI_MODEL}
|
||||
- VITE_AI_MODEL_GENERATE=${VITE_AI_MODEL_GENERATE}
|
||||
- VITE_AI_MODEL_FIX=${VITE_AI_MODEL_FIX}
|
||||
- VITE_AI_MODEL_POLISH=${VITE_AI_MODEL_POLISH}
|
||||
volumes:
|
||||
- ./src:/app/apps/admin/src
|
||||
- ./index.html:/app/apps/admin/index.html
|
||||
- ./vite.config.ts:/app/apps/admin/vite.config.ts
|
||||
- ./tsconfig.json:/app/apps/admin/tsconfig.json
|
||||
- ../../packages/shared/src:/app/packages/shared/src
|
||||
restart: unless-stopped
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,8 +11,8 @@ import {
|
||||
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 { Sparkles, Loader2, RefreshCw, Check, CheckSquare, Square } from 'lucide-react';
|
||||
import { generatePack, generateQuestions, suggestCategories, suggestPacks, TONE_LEVELS, INTENSITY_LEVELS, type GeneratedPack, type GeneratedQuestion, type GeneratedCategoryIdea, type GeneratedPackIdea, type ToneLevel } from '@/lib/openai';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -22,8 +23,10 @@ interface AiGeneratorDialogProps {
|
||||
context?: {
|
||||
categoryName?: string;
|
||||
packName?: string;
|
||||
packDescription?: string | null;
|
||||
existingCategories?: string[];
|
||||
existingPacks?: string[];
|
||||
isExplicit?: boolean;
|
||||
};
|
||||
onGenerated: (result: any) => void;
|
||||
}
|
||||
@@ -37,7 +40,16 @@ export function AiGeneratorDialog({
|
||||
}: AiGeneratorDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [count, setCount] = useState(10);
|
||||
const [isExplicit, setIsExplicit] = useState(false);
|
||||
// Default tone based on pack's explicit flag (5 for explicit packs, 3 for non-explicit)
|
||||
const [tone, setTone] = useState<ToneLevel>(context?.isExplicit ? 5 : 3);
|
||||
const isExplicit = tone >= 4; // For other generators that still use boolean
|
||||
|
||||
// Update tone when dialog opens with new context
|
||||
useEffect(() => {
|
||||
if (open && type === 'questions') {
|
||||
setTone(context?.isExplicit ? 5 : 3);
|
||||
}
|
||||
}, [open, context?.isExplicit, type]);
|
||||
|
||||
// Generated results
|
||||
const [generatedPack, setGeneratedPack] = useState<GeneratedPack | null>(null);
|
||||
@@ -45,6 +57,9 @@ export function AiGeneratorDialog({
|
||||
const [generatedIdeas, setGeneratedIdeas] = useState<GeneratedCategoryIdea[]>([]);
|
||||
const [generatedPackIdeas, setGeneratedPackIdeas] = useState<GeneratedPackIdea[]>([]);
|
||||
|
||||
// Selection state for questions
|
||||
const [selectedQuestionIndices, setSelectedQuestionIndices] = useState<Set<number>>(new Set());
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -56,8 +71,10 @@ export function AiGeneratorDialog({
|
||||
toast.error('Pack name is required');
|
||||
return;
|
||||
}
|
||||
const result = await generateQuestions(context.packName, count, undefined, isExplicit);
|
||||
const result = await generateQuestions(context.packName, count, undefined, tone, context.packDescription || undefined);
|
||||
setGeneratedQuestions(result);
|
||||
// Select all questions by default
|
||||
setSelectedQuestionIndices(new Set(result.map((_, i) => i)));
|
||||
} else if (type === 'category-ideas') {
|
||||
const result = await suggestCategories(context?.existingCategories || [], isExplicit);
|
||||
setGeneratedIdeas(result);
|
||||
@@ -81,16 +98,43 @@ export function AiGeneratorDialog({
|
||||
if (type === 'pack' && generatedPack) {
|
||||
onGenerated(generatedPack);
|
||||
} else if (type === 'questions' && generatedQuestions.length > 0) {
|
||||
onGenerated(generatedQuestions);
|
||||
// Only pass selected questions
|
||||
const selectedQuestions = generatedQuestions.filter((_, i) => selectedQuestionIndices.has(i));
|
||||
if (selectedQuestions.length === 0) {
|
||||
toast.error('Please select at least one question');
|
||||
return;
|
||||
}
|
||||
onGenerated(selectedQuestions);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleQuestionSelection = (index: number) => {
|
||||
setSelectedQuestionIndices(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAllQuestions = () => {
|
||||
setSelectedQuestionIndices(new Set(generatedQuestions.map((_, i) => i)));
|
||||
};
|
||||
|
||||
const deselectAllQuestions = () => {
|
||||
setSelectedQuestionIndices(new Set());
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset state
|
||||
setGeneratedPack(null);
|
||||
setGeneratedQuestions([]);
|
||||
setGeneratedIdeas([]);
|
||||
setGeneratedPackIdeas([]);
|
||||
setSelectedQuestionIndices(new Set());
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -130,13 +174,38 @@ export function AiGeneratorDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explicit Toggle */}
|
||||
{(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack' || type === 'questions') && (
|
||||
{/* Tone Selector for Questions */}
|
||||
{type === 'questions' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Content Tone</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TONE_LEVELS.map((t) => (
|
||||
<Button
|
||||
key={t.level}
|
||||
type="button"
|
||||
variant={tone === t.level ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTone(t.level as ToneLevel)}
|
||||
className={tone === t.level ? 'ring-2 ring-offset-1' : ''}
|
||||
title={t.description}
|
||||
>
|
||||
{t.level}. {t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{TONE_LEVELS.find(t => t.level === tone)?.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explicit Toggle for other generators */}
|
||||
{(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack') && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="explicit-mode"
|
||||
checked={isExplicit}
|
||||
onCheckedChange={setIsExplicit}
|
||||
onCheckedChange={(checked) => setTone(checked ? 5 : 3)}
|
||||
/>
|
||||
<Label htmlFor="explicit-mode">Suggest Explicit Ideas</Label>
|
||||
</div>
|
||||
@@ -149,8 +218,11 @@ export function AiGeneratorDialog({
|
||||
</div>
|
||||
)}
|
||||
{context?.packName && (
|
||||
<div className="rounded-md bg-muted p-3 text-sm">
|
||||
<strong>Pack:</strong> {context.packName}
|
||||
<div className="rounded-md bg-muted p-3 text-sm space-y-1">
|
||||
<div><strong>Pack:</strong> {context.packName}</div>
|
||||
{context.packDescription && (
|
||||
<div className="text-muted-foreground text-xs">{context.packDescription}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -185,19 +257,66 @@ export function AiGeneratorDialog({
|
||||
{/* Generated Questions Preview */}
|
||||
{generatedQuestions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium">Generated Questions ({generatedQuestions.length})</h4>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">
|
||||
Generated Questions ({selectedQuestionIndices.size}/{generatedQuestions.length} selected)
|
||||
</h4>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={selectAllQuestions}
|
||||
disabled={selectedQuestionIndices.size === generatedQuestions.length}
|
||||
>
|
||||
<CheckSquare className="mr-1 h-3 w-3" />
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={deselectAllQuestions}
|
||||
disabled={selectedQuestionIndices.size === 0}
|
||||
>
|
||||
<Square className="mr-1 h-3 w-3" />
|
||||
None
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
key={i}
|
||||
className={`rounded-lg border bg-card p-3 text-sm cursor-pointer transition-colors ${
|
||||
selectedQuestionIndices.has(i)
|
||||
? 'border-primary ring-1 ring-primary/20'
|
||||
: 'opacity-60 hover:opacity-80'
|
||||
}`}
|
||||
onClick={() => toggleQuestionSelection(i)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedQuestionIndices.has(i)}
|
||||
onCheckedChange={() => toggleQuestionSelection(i)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<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 text-white ${INTENSITY_LEVELS[q.intensity - 1]?.color || 'bg-muted'}`}
|
||||
title={INTENSITY_LEVELS[q.intensity - 1]?.description}
|
||||
>
|
||||
{q.intensity} - {INTENSITY_LEVELS[q.intensity - 1]?.label || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -217,7 +336,10 @@ export function AiGeneratorDialog({
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{idea.icon}</span>
|
||||
<span className="font-medium">{idea.name}</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>
|
||||
@@ -262,9 +384,9 @@ export function AiGeneratorDialog({
|
||||
Regenerate
|
||||
</Button>
|
||||
{type !== 'category-ideas' && type !== 'category-pack-ideas' && (
|
||||
<Button onClick={handleUse}>
|
||||
<Button onClick={handleUse} disabled={type === 'questions' && selectedQuestionIndices.size === 0}>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Use This
|
||||
{type === 'questions' ? `Use Selected (${selectedQuestionIndices.size})` : 'Use This'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Define local compatible interface
|
||||
@@ -22,6 +22,7 @@ interface Question {
|
||||
partner_text: string | null;
|
||||
intensity: number;
|
||||
allowed_couple_genders: string[] | null;
|
||||
target_user_genders: string[] | null;
|
||||
}
|
||||
|
||||
interface FixTargetsDialogProps {
|
||||
@@ -38,13 +39,58 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
|
||||
const [step, setStep] = useState<'initial' | 'review'>('initial');
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
// Check if couple targets represent "All" (null, empty, or all three values)
|
||||
const isAllCoupleTargets = (targets: string[] | null): boolean => {
|
||||
if (!targets || targets.length === 0) return true;
|
||||
if (targets.length === 3) {
|
||||
const sorted = [...targets].sort();
|
||||
return sorted[0] === 'female+female' && sorted[1] === 'female+male' && sorted[2] === 'male+male';
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if initiator targets represent "All" (null or empty)
|
||||
const isAllInitiator = (targets: string[] | null): boolean => {
|
||||
return !targets || targets.length === 0;
|
||||
};
|
||||
|
||||
// Check if two arrays are equal
|
||||
const arraysEqual = (a: string[] | null, b: string[] | null): boolean => {
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((val, i) => val === sortedB[i]);
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
const results = await analyzeQuestionTargets(questions);
|
||||
setSuggestions(results);
|
||||
// Filter out redundant suggestions where nothing meaningful changes
|
||||
const meaningfulResults = results.filter(suggestion => {
|
||||
const question = questions.find(q => q.id === suggestion.id);
|
||||
if (!question) return false;
|
||||
|
||||
// Check if couple targets change meaningfully
|
||||
const currentCoupleIsAll = isAllCoupleTargets(question.allowed_couple_genders);
|
||||
const suggestedCoupleIsAll = isAllCoupleTargets(suggestion.suggested_targets);
|
||||
const coupleChanged = !(currentCoupleIsAll && suggestedCoupleIsAll) &&
|
||||
!arraysEqual(question.allowed_couple_genders, suggestion.suggested_targets);
|
||||
|
||||
// Check if initiator changes meaningfully
|
||||
const currentInitiatorIsAll = isAllInitiator(question.target_user_genders);
|
||||
const suggestedInitiatorIsAll = isAllInitiator(suggestion.suggested_initiator);
|
||||
const initiatorChanged = !(currentInitiatorIsAll && suggestedInitiatorIsAll) &&
|
||||
!arraysEqual(question.target_user_genders, suggestion.suggested_initiator);
|
||||
|
||||
// Include if either changed
|
||||
return coupleChanged || initiatorChanged;
|
||||
});
|
||||
setSuggestions(meaningfulResults);
|
||||
// Default select all
|
||||
setSelectedIds(new Set(results.map(r => r.id)));
|
||||
setSelectedIds(new Set(meaningfulResults.map(r => r.id)));
|
||||
setStep('review');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -63,12 +109,12 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply updates in parallel
|
||||
// Apply updates in parallel with audit logging
|
||||
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)
|
||||
auditedSupabase.update('questions', update.id, {
|
||||
allowed_couple_genders: update.suggested_targets && update.suggested_targets.length > 0 ? update.suggested_targets : null,
|
||||
target_user_genders: update.suggested_initiator && update.suggested_initiator.length > 0 ? update.suggested_initiator : null,
|
||||
})
|
||||
));
|
||||
|
||||
toast.success(`Updated ${updates.length} questions`);
|
||||
@@ -103,6 +149,11 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
|
||||
return targets.map(t => t.replace('male+male', 'M+M').replace('female+male', 'M+F').replace('female+female', 'F+F')).join(', ');
|
||||
};
|
||||
|
||||
const formatInitiator = (targets: string[] | null) => {
|
||||
if (!targets || targets.length === 0) return 'Any';
|
||||
return targets.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(', ');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
@@ -163,8 +214,8 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead>Current</TableHead>
|
||||
<TableHead>Suggested</TableHead>
|
||||
<TableHead>Couples</TableHead>
|
||||
<TableHead>Initiator</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -190,19 +241,28 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
|
||||
</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">
|
||||
<TableCell className="text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
{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">
|
||||
{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">
|
||||
{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">
|
||||
{formatInitiator(suggestion.suggested_initiator)}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{suggestion.reason}
|
||||
</TableCell>
|
||||
|
||||
@@ -1,15 +1,55 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
import { supabase } from '@/config';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface PackInfo {
|
||||
category_id: string | null;
|
||||
category_name: string | null;
|
||||
}
|
||||
|
||||
// Generate breadcrumbs based on current path and params
|
||||
function useBreadcrumbs(): BreadcrumbItem[] {
|
||||
function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const [packInfo, setPackInfo] = useState<PackInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Fetch pack info when on questions page to get category_id
|
||||
useEffect(() => {
|
||||
const fetchPackInfo = async () => {
|
||||
if (params.packId && !params.categoryId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('question_packs')
|
||||
.select('category_id, categories(name)')
|
||||
.eq('id', params.packId)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setPackInfo({
|
||||
category_id: data.category_id,
|
||||
category_name: (data.categories as any)?.name || null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pack info for breadcrumbs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setPackInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPackInfo();
|
||||
}, [params.packId, params.categoryId]);
|
||||
|
||||
const pathSegments = location.pathname.split('/').filter(Boolean);
|
||||
const breadcrumbs: BreadcrumbItem[] = [];
|
||||
@@ -23,6 +63,13 @@ function useBreadcrumbs(): BreadcrumbItem[] {
|
||||
breadcrumbs.push({ label: 'Categories', href: '/categories' });
|
||||
} else if (segment === 'packs' && params.categoryId) {
|
||||
breadcrumbs.push({ label: 'Packs', href: currentPath });
|
||||
} else if (segment === 'packs' && params.packId && packInfo?.category_id) {
|
||||
// On questions page, link back to category's packs
|
||||
breadcrumbs.push({ label: 'Categories', href: '/categories' });
|
||||
breadcrumbs.push({
|
||||
label: packInfo.category_name || 'Packs',
|
||||
href: `/categories/${packInfo.category_id}/packs`
|
||||
});
|
||||
} else if (segment === 'questions' && params.packId) {
|
||||
breadcrumbs.push({ label: 'Questions', href: currentPath });
|
||||
} else if (segment === 'users') {
|
||||
@@ -44,13 +91,13 @@ function useBreadcrumbs(): BreadcrumbItem[] {
|
||||
breadcrumbs[breadcrumbs.length - 1].href = undefined;
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
return { breadcrumbs, loading };
|
||||
}
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const breadcrumbs = useBreadcrumbs();
|
||||
const { breadcrumbs, loading } = useBreadcrumbs();
|
||||
|
||||
if (breadcrumbs.length === 0) return null;
|
||||
if (breadcrumbs.length === 0 && !loading) return null;
|
||||
|
||||
return (
|
||||
<nav className="flex items-center space-x-1 text-sm text-muted-foreground">
|
||||
|
||||
+329
-60
@@ -25,8 +25,26 @@ const getOpenAI = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const getModel = () => {
|
||||
return import.meta.env.VITE_AI_MODEL || 'gpt-4o-mini';
|
||||
type ModelPurpose = 'generate' | 'fix' | 'polish';
|
||||
|
||||
const getModel = (purpose?: ModelPurpose) => {
|
||||
// Granular model selection by purpose
|
||||
// Falls back to VITE_AI_MODEL, then to default
|
||||
const defaultModel = 'gpt-4o-mini';
|
||||
const fallback = import.meta.env.VITE_AI_MODEL || defaultModel;
|
||||
|
||||
if (!purpose) return fallback;
|
||||
|
||||
switch (purpose) {
|
||||
case 'generate':
|
||||
return import.meta.env.VITE_AI_MODEL_GENERATE || fallback;
|
||||
case 'fix':
|
||||
return import.meta.env.VITE_AI_MODEL_FIX || fallback;
|
||||
case 'polish':
|
||||
return import.meta.env.VITE_AI_MODEL_POLISH || fallback;
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export interface GeneratedPack {
|
||||
@@ -70,7 +88,7 @@ export async function generatePack(categoryName?: string, explicit: boolean = fa
|
||||
Make it romantic, playful, and relationship-focused.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -88,25 +106,141 @@ export async function generatePack(categoryName?: string, explicit: boolean = fa
|
||||
return JSON.parse(content) as GeneratedPack;
|
||||
}
|
||||
|
||||
// Tone levels for content generation (controls language/explicitness)
|
||||
export const TONE_LEVELS = [
|
||||
{ level: 0, label: 'Clean', description: 'No romance or intimacy - communication, activities, fun' },
|
||||
{ level: 1, label: 'Romantic', description: 'Sweet, loving, emotional connection focus' },
|
||||
{ level: 2, label: 'Playful', description: 'Flirty, teasing, fun and lighthearted' },
|
||||
{ level: 3, label: 'Sensual', description: 'Intimate, passionate, suggestive but tasteful' },
|
||||
{ level: 4, label: 'Spicy', description: 'Bold, adventurous, mildly explicit' },
|
||||
{ level: 5, label: 'Explicit', description: 'Raw, graphic, NSFW content' },
|
||||
] as const;
|
||||
|
||||
export type ToneLevel = 0 | 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
// Intensity levels for questions (controls physical intimacy level)
|
||||
// This guide helps AI consistently grade question intensity
|
||||
export const INTENSITY_GUIDE = `
|
||||
INTENSITY GRADING GUIDE - Use this to assign intensity levels consistently:
|
||||
|
||||
Level 1 - LIGHT (Emotional/Verbal):
|
||||
- Talking, sharing feelings, compliments, emotional vulnerability
|
||||
- Non-physical activities: writing letters, planning dates, sharing dreams
|
||||
- Examples: "Share your biggest dream with your partner", "Write a love note", "Tell your partner what you love most about them"
|
||||
|
||||
Level 2 - MILD (Light Physical):
|
||||
- Light physical affection: holding hands, hugging, cuddling
|
||||
- Gentle kisses, playing with hair, light touches
|
||||
- Non-sexual physical closeness: dancing together, sleeping in each other's arms
|
||||
- Examples: "Give your partner a forehead kiss", "Cuddle while watching a movie", "Hold hands during dinner"
|
||||
|
||||
Level 3 - MODERATE (Sensual/Intimate):
|
||||
- Making out, passionate kissing, intimate massage
|
||||
- Sensual touch over clothes, caressing, teasing
|
||||
- Showering together (non-sexual), skinny dipping
|
||||
- Suggestive activities that build tension without explicit sexual acts
|
||||
- Examples: "Give your partner a sensual massage", "Make out in an unexpected place", "Slow dance intimately"
|
||||
|
||||
Level 4 - SPICY (Sexual without Penetration):
|
||||
- Oral sex, manual stimulation, mutual masturbation
|
||||
- Foreplay activities, using hands/mouth on genitals
|
||||
- Using toys externally, edging, teasing to climax
|
||||
- Nakedness with sexual intent
|
||||
- Examples: "Give your partner oral", "Touch your partner until they climax", "Use a vibrator on your partner"
|
||||
|
||||
Level 5 - INTENSE (Penetration/Advanced):
|
||||
- Vaginal or anal penetration (sex, toys, fingers)
|
||||
- Kinks: bondage, role-play with power dynamics, BDSM
|
||||
- Group activities, exhibitionism, voyeurism
|
||||
- Any activity involving penetration or advanced kinks
|
||||
- Examples: "Have sex in a new location", "Try anal play", "Tie your partner up and have your way with them"
|
||||
|
||||
IMPORTANT: Intensity is about the PHYSICAL INTIMACY level, not the language used.
|
||||
A question can be intensity 5 with tasteful language (non-explicit pack) or crude language (explicit pack).
|
||||
`;
|
||||
|
||||
// Condensed version for prompts (to save tokens)
|
||||
export const INTENSITY_GUIDE_SHORT = `
|
||||
INTENSITY LEVELS:
|
||||
1 (Light): Talking, emotional sharing, compliments, non-physical activities
|
||||
2 (Mild): Holding hands, hugging, cuddling, gentle kisses, light touch
|
||||
3 (Moderate): Making out, sensual massage, passionate kissing, intimate but not sexual
|
||||
4 (Spicy): Oral sex, manual stimulation, foreplay, sexual touching without penetration
|
||||
5 (Intense): Penetration (vaginal/anal), advanced kinks, BDSM, group activities
|
||||
`;
|
||||
|
||||
// Intensity levels for UI display (consistent with AI grading)
|
||||
export const INTENSITY_LEVELS = [
|
||||
{ level: 1, label: 'Light', description: 'Talking, emotional sharing, compliments, non-physical', color: 'bg-green-500' },
|
||||
{ level: 2, label: 'Mild', description: 'Holding hands, hugging, cuddling, gentle kisses', color: 'bg-lime-500' },
|
||||
{ level: 3, label: 'Moderate', description: 'Making out, sensual massage, passionate kissing', color: 'bg-yellow-500' },
|
||||
{ level: 4, label: 'Spicy', description: 'Oral, manual stimulation, foreplay, sexual without penetration', color: 'bg-orange-500' },
|
||||
{ level: 5, label: 'Intense', description: 'Penetration, advanced kinks, BDSM, group activities', color: 'bg-red-500' },
|
||||
] as const;
|
||||
|
||||
export async function generateQuestions(
|
||||
packName: string,
|
||||
count: number = 10,
|
||||
intensity?: number,
|
||||
explicit: boolean = false
|
||||
tone: ToneLevel = 3,
|
||||
packDescription?: string
|
||||
): 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 isClean = tone === 0;
|
||||
const isExplicit = tone >= 4;
|
||||
|
||||
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 intensityInstruction = isClean
|
||||
? 'All questions should be intensity level 1 (non-physical activities).'
|
||||
: intensity
|
||||
? `All questions should be at intensity level ${intensity}.`
|
||||
: 'Vary the intensity levels from 1 to 5 across the questions for good variety.';
|
||||
|
||||
const prompt = `Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".
|
||||
const toneInstructions: Record<ToneLevel, string> = {
|
||||
0: 'TONE: Completely clean and non-romantic. Focus on communication, teamwork, fun activities, personal growth, and bonding WITHOUT any romantic or sexual undertones. Think conversation starters, bucket list activities, games, challenges, and getting to know each other better. NO flirting, NO romance, NO intimacy references.',
|
||||
1: 'TONE: Romantic and sweet. Focus on emotional connection, love, and tenderness. Keep content clean and wholesome - NO sexual content whatsoever. Think date nights, compliments, and heartfelt moments.',
|
||||
2: 'TONE: Playful and flirty. Light teasing, fun activities, and cheeky suggestions. Mildly suggestive but NOTHING explicit or sexual. Think playful banter and innocent mischief.',
|
||||
3: 'TONE: Sensual and intimate. Passionate and suggestive content that implies intimacy without being graphic. Use tasteful language - "make love" rather than crude terms. Romantic but with heat. AVOID explicit sexual acts or crude terminology.',
|
||||
4: 'TONE: Spicy and bold. Adventurous sexual content with direct but not crude language. Can include kinks, fantasies, and explicit acts but described tastefully. Use terms like "oral", "bondage" etc.',
|
||||
5: 'TONE: Explicit and raw. NSFW content with graphic, direct sexual terminology. Be crude and uninhibited. Use explicit terms freely. This is adult content - do not hold back or euphemize.',
|
||||
};
|
||||
|
||||
${intensityGuide}
|
||||
${explicitInstruction}
|
||||
const toneInstruction = toneInstructions[tone];
|
||||
|
||||
// Use different examples based on tone level
|
||||
const symmetricExamples = isClean
|
||||
? 'GOOD: "Cook a new recipe together", "Play a board game", "Go on a hike", "Have a deep conversation about your goals".'
|
||||
: isExplicit
|
||||
? 'GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together".'
|
||||
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share dreams", "Give each other massages", "Dance together at home".';
|
||||
|
||||
const asymmetricExamples = isClean
|
||||
? `text (The Doer): Active command/proposal (e.g., "Teach your partner something new", "Plan a surprise activity").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Learn something new from your partner", "Be surprised with an activity").
|
||||
- GOOD: "Cook your partner their favorite meal" / "Have your partner cook your favorite meal".`
|
||||
: isExplicit
|
||||
? `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".`
|
||||
: `text (The Doer): Active command/proposal (e.g., "Write a love letter to your partner", "Plan a surprise date").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date").
|
||||
- GOOD: "Give your partner a massage" / "Receive a massage from your partner".`;
|
||||
|
||||
const explicitWarning = isClean
|
||||
? '\n\nCRITICAL: This is a CLEAN pack with NO romantic or sexual content. Focus ONLY on communication, activities, challenges, and bonding. NO romance, NO flirting, NO intimacy, NO physical affection beyond friendly gestures.'
|
||||
: isExplicit
|
||||
? ''
|
||||
: '\n\nCRITICAL: This is a NON-EXPLICIT pack. Do NOT include any sexual acts, crude language, or NSFW content. Keep all proposals romantic, playful, or emotionally intimate without being sexually explicit.';
|
||||
|
||||
const descriptionContext = packDescription
|
||||
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
|
||||
: '';
|
||||
|
||||
const prompt = `Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".${descriptionContext}
|
||||
|
||||
${INTENSITY_GUIDE_SHORT}
|
||||
|
||||
${intensityInstruction}
|
||||
${toneInstruction}${explicitWarning}
|
||||
|
||||
IMPORTANT: The app uses a swipe-based interface (Like/Dislike/Maybe).
|
||||
Cards should be "Proposals" relative to a specific action, rather than interview questions.
|
||||
@@ -116,35 +250,41 @@ For each "question", strictly decide if it is a SYMMETRIC activity (shared) or A
|
||||
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".
|
||||
- ${symmetricExamples}
|
||||
- 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").
|
||||
- text: The action proposal.
|
||||
- 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".
|
||||
- ${asymmetricExamples}
|
||||
- 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
|
||||
- intensity: A number 1-5 based on the INTENSITY LEVELS guide above. Grade based on PHYSICAL INTIMACY, not language.
|
||||
Examples: "Share a dream" = 1, "Cuddle" = 2, "Massage" = 3, "Oral sex" = 4, "Have sex" = 5
|
||||
|
||||
Return a JSON object with a "questions" array containing these question objects.
|
||||
Mix distinct symmetric and asymmetric proposals.`;
|
||||
|
||||
const systemMessages: Record<ToneLevel, string> = {
|
||||
0: 'You are a content writer for a couples app focused on communication, activities, and bonding. Generate clean, non-romantic content like conversation starters, fun challenges, and activities. No romance or intimacy. Always respond with valid JSON only.',
|
||||
1: 'You are a romantic content writer for a couples relationship app. Generate sweet, wholesome content that helps couples connect emotionally. Always respond with valid JSON only.',
|
||||
2: 'You are a playful content writer for a couples relationship app. Generate fun, flirty content that helps couples have fun together. Always respond with valid JSON only.',
|
||||
3: 'You are a sensual content writer for a couples relationship app. Generate passionate, intimate content that helps couples explore their desires tastefully. Always respond with valid JSON only.',
|
||||
4: 'You are a bold content writer for an adult couples app. Generate spicy, adventurous content that helps couples explore kinks and fantasies. Always respond with valid JSON only.',
|
||||
5: '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.',
|
||||
};
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('generate'),
|
||||
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.',
|
||||
content: systemMessages[tone],
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
@@ -171,7 +311,7 @@ export async function generateCategory(): Promise<{ name: string; description: s
|
||||
Be creative and think of categories that help couples explore different aspects of their relationship.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -191,6 +331,7 @@ export async function generateCategory(): Promise<{ name: string; description: s
|
||||
|
||||
export interface GeneratedCategoryIdea {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@@ -208,18 +349,19 @@ export async function suggestCategories(
|
||||
: '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)
|
||||
- description: Brief description of what packs in this category explore (1 sentence)
|
||||
- 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(),
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -271,7 +413,7 @@ export async function suggestPacks(
|
||||
Make them engaging and specific to "${categoryName}".`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -299,7 +441,7 @@ export async function polishContent(
|
||||
|
||||
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.';
|
||||
: 'CRITICAL: This is a NON-EXPLICIT pack. Keep it clean, romantic, and playful. Do NOT add any sexual acts, crude language, or NSFW content. If the original text contains explicit content, replace with tasteful romantic alternatives.';
|
||||
|
||||
const contextMap = {
|
||||
question: 'a question for a couples app',
|
||||
@@ -339,7 +481,7 @@ export async function polishContent(
|
||||
- polished: The improved text string`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('polish'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -361,11 +503,12 @@ export async function polishContent(
|
||||
export interface TargetAnalysis {
|
||||
id: string;
|
||||
suggested_targets: string[] | null;
|
||||
suggested_initiator: string[] | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export async function analyzeQuestionTargets(
|
||||
questions: { id: string; text: string; partner_text?: string | null; allowed_couple_genders: string[] | null }[]
|
||||
questions: { id: string; text: string; partner_text?: string | null; allowed_couple_genders: string[] | null; target_user_genders: string[] | null }[]
|
||||
): Promise<TargetAnalysis[]> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
@@ -373,38 +516,63 @@ export async function analyzeQuestionTargets(
|
||||
id: q.id,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text,
|
||||
current_targets: q.allowed_couple_genders
|
||||
current_targets: q.allowed_couple_genders,
|
||||
current_initiator: q.target_user_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 prompt = `Analyze the following questions for a couples app and suggest:
|
||||
1. "allowed_couple_genders" (which couple types can see this question)
|
||||
2. "target_user_genders" (who should see this question FIRST as initiator - only relevant for two-part questions with partner_text)
|
||||
|
||||
COUPLE TARGET OPTIONS: 'male+male', 'female+male', 'female+female'
|
||||
INITIATOR OPTIONS: 'male', 'female', 'non-binary'
|
||||
|
||||
RULES FOR COUPLE TARGETS (allowed_couple_genders):
|
||||
- Default to ALL (null) 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).
|
||||
|
||||
Examples:
|
||||
- "Blowjob" -> Needs penis -> Exclude F+F -> ['male+male', 'female+male']
|
||||
- "Lick pussy" -> Needs vagina -> Exclude M+M -> ['female+male', 'female+female']
|
||||
|
||||
RULES FOR INITIATOR (target_user_genders):
|
||||
- ONLY relevant for TWO-PART questions (those with partner_text set).
|
||||
- Analyze the "text" field to determine who should see this card FIRST as the initiator.
|
||||
- Consider WHO the action is TYPICALLY intended for, not just who CAN physically perform it.
|
||||
- In M+F couples, consider the typical heterosexual context of sexual acts.
|
||||
- If text implies a specific gender role, set initiator accordingly.
|
||||
- If text is truly gender-neutral AND role-neutral, leave as null.
|
||||
|
||||
Key considerations for initiator:
|
||||
1. Does the "text" describe receiving something from a penis? -> Female initiator (in M+F context)
|
||||
2. Does the "text" describe an action typically done BY someone with a penis (e.g., "my cum", "cum on you")? -> Male initiator
|
||||
3. Does the "text" describe receiving penetration? -> Consider who is typically penetrated
|
||||
4. Is the action truly symmetric with no gendered context? -> null
|
||||
|
||||
Examples:
|
||||
- text: "Swallow your partner's cum" -> Receiving cum requires partner has penis, so in M+F this is female-oriented -> initiator: ['female']
|
||||
- text: "Lick my cum off your body" -> "my cum" implies initiator produces cum (has penis) -> initiator: ['male']
|
||||
- text: "Deep throat your partner" -> Requires partner has penis, so receiver-focused -> initiator: ['female'] for M+F
|
||||
- text: "Give your partner a massage" -> Truly gender-neutral -> initiator: null
|
||||
- text: "Let her ride you" / partner_text: "Ride your partner" -> "her" implies female partner -> initiator: ['male']
|
||||
- text: "Finger your partner" / partner_text: "Be fingered" -> Receiver needs vagina -> suggested_targets: exclude M+M, initiator: null (either can finger)
|
||||
|
||||
Return a JSON object with an "analysis" array containing objects for questions where you have a recommendation.
|
||||
Include ALL fields even if null. Include a "reason" string explaining your suggestions.
|
||||
|
||||
Structure:
|
||||
{
|
||||
"analysis": [
|
||||
{ "id": "...", "suggested_targets": ["male+male", "female+male"], "suggested_initiator": ["male"], "reason": "Text implies male initiator with female partner" }
|
||||
]
|
||||
}
|
||||
|
||||
Questions:
|
||||
${JSON.stringify(simplifiedQuestions)}
|
||||
`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel(),
|
||||
model: getModel('fix'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -422,3 +590,104 @@ export async function analyzeQuestionTargets(
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.analysis || [];
|
||||
}
|
||||
|
||||
export interface TextAnalysis {
|
||||
id: string;
|
||||
suggested_text: string;
|
||||
suggested_partner_text: string | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export async function analyzeQuestionText(
|
||||
questions: { id: string; text: string; partner_text?: string | null }[],
|
||||
isExplicit: boolean = false
|
||||
): Promise<TextAnalysis[]> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
const simplifiedQuestions = questions.map(q => ({
|
||||
id: q.id,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text,
|
||||
}));
|
||||
|
||||
const toneInstruction = isExplicit
|
||||
? 'TONE: This is an EXPLICIT pack. Use raw, direct, graphic language. Do NOT sanitize or euphemize explicit content. Keep crude terms like "cum", "fuck", "cock", etc. if they appear in the original.'
|
||||
: 'TONE: This is a NON-EXPLICIT pack. CRITICAL: Do NOT include any sexual acts, crude language, or NSFW content. Keep language romantic, sensual, or playful without crude/graphic terms. If explicit sexual content appears, replace with tasteful romantic alternatives or flag for removal.';
|
||||
|
||||
// Use different examples based on explicit/non-explicit
|
||||
const symmetricExamples = isExplicit
|
||||
? 'GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together", "Have a threesome"'
|
||||
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share your dreams", "Give each other massages", "Dance together at home"';
|
||||
|
||||
const asymmetricExamples = isExplicit
|
||||
? `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"`
|
||||
: `text (The Doer): Active command/proposal (e.g., "Write a love letter to your partner", "Plan a surprise date for your partner").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date by your partner").
|
||||
- GOOD: "Give your partner a massage" / "Receive a massage from your partner"`;
|
||||
|
||||
const prompt = `Analyze the following questions for a couples app and suggest improved text that follows our style guidelines.
|
||||
|
||||
${toneInstruction}
|
||||
|
||||
The app uses a swipe-based interface (Like/Dislike/Maybe).
|
||||
Cards should be "Proposals" relative to a specific action, NOT interview questions.
|
||||
|
||||
For each question, decide if it is a SYMMETRIC activity (shared) or ASYMMETRIC action (one-way).
|
||||
|
||||
1. SYMMETRIC Activities (Single Card - partner_text should be null):
|
||||
- Use for shared experiences where both partners do the same thing together.
|
||||
- Phrasing: Direct action proposal.
|
||||
- ${symmetricExamples}
|
||||
- BAD: "Have you ever thought about...", "Do you think we should...", "How about we..."
|
||||
|
||||
2. ASYMMETRIC Actions (Two-Part Card - needs both text and partner_text):
|
||||
- Use for actions where one partner does something TO the other.
|
||||
- ${asymmetricExamples}
|
||||
- BAD: "Would you like to...?" (Ambiguous who "me" is)
|
||||
|
||||
CRITICAL RULES:
|
||||
1. PRESERVE THE CORE ACTION - Do NOT change what the question is about. Only improve phrasing.
|
||||
2. Keep text SHORT and DIRECT - no explanatory parentheticals.
|
||||
3. Remove wishy-washy language: "Would you want to...", "Have you ever...", "Do you think...", "...fantasy"
|
||||
4. Make it direct and actionable.
|
||||
5. For asymmetric cards, ensure text is initiator-focused and partner_text is receiver-focused.
|
||||
6. Use "your partner" instead of "me", "you", "him", "her" to keep it gender-neutral.
|
||||
7. If a question is ALREADY well-phrased, do NOT include it in the results.
|
||||
8. NEVER add explanatory text in parentheses - keep the proposal clean and simple.
|
||||
|
||||
Return a JSON object with a "suggestions" array containing ONLY questions that need improvement.
|
||||
Each object should have:
|
||||
- id: The question ID
|
||||
- suggested_text: The improved text (short, direct, no parentheticals)
|
||||
- suggested_partner_text: The improved partner_text (or null if symmetric)
|
||||
- reason: Brief explanation of what was improved
|
||||
|
||||
Questions:
|
||||
${JSON.stringify(simplifiedQuestions)}
|
||||
`;
|
||||
|
||||
const systemMessage = isExplicit
|
||||
? 'You are an uninhibited content editor for an adult couples app. You improve question phrasing to be direct, raw, and explicit while preserving the original intent. Do not censor or euphemize. Always respond with valid JSON only.'
|
||||
: 'You are a professional content editor for a couples app. You improve question phrasing to be direct, actionable, and engaging while preserving the original intent. Always respond with valid JSON only.';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('fix'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemMessage
|
||||
},
|
||||
{ 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.suggestions || [];
|
||||
}
|
||||
@@ -14,14 +14,15 @@ import { format } from 'date-fns';
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
user_id: string;
|
||||
admin_user_id: string;
|
||||
admin_username: string | null;
|
||||
admin_role: string;
|
||||
action: string;
|
||||
table_name: string;
|
||||
record_id: string | null;
|
||||
old_data: Record<string, unknown> | null;
|
||||
new_data: Record<string, unknown> | null;
|
||||
old_values: Record<string, unknown> | null;
|
||||
new_values: Record<string, unknown> | null;
|
||||
changed_fields: string[] | null;
|
||||
created_at: string;
|
||||
user_email?: string;
|
||||
}
|
||||
|
||||
const actionColors: Record<string, string> = {
|
||||
@@ -81,9 +82,9 @@ export function AuditLogsPage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-40">Time</TableHead>
|
||||
<TableHead className="w-32">Admin</TableHead>
|
||||
<TableHead className="w-24">Action</TableHead>
|
||||
<TableHead>Table</TableHead>
|
||||
<TableHead>Record ID</TableHead>
|
||||
<TableHead>Changes</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -100,6 +101,10 @@ export function AuditLogsPage() {
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{format(new Date(log.created_at), 'MMM d, h:mm a')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm font-medium">{log.admin_username || 'Unknown'}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{log.admin_role?.replace('_', ' ')}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${actionColors[log.action] || 'bg-gray-500'} text-white`}>
|
||||
{log.action}
|
||||
@@ -108,21 +113,23 @@ export function AuditLogsPage() {
|
||||
<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 && (
|
||||
{log.action === 'DELETE' && log.old_values && (
|
||||
<code className="text-xs bg-red-50 text-red-700 px-2 py-1 rounded">
|
||||
Deleted: {JSON.stringify(log.old_data).substring(0, 100)}...
|
||||
Deleted: {JSON.stringify(log.old_values).substring(0, 100)}...
|
||||
</code>
|
||||
)}
|
||||
{log.action === 'INSERT' && log.new_data && (
|
||||
{log.action === 'INSERT' && log.new_values && (
|
||||
<code className="text-xs bg-green-50 text-green-700 px-2 py-1 rounded">
|
||||
Created: {JSON.stringify(log.new_data).substring(0, 100)}...
|
||||
Created: {JSON.stringify(log.new_values).substring(0, 100)}...
|
||||
</code>
|
||||
)}
|
||||
{log.action === 'UPDATE' && (
|
||||
{log.action === 'UPDATE' && log.changed_fields && (
|
||||
<code className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">
|
||||
Changed: {log.changed_fields.join(', ')}
|
||||
</code>
|
||||
)}
|
||||
{log.action === 'UPDATE' && !log.changed_fields && (
|
||||
<code className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">
|
||||
Updated record
|
||||
</code>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -153,12 +154,10 @@ export function AdminsPage() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('admin_users')
|
||||
.insert({
|
||||
user_id: selectedUser.id,
|
||||
role: selectedRole
|
||||
});
|
||||
const { error } = await auditedSupabase.insert('admin_users', {
|
||||
user_id: selectedUser.id,
|
||||
role: selectedRole
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -182,10 +181,7 @@ export function AdminsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('admin_users')
|
||||
.delete()
|
||||
.eq('user_id', admin.user_id);
|
||||
const { error } = await auditedSupabase.deleteBy('admin_users', 'user_id', admin.user_id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -108,7 +109,7 @@ export function CategoriesPage() {
|
||||
setEditingCategory(null);
|
||||
setFormData({
|
||||
name: idea.name,
|
||||
description: '',
|
||||
description: idea.description || '',
|
||||
icon: idea.icon,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
@@ -124,27 +125,22 @@ export function CategoriesPage() {
|
||||
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);
|
||||
const { error } = await auditedSupabase.update('categories', editingCategory.id, {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
const { error } = await auditedSupabase.insert('categories', {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
sort_order: categories.length,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Category created');
|
||||
@@ -166,10 +162,7 @@ export function CategoriesPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('categories')
|
||||
.delete()
|
||||
.eq('id', category.id);
|
||||
const { error } = await auditedSupabase.delete('categories', category.id);
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Category deleted');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Loader2, Crown, Eye, EyeOff } from 'lucide-react';
|
||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Loader2, Crown, Eye, EyeOff, Flame, Heart } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
@@ -36,6 +37,7 @@ interface QuestionPack {
|
||||
icon: string | null;
|
||||
is_premium: boolean;
|
||||
is_public: boolean;
|
||||
is_explicit: boolean;
|
||||
sort_order: number | null;
|
||||
category_id: string | null;
|
||||
created_at: string | null;
|
||||
@@ -59,6 +61,7 @@ export function PacksPage() {
|
||||
icon: '💕',
|
||||
is_premium: false,
|
||||
is_public: true,
|
||||
is_explicit: false,
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
@@ -126,6 +129,7 @@ export function PacksPage() {
|
||||
icon: '💕',
|
||||
is_premium: false,
|
||||
is_public: true,
|
||||
is_explicit: false,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
@@ -138,6 +142,7 @@ export function PacksPage() {
|
||||
icon: pack.icon || '💕',
|
||||
is_premium: pack.is_premium,
|
||||
is_public: pack.is_public,
|
||||
is_explicit: pack.is_explicit,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
@@ -151,31 +156,28 @@ export function PacksPage() {
|
||||
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);
|
||||
const { error } = await auditedSupabase.update('question_packs', editingPack.id, {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
is_premium: formData.is_premium,
|
||||
is_public: formData.is_public,
|
||||
is_explicit: formData.is_explicit,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
const { error } = await auditedSupabase.insert('question_packs', {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
is_premium: formData.is_premium,
|
||||
is_public: formData.is_public,
|
||||
is_explicit: formData.is_explicit,
|
||||
category_id: categoryId || null,
|
||||
sort_order: packs.length,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Pack created');
|
||||
@@ -197,10 +199,7 @@ export function PacksPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('question_packs')
|
||||
.delete()
|
||||
.eq('id', pack.id);
|
||||
const { error } = await auditedSupabase.delete('question_packs', pack.id);
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Pack deleted');
|
||||
@@ -361,6 +360,21 @@ export function PacksPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Explicit Content</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Contains adult or mature content
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.is_explicit}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData(d => ({ ...d, is_explicit: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -429,10 +443,26 @@ export function PacksPage() {
|
||||
Premium
|
||||
</Badge>
|
||||
)}
|
||||
{!pack.is_public && (
|
||||
{pack.is_public ? (
|
||||
<Badge variant="outline" className="border-green-500 text-green-600">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Published
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<EyeOff className="h-3 w-3 mr-1" />
|
||||
Hidden
|
||||
Draft
|
||||
</Badge>
|
||||
)}
|
||||
{pack.is_explicit ? (
|
||||
<Badge variant="outline" className="border-red-500 text-red-600">
|
||||
<Flame className="h-3 w-3 mr-1" />
|
||||
Explicit
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-pink-400 text-pink-500">
|
||||
<Heart className="h-3 w-3 mr-1" />
|
||||
Safe
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
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';
|
||||
@@ -25,16 +25,18 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Plus, Pencil, Trash2, Sparkles, Loader2, Users, ShieldCheck } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Sparkles, Loader2, Users } 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';
|
||||
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
|
||||
|
||||
interface QuestionPack {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_explicit: boolean;
|
||||
}
|
||||
|
||||
interface Question {
|
||||
@@ -44,11 +46,14 @@ interface Question {
|
||||
partner_text: string | null;
|
||||
intensity: number;
|
||||
allowed_couple_genders: string[] | null;
|
||||
target_user_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'];
|
||||
import { INTENSITY_LEVELS } from '@/lib/openai';
|
||||
|
||||
// Helper to get intensity info by level (1-indexed)
|
||||
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
|
||||
|
||||
export function QuestionsPage() {
|
||||
const { packId } = useParams<{ packId: string }>();
|
||||
@@ -57,7 +62,7 @@ export function QuestionsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [aiDialogOpen, setAiDialogOpen] = useState(false);
|
||||
const [fixTargetsOpen, setFixTargetsOpen] = useState(false);
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -67,11 +72,13 @@ export function QuestionsPage() {
|
||||
partner_text: string;
|
||||
intensity: number;
|
||||
allowed_couple_genders: string[];
|
||||
target_user_genders: string[];
|
||||
}>({
|
||||
text: '',
|
||||
partner_text: '',
|
||||
intensity: 1,
|
||||
allowed_couple_genders: [],
|
||||
target_user_genders: [],
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
@@ -82,7 +89,7 @@ export function QuestionsPage() {
|
||||
// Fetch pack info
|
||||
const { data: packData } = await supabase
|
||||
.from('question_packs')
|
||||
.select('id, name, icon')
|
||||
.select('id, name, description, icon, is_explicit')
|
||||
.eq('id', packId)
|
||||
.single();
|
||||
|
||||
@@ -111,7 +118,7 @@ export function QuestionsPage() {
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingQuestion(null);
|
||||
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [] });
|
||||
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -122,6 +129,7 @@ export function QuestionsPage() {
|
||||
partner_text: question.partner_text || '',
|
||||
intensity: question.intensity,
|
||||
allowed_couple_genders: question.allowed_couple_genders || [],
|
||||
target_user_genders: question.target_user_genders || [],
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
@@ -135,28 +143,25 @@ export function QuestionsPage() {
|
||||
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);
|
||||
const { error } = await auditedSupabase.update('questions', editingQuestion.id, {
|
||||
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,
|
||||
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
const { error } = await auditedSupabase.insert('questions', {
|
||||
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,
|
||||
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Question created');
|
||||
@@ -178,10 +183,7 @@ export function QuestionsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('questions')
|
||||
.delete()
|
||||
.eq('id', question.id);
|
||||
const { error } = await auditedSupabase.delete('questions', question.id);
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Question deleted');
|
||||
@@ -196,16 +198,15 @@ export function QuestionsPage() {
|
||||
// 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,
|
||||
}))
|
||||
);
|
||||
const { error } = await auditedSupabase.insert(
|
||||
'questions',
|
||||
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`);
|
||||
@@ -250,9 +251,9 @@ export function QuestionsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setFixTargetsOpen(true)}>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
Fix Targets
|
||||
<Button variant="outline" onClick={() => setReviewOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Review Questions
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAiDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
@@ -284,6 +285,7 @@ export function QuestionsPage() {
|
||||
<AIPolishButton
|
||||
text={formData.text}
|
||||
type="question"
|
||||
explicit={pack?.is_explicit ?? false}
|
||||
onPolished={(val) => setFormData(d => ({ ...d, text: val }))}
|
||||
/>
|
||||
</div>
|
||||
@@ -308,6 +310,7 @@ export function QuestionsPage() {
|
||||
<AIPolishButton
|
||||
text={formData.partner_text}
|
||||
type="partner_text"
|
||||
explicit={pack?.is_explicit ?? false}
|
||||
onPolished={(val) => setFormData(d => ({ ...d, partner_text: val }))}
|
||||
/>
|
||||
</div>
|
||||
@@ -326,16 +329,17 @@ export function QuestionsPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>Intensity Level</Label>
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
{INTENSITY_LEVELS.map((intensity) => (
|
||||
<Button
|
||||
key={level}
|
||||
key={intensity.level}
|
||||
type="button"
|
||||
variant={formData.intensity === level ? 'default' : 'outline'}
|
||||
variant={formData.intensity === intensity.level ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFormData(d => ({ ...d, intensity: level }))}
|
||||
className={formData.intensity === level ? intensityColors[level] : ''}
|
||||
onClick={() => setFormData(d => ({ ...d, intensity: intensity.level }))}
|
||||
className={formData.intensity === intensity.level ? intensity.color : ''}
|
||||
title={intensity.description}
|
||||
>
|
||||
{level} - {intensityLabels[level]}
|
||||
{intensity.level} - {intensity.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -375,6 +379,40 @@ export function QuestionsPage() {
|
||||
Select specific couple types to restrict this question. Leave all unselected to allow for everyone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Initiator Gender (optional)</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ id: 'male', label: 'Male' },
|
||||
{ id: 'female', label: 'Female' },
|
||||
{ id: 'non-binary', label: 'Non-binary' },
|
||||
].map((type) => {
|
||||
const isSelected = formData.target_user_genders.includes(type.id);
|
||||
return (
|
||||
<Button
|
||||
key={type.id}
|
||||
type="button"
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFormData(d => ({
|
||||
...d,
|
||||
target_user_genders: isSelected
|
||||
? d.target_user_genders.filter(id => id !== type.id)
|
||||
: [...d.target_user_genders, type.id]
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{type.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Who sees this question first? Their partner will see the Partner Text after they answer. Leave empty for anyone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -402,14 +440,15 @@ export function QuestionsPage() {
|
||||
open={aiDialogOpen}
|
||||
onOpenChange={setAiDialogOpen}
|
||||
type="questions"
|
||||
context={{ packName: pack?.name }}
|
||||
context={{ packName: pack?.name, packDescription: pack?.description, isExplicit: pack?.is_explicit }}
|
||||
onGenerated={handleAiGenerated}
|
||||
/>
|
||||
|
||||
<FixTargetsDialog
|
||||
open={fixTargetsOpen}
|
||||
onOpenChange={setFixTargetsOpen}
|
||||
<ReviewQuestionsDialog
|
||||
open={reviewOpen}
|
||||
onOpenChange={setReviewOpen}
|
||||
questions={questions}
|
||||
isExplicit={pack?.is_explicit ?? false}
|
||||
onUpdated={fetchData}
|
||||
/>
|
||||
|
||||
@@ -441,7 +480,8 @@ export function QuestionsPage() {
|
||||
<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">Couples</TableHead>
|
||||
<TableHead className="w-24">Initiator</TableHead>
|
||||
<TableHead className="w-24">Intensity</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -479,8 +519,24 @@ export function QuestionsPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${intensityColors[question.intensity]} text-white`}>
|
||||
{intensityLabels[question.intensity]}
|
||||
{question.target_user_genders && question.target_user_genders.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{question.target_user_genders.map(g => (
|
||||
<Badge key={g} variant="outline" className="text-[10px] px-1 py-0 h-5 border-orange-500 text-orange-500">
|
||||
{g.charAt(0).toUpperCase()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">Any</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={`${getIntensity(question.intensity).color} text-white`}
|
||||
title={getIntensity(question.intensity).description}
|
||||
>
|
||||
{getIntensity(question.intensity).label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -128,17 +129,15 @@ export function UserDetailPage() {
|
||||
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,
|
||||
});
|
||||
const { error } = await auditedSupabase.insert('subscriptions', {
|
||||
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;
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import { supabase } from '../config';
|
||||
export { supabase };
|
||||
|
||||
const resources = {
|
||||
question_packs: ['id', 'name', 'description', 'icon', 'is_premium', 'is_public', 'sort_order', 'created_at'],
|
||||
questions: ['id', 'pack_id', 'text', 'partner_text', 'intensity', 'created_at'],
|
||||
categories: ['id', 'name', 'description', 'icon', 'sort_order', 'created_at'],
|
||||
question_packs: ['id', 'name', 'description', 'icon', 'is_premium', 'is_public', 'is_explicit', 'sort_order', 'created_at'],
|
||||
questions: ['id', 'pack_id', 'text', 'partner_text', 'intensity', 'allowed_couple_genders', 'target_user_genders', 'created_at'],
|
||||
profiles: ['id', 'name', 'avatar_url', 'is_premium', 'couple_id', 'created_at'],
|
||||
couples: ['id', 'invite_code', 'created_at'],
|
||||
responses: ['id', 'user_id', 'question_id', 'couple_id', 'answer', 'created_at'],
|
||||
|
||||
@@ -1,4 +1,64 @@
|
||||
import { List, Datagrid, TextField, ReferenceField, NumberField, EditButton, Create, SimpleForm, TextInput, NumberInput, ReferenceInput, SelectInput, Edit } from 'react-admin';
|
||||
import {
|
||||
List,
|
||||
Datagrid,
|
||||
TextField,
|
||||
ReferenceField,
|
||||
NumberField,
|
||||
EditButton,
|
||||
Create,
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
ReferenceInput,
|
||||
SelectInput,
|
||||
Edit,
|
||||
CheckboxGroupInput,
|
||||
ArrayField,
|
||||
ChipField,
|
||||
SingleFieldList,
|
||||
BooleanField,
|
||||
FunctionField,
|
||||
} from 'react-admin';
|
||||
import { Typography, Box, Chip, Tooltip, IconButton } from '@mui/material';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
|
||||
const COUPLE_GENDER_CHOICES = [
|
||||
{ id: 'male+female', name: 'Male + Female' },
|
||||
{ id: 'male+male', name: 'Male + Male' },
|
||||
{ id: 'female+female', name: 'Female + Female' },
|
||||
];
|
||||
|
||||
const TARGET_USER_GENDER_CHOICES = [
|
||||
{ id: 'male', name: 'Male' },
|
||||
{ id: 'female', name: 'Female' },
|
||||
{ id: 'non-binary', name: 'Non-binary' },
|
||||
{ id: 'prefer-not-to-say', name: 'Prefer not to say' },
|
||||
];
|
||||
|
||||
const LabelWithTooltip = ({ label, tooltip }: { label: string; tooltip: string }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography component="span">{label}</Typography>
|
||||
<Tooltip title={tooltip} arrow placement="right">
|
||||
<IconButton size="small" sx={{ p: 0.25 }}>
|
||||
<HelpOutlineIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const GenderChips = ({ record, source }: { record?: any; source: string }) => {
|
||||
const values = record?.[source];
|
||||
if (!values || values.length === 0) {
|
||||
return <Chip label="All" size="small" variant="outlined" />;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{values.map((v: string) => (
|
||||
<Chip key={v} label={v} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuestionList = () => (
|
||||
<List>
|
||||
@@ -8,31 +68,85 @@ export const QuestionList = () => (
|
||||
<TextField source="name" />
|
||||
</ReferenceField>
|
||||
<NumberField source="intensity" />
|
||||
<FunctionField
|
||||
label="Two-part"
|
||||
render={(record: any) => record?.partner_text ? 'Yes' : 'No'}
|
||||
/>
|
||||
<FunctionField
|
||||
label="Target Users"
|
||||
render={(record: any) => {
|
||||
const values = record?.target_user_genders;
|
||||
if (!values || values.length === 0) return 'All';
|
||||
return values.join(', ');
|
||||
}}
|
||||
/>
|
||||
<EditButton />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
const QuestionForm = () => (
|
||||
<SimpleForm>
|
||||
<ReferenceInput source="pack_id" reference="question_packs">
|
||||
<SelectInput optionText="name" />
|
||||
</ReferenceInput>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}>
|
||||
Question Text
|
||||
</Typography>
|
||||
<TextInput
|
||||
source="text"
|
||||
multiline
|
||||
fullWidth
|
||||
helperText="Primary text shown to the initiator (first person to see this question)"
|
||||
/>
|
||||
<TextInput
|
||||
source="partner_text"
|
||||
multiline
|
||||
fullWidth
|
||||
helperText="Optional: Text shown to the partner after the initiator has answered. Leave blank for single-text questions."
|
||||
/>
|
||||
|
||||
<NumberInput source="intensity" min={1} max={5} />
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 3, mb: 1 }}>
|
||||
Targeting
|
||||
</Typography>
|
||||
|
||||
<CheckboxGroupInput
|
||||
source="target_user_genders"
|
||||
label={
|
||||
<LabelWithTooltip
|
||||
label="Show to (as initiator)"
|
||||
tooltip="The 'initiator' is the first person in the couple to see and answer a question. This filter controls which genders can be the initiator. Their partner will always see the question after the initiator answers, regardless of this setting."
|
||||
/>
|
||||
}
|
||||
choices={TARGET_USER_GENDER_CHOICES}
|
||||
helperText="Leave empty to show to all genders as initiator."
|
||||
/>
|
||||
|
||||
<CheckboxGroupInput
|
||||
source="allowed_couple_genders"
|
||||
label={
|
||||
<LabelWithTooltip
|
||||
label="Couple types"
|
||||
tooltip="Filters which couple compositions can see this question. A 'male+female' couple has one male and one female partner. This ensures questions are only shown to couples where the content is relevant to their relationship dynamic."
|
||||
/>
|
||||
}
|
||||
choices={COUPLE_GENDER_CHOICES}
|
||||
helperText="Leave empty to show to all couple types."
|
||||
/>
|
||||
</SimpleForm>
|
||||
);
|
||||
|
||||
export const QuestionEdit = () => (
|
||||
<Edit>
|
||||
<SimpleForm>
|
||||
<ReferenceInput source="pack_id" reference="question_packs">
|
||||
<SelectInput optionText="name" />
|
||||
</ReferenceInput>
|
||||
<TextInput source="text" multiline fullWidth />
|
||||
<NumberInput source="intensity" min={1} max={5} />
|
||||
</SimpleForm>
|
||||
<QuestionForm />
|
||||
</Edit>
|
||||
);
|
||||
|
||||
export const QuestionCreate = () => (
|
||||
<Create>
|
||||
<SimpleForm>
|
||||
<ReferenceInput source="pack_id" reference="question_packs">
|
||||
<SelectInput optionText="name" />
|
||||
</ReferenceInput>
|
||||
<TextInput source="text" multiline fullWidth />
|
||||
<NumberInput source="intensity" min={1} max={5} />
|
||||
</SimpleForm>
|
||||
<QuestionForm />
|
||||
</Create>
|
||||
);
|
||||
@@ -14,6 +14,7 @@ export default defineConfig({
|
||||
port: 3001,
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user