feat(admin): add NSFW flag to packs, remove intensity

Re-add is_explicit toggle on pack and dare pack forms labeled as
"NSFW". Show NSFW badge on pack cards and user enabled packs.

Remove all intensity/comfort level references from admin UI including
form fields, analytics charts, review dialog columns, AI generation
prompts/scoring, cherry-pick balancing, and settings toggles. The
database columns remain unchanged.
This commit is contained in:
jjbrunton committed 2026-01-27 22:50:46 +00:00
1 parent 14b0199625
commit e380c0a31c
32 files changed
+948 -1075

No files matched your search

+9 -37
View File
@@ -29,7 +29,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, Flame, Heart, ChevronUp, ChevronDown } from 'lucide-react';
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
// =============================================================================
@@ -52,9 +52,6 @@ interface DarePack {
is_explicit: boolean;
sort_order: number;
category_id: string | null;
min_intensity: number | null;
max_intensity: number | null;
avg_intensity: number | null;
created_at: string | null;
dare_count?: number;
}
@@ -296,15 +293,6 @@ export function DarePacksPage() {
}
};
const getIntensityColor = (avg: number | null) => {
if (avg === null) return 'bg-muted';
if (avg <= 1.5) return 'bg-green-500';
if (avg <= 2.5) return 'bg-lime-500';
if (avg <= 3.5) return 'bg-yellow-500';
if (avg <= 4.5) return 'bg-orange-500';
return 'bg-red-500';
};
// =============================================================================
// Render
// =============================================================================
@@ -446,9 +434,9 @@ export function DarePacksPage() {
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Explicit</Label>
<Label>NSFW</Label>
<p className="text-xs text-muted-foreground">
Contains adult/explicit content
Contains adult or mature content
</p>
</div>
<Switch
@@ -456,6 +444,7 @@ export function DarePacksPage() {
onCheckedChange={(checked) => form.setField('is_explicit', checked)}
/>
</div>
</div>
<DialogFooter>
@@ -522,6 +511,11 @@ export function DarePacksPage() {
</div>
</div>
<div className="flex items-center gap-1">
{pack.is_explicit && (
<Badge variant="destructive">
NSFW
</Badge>
)}
{pack.is_premium && (
<Badge variant="default" className="bg-amber-500">
<Crown className="h-3 w-3 mr-1" />
@@ -539,17 +533,6 @@ export function DarePacksPage() {
Draft
</Badge>
)}
{pack.is_explicit ? (
<Badge variant="outline" className="border-red-500 text-red-400">
<Flame className="h-3 w-3 mr-1" />
Explicit
</Badge>
) : (
<Badge variant="outline" className="border-pink-400 text-pink-300">
<Heart className="h-3 w-3 mr-1" />
Safe
</Badge>
)}
</div>
</div>
<div className="flex items-start justify-between">
@@ -574,17 +557,6 @@ export function DarePacksPage() {
<CardDescription className="line-clamp-2">
{pack.description || 'No description'}
</CardDescription>
{/* Intensity indicator */}
{pack.avg_intensity !== null && (
<div className="flex items-center gap-2 mt-2">
<Badge className={`${getIntensityColor(pack.avg_intensity)} text-white`}>
Avg: {pack.avg_intensity?.toFixed(1)}
</Badge>
<span className="text-xs text-muted-foreground">
Range: {pack.min_intensity ?? '?'} - {pack.max_intensity ?? '?'}
</span>
</div>
)}
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
+2 -9
View File
@@ -47,7 +47,6 @@ interface DarePack {
name: string;
description: string | null;
icon: string | null;
is_explicit: boolean;
}
interface Dare {
@@ -70,8 +69,6 @@ const DURATION_OPTIONS = [
{ value: '168', label: '1 week' },
];
const getDefaultIntensity = (isExplicit?: boolean) => (isExplicit ? 4 : 2);
// Format duration for display
const formatDuration = (hours: number | null): string => {
if (hours === null) return '—';
@@ -101,11 +98,9 @@ export function DaresPage() {
// Form state
const [formData, setFormData] = useState<{
text: string;
intensity: number;
suggested_duration_hours: string;
}>({
text: '',
intensity: getDefaultIntensity(),
suggested_duration_hours: '',
});
@@ -117,7 +112,7 @@ export function DaresPage() {
// Fetch pack info
const { data: packData } = await supabase
.from('dare_packs')
.select('id, name, description, icon, is_explicit')
.select('id, name, description, icon')
.eq('id', packId)
.single();
@@ -167,7 +162,7 @@ export function DaresPage() {
const openCreateDialog = () => {
setEditingDare(null);
setFormData({ text: '', intensity: getDefaultIntensity(pack?.is_explicit), suggested_duration_hours: '' });
setFormData({ text: '', suggested_duration_hours: '' });
setDialogOpen(true);
};
@@ -175,7 +170,6 @@ export function DaresPage() {
setEditingDare(dare);
setFormData({
text: dare.text,
intensity: dare.intensity,
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
});
setDialogOpen(true);
@@ -191,7 +185,6 @@ export function DaresPage() {
try {
const dareData = {
text: formData.text,
intensity: formData.intensity,
suggested_duration_hours: formData.suggested_duration_hours
? parseInt(formData.suggested_duration_hours, 10)
: null,
+6 -12
View File
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown, Clock, BellOff } from 'lucide-react';
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Tags, ChevronUp, ChevronDown, Clock, BellOff } from 'lucide-react';
import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
@@ -513,6 +513,11 @@ export function PacksPage() {
</div>
</div>
<div className="flex items-center gap-1">
{pack.is_explicit && (
<Badge variant="destructive">
NSFW
</Badge>
)}
{pack.is_premium && (
<Badge variant="default" className="bg-amber-500">
<Crown className="h-3 w-3 mr-1" />
@@ -542,17 +547,6 @@ export function PacksPage() {
Draft
</Badge>
)}
{pack.is_explicit ? (
<Badge variant="outline" className="border-red-500 text-red-400">
<Flame className="h-3 w-3 mr-1" />
Explicit
</Badge>
) : (
<Badge variant="outline" className="border-pink-400 text-pink-300">
<Heart className="h-3 w-3 mr-1" />
Safe
</Badge>
)}
</div>
</div>
<div className="flex items-start justify-between">
@@ -62,9 +62,6 @@ interface ChartDatum {
type SegmentKey = 'all' | 'premium' | 'free';
const intensityPalette = ['#22c55e', '#84cc16', '#facc15', '#f97316', '#ef4444'];
const intensityLabels = ['Gentle', 'Warm', 'Playful', 'Steamy', 'Intense'];
const QUESTION_TYPE_CONFIG: Record<QuestionType, { label: string; color: string }> = {
swipe: { label: 'Swipe', color: '#38bdf8' },
text_answer: { label: 'Text', color: '#f59e0b' },
@@ -101,11 +98,6 @@ export function QuestionAnalyticsPage() {
});
const [premiumBreakdown, setPremiumBreakdown] = useState<ChartDatum[]>([]);
const [questionTypeBreakdown, setQuestionTypeBreakdown] = useState<ChartDatum[]>([]);
const [intensityBreakdown, setIntensityBreakdown] = useState<Record<SegmentKey, ChartDatum[]>>({
all: [],
premium: [],
free: [],
});
const [topicBreakdown, setTopicBreakdown] = useState<Record<SegmentKey, BreakdownItem[]>>({
all: [],
premium: [],
@@ -121,7 +113,6 @@ export function QuestionAnalyticsPage() {
premium: [],
free: [],
});
const [intensitySegment, setIntensitySegment] = useState<SegmentKey>('all');
const [tagSegment, setTagSegment] = useState<SegmentKey>('all');
const [categorySegment, setCategorySegment] = useState<SegmentKey>('all');
const [packSegment, setPackSegment] = useState<SegmentKey>('all');
@@ -215,12 +206,6 @@ export function QuestionAnalyticsPage() {
packTopicsMap.set(relation.pack_id, topicsForPack);
});
const emptyIntensityCounts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
const intensityCounts: Record<SegmentKey, Record<number, number>> = {
all: { ...emptyIntensityCounts },
premium: { ...emptyIntensityCounts },
free: { ...emptyIntensityCounts },
};
const untaggedCounts: Record<SegmentKey, number> = { all: 0, premium: 0, free: 0 };
const questionTypeCounts: Record<QuestionType, number> = {
swipe: 0,
@@ -262,10 +247,6 @@ export function QuestionAnalyticsPage() {
freeCount += 1;
}
const intensity = question.intensity ?? 1;
intensityCounts.all[intensity] = (intensityCounts.all[intensity] || 0) + 1;
intensityCounts[segmentKey][intensity] = (intensityCounts[segmentKey][intensity] || 0) + 1;
const updatePackCounts = (key: SegmentKey) => {
const map = packCountsBySegment[key];
const packEntry = map.get(question.pack_id) || {
@@ -332,12 +313,6 @@ export function QuestionAnalyticsPage() {
color: config.color,
}));
const buildIntensityData = (key: SegmentKey) => intensityLabels.map((label, index) => ({
name: label,
value: intensityCounts[key][index + 1] || 0,
color: intensityPalette[index],
}));
const buildTopicData = (key: SegmentKey) => {
const data = Array.from(topicCountsBySegment[key].values());
if (untaggedCounts[key] > 0 || data.length === 0) {
@@ -348,12 +323,6 @@ export function QuestionAnalyticsPage() {
return data;
};
const intensityData = {
all: buildIntensityData('all'),
premium: buildIntensityData('premium'),
free: buildIntensityData('free'),
};
const topicData = {
all: buildTopicData('all'),
premium: buildTopicData('premium'),
@@ -385,7 +354,6 @@ export function QuestionAnalyticsPage() {
});
setPremiumBreakdown(premiumData);
setQuestionTypeBreakdown(questionTypeData);
setIntensityBreakdown(intensityData);
setTopicBreakdown(topicData);
setCategoryBreakdown(categoryData);
setPackBreakdown(packData);
@@ -427,7 +395,6 @@ export function QuestionAnalyticsPage() {
free: stats.totalQuestions ? ((stats.freeQuestions / stats.totalQuestions) * 100).toFixed(0) : '0',
};
const intensityData = intensityBreakdown[intensitySegment];
const tagData = topicBreakdown[tagSegment];
const categoryData = categoryBreakdown[categorySegment];
const packData = packBreakdown[packSegment];
@@ -447,7 +414,7 @@ export function QuestionAnalyticsPage() {
Question Analytics
</h1>
<p className="text-muted-foreground">
Deep dive into premium coverage, tags, categories, and intensity distribution
Deep dive into premium coverage, tags, categories, and question types
</p>
</div>
<div className="flex flex-col gap-2">
@@ -601,78 +568,6 @@ export function QuestionAnalyticsPage() {
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Intensity Distribution</CardTitle>
<CardDescription>
How questions are spread across intensity levels
</CardDescription>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{segmentOptions.map((option) => (
<Button
key={option.key}
variant={intensitySegment === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setIntensitySegment(option.key)}
className="rounded-full"
>
{option.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{segmentLabelByKey[intensitySegment]}: {totalBySegment[intensitySegment].toLocaleString()} questions ({segmentShareByKey[intensitySegment]}%)
</p>
</div>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-[300px] w-full" />
) : totalBySegment[intensitySegment] === 0 ? (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
No questions in this segment yet
</div>
) : (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={intensityData}>
<XAxis
dataKey="name"
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
<Bar dataKey="value" radius={[6, 6, 0, 0]}>
{intensityData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
</div>
<Card>
+2 -19
View File
@@ -49,7 +49,6 @@ interface QuestionPack {
name: string;
description: string | null;
icon: string | null;
is_explicit: boolean;
}
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
@@ -74,7 +73,6 @@ interface Question {
}
const DEFAULT_AUDIO_DURATION = 60;
const getDefaultIntensity = (isExplicit?: boolean) => (isExplicit ? 4 : 2);
const QUESTION_TYPE_OPTIONS: Array<{ value: QuestionType; label: string }> = [
{ value: 'swipe', label: 'Swipe' },
@@ -124,7 +122,6 @@ export function QuestionsPage() {
const [formData, setFormData] = useState<{
text: string;
partner_text: string;
intensity: number;
allowed_couple_genders: string[];
target_user_genders: string[];
required_props: string;
@@ -134,7 +131,6 @@ export function QuestionsPage() {
}>({
text: '',
partner_text: '',
intensity: getDefaultIntensity(),
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
@@ -156,7 +152,7 @@ export function QuestionsPage() {
const packPromise = supabase
.from('question_packs')
.select('id, name, description, icon, is_explicit')
.select('id, name, description, icon')
.eq('id', packId)
.single();
let questionsQuery = supabase
@@ -272,7 +268,6 @@ export function QuestionsPage() {
setFormData({
text: '',
partner_text: '',
intensity: getDefaultIntensity(pack?.is_explicit),
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
@@ -289,7 +284,6 @@ export function QuestionsPage() {
setFormData({
text: question.text,
partner_text: question.partner_text || '',
intensity: question.intensity,
allowed_couple_genders: question.allowed_couple_genders || [],
target_user_genders: question.target_user_genders || [],
required_props: question.required_props?.join(', ') ?? '',
@@ -326,7 +320,6 @@ export function QuestionsPage() {
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,
required_props: requiredPropsValue,
@@ -342,7 +335,6 @@ export function QuestionsPage() {
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,
required_props: requiredPropsValue,
@@ -384,7 +376,6 @@ export function QuestionsPage() {
const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number; inverse_pair_id?: string | null }>) => {
const generatedQuestionType: QuestionType = 'swipe';
const defaultIntensity = getDefaultIntensity(pack?.is_explicit);
// Bulk insert AI-generated questions with inverse_of linking
const insertQuestions = async () => {
@@ -411,7 +402,6 @@ export function QuestionsPage() {
pack_id: packId,
text: q.text,
partner_text: q.partner_text || null,
intensity: defaultIntensity,
inverse_of: null,
question_type: generatedQuestionType,
config: null,
@@ -431,7 +421,6 @@ export function QuestionsPage() {
pack_id: packId,
text: primary.text,
partner_text: primary.partner_text || null,
intensity: defaultIntensity,
inverse_of: null,
question_type: generatedQuestionType,
config: null,
@@ -449,7 +438,6 @@ export function QuestionsPage() {
pack_id: packId,
text: inverse.text,
partner_text: inverse.partner_text || null,
intensity: defaultIntensity,
inverse_of: primaryId,
question_type: generatedQuestionType,
config: null,
@@ -467,7 +455,6 @@ export function QuestionsPage() {
pack_id: packId,
text: extra.text,
partner_text: extra.partner_text || null,
intensity: defaultIntensity,
inverse_of: null,
question_type: generatedQuestionType,
config: null,
@@ -483,7 +470,6 @@ export function QuestionsPage() {
pack_id: packId,
text: pair[0].text,
partner_text: pair[0].partner_text || null,
intensity: defaultIntensity,
inverse_of: null,
question_type: generatedQuestionType,
config: null,
@@ -649,7 +635,6 @@ export function QuestionsPage() {
<AIPolishButton
text={formData.text}
type="question"
explicit={pack?.is_explicit ?? false}
onPolished={(val) => setFormData(d => ({ ...d, text: val }))}
/>
</div>
@@ -713,7 +698,6 @@ 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>
@@ -926,7 +910,7 @@ export function QuestionsPage() {
open={aiDialogOpen}
onOpenChange={setAiDialogOpen}
type="questions"
context={{ packName: pack?.name, packDescription: pack?.description, isExplicit: pack?.is_explicit, existingQuestions: questions.map(q => q.text) }}
context={{ packName: pack?.name, packDescription: pack?.description, existingQuestions: questions.map(q => q.text) }}
onGenerated={handleAiGenerated}
/>
@@ -934,7 +918,6 @@ export function QuestionsPage() {
open={reviewOpen}
onOpenChange={setReviewOpen}
questions={reviewQuestions}
isExplicit={pack?.is_explicit ?? false}
onUpdated={fetchData}
/>