Add MCP server and enhance AI content tools

Implement self-hosted MCP server for admin actions, update AI generation tools in admin dashboard, and refine mobile app stores and components.
This commit is contained in:
jjbrunton committed 2026-01-10 19:24:06 +00:00
1 parent b0f1700e40
commit 4e40b92b16
71 files changed
+10511 -1008

No files matched your search

+201 -73
View File
@@ -66,17 +66,23 @@ export function AiSettingsPage() {
setFormData({
openrouter_api_key: config.openrouter_api_key || '',
default_model: config.default_model || '',
default_temperature: config.default_temperature,
model_generate: config.model_generate || '',
temperature_generate: config.temperature_generate,
model_fix: config.model_fix || '',
temperature_fix: config.temperature_fix,
model_polish: config.model_polish || '',
temperature_polish: config.temperature_polish,
council_enabled: config.council_enabled || false,
council_generator_model: config.council_generator_model || '',
council_generators: generators,
council_reviewer_model: config.council_reviewer_model || '',
council_reviewer_temperature: config.council_reviewer_temperature,
council_selection_mode: config.council_selection_mode || 'whole_set',
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_temperature: config.classifier_temperature,
classifier_prompt: config.classifier_prompt || '',
heuristics_enabled: config.heuristics_enabled ?? false,
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
@@ -164,17 +170,23 @@ export function AiSettingsPage() {
setFormData({
openrouter_api_key: config.openrouter_api_key || '',
default_model: config.default_model || '',
default_temperature: config.default_temperature,
model_generate: config.model_generate || '',
temperature_generate: config.temperature_generate,
model_fix: config.model_fix || '',
temperature_fix: config.temperature_fix,
model_polish: config.model_polish || '',
temperature_polish: config.temperature_polish,
council_enabled: config.council_enabled || false,
council_generator_model: config.council_generator_model || '',
council_generators: generators,
council_reviewer_model: config.council_reviewer_model || '',
council_reviewer_temperature: config.council_reviewer_temperature,
council_selection_mode: config.council_selection_mode || 'whole_set',
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_temperature: config.classifier_temperature,
classifier_prompt: config.classifier_prompt || '',
heuristics_enabled: config.heuristics_enabled ?? false,
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
@@ -208,8 +220,8 @@ export function AiSettingsPage() {
handleChange('council_generators', newGenerators);
};
const updateGeneratorModel = (index: number, model: string) => {
const newGenerators = generators.map((g, i) => i === index ? { ...g, model } : g);
const updateGenerator = (index: number, updates: Partial<CouncilGenerator>) => {
const newGenerators = generators.map((g, i) => i === index ? { ...g, ...updates } : g);
handleChange('council_generators', newGenerators);
};
@@ -367,16 +379,31 @@ export function AiSettingsPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="classifier-model">Classifier Model</Label>
<ModelCombobox
value={formData.classifier_model || ''}
onChange={(value) => handleChange('classifier_model', value)}
placeholder="openai/gpt-4o"
/>
<p className="text-xs text-muted-foreground">
Model used for classification (must support vision if image moderation is needed)
</p>
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="classifier-model">Classifier Model</Label>
<ModelCombobox
value={formData.classifier_model || ''}
onChange={(value) => handleChange('classifier_model', value)}
placeholder="openai/gpt-4o"
/>
<p className="text-xs text-muted-foreground">
Model used for classification (must support vision if image moderation is needed)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="classifier-temperature">Temp</Label>
<Input
id="classifier-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.classifier_temperature ?? ''}
onChange={(e) => handleChange('classifier_temperature', e.target.value === '' ? null : Number(e.target.value))}
placeholder="1.0"
/>
</div>
</div>
<div className="space-y-2">
@@ -624,16 +651,31 @@ export function AiSettingsPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="default-model">Default Model</Label>
<ModelCombobox
value={formData.default_model || ''}
onChange={(value) => handleChange('default_model', value)}
placeholder="openai/gpt-4o-mini"
/>
<p className="text-xs text-muted-foreground">
Fallback model when no specific model is configured
</p>
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="default-model">Default Model</Label>
<ModelCombobox
value={formData.default_model || ''}
onChange={(value) => handleChange('default_model', value)}
placeholder="openai/gpt-4o-mini"
/>
<p className="text-xs text-muted-foreground">
Fallback model when no specific model is configured
</p>
</div>
<div className="space-y-2">
<Label htmlFor="default-temperature">Temp</Label>
<Input
id="default-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.default_temperature ?? ''}
onChange={(e) => handleChange('default_temperature', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.7"
/>
</div>
</div>
<Separator />
@@ -663,16 +705,31 @@ export function AiSettingsPage() {
{!formData.council_enabled ? (
/* Single generator mode */
<div className="space-y-2">
<Label htmlFor="model-generate">Generation Model</Label>
<ModelCombobox
value={formData.model_generate || ''}
onChange={(value) => handleChange('model_generate', value)}
placeholder="Uses default model"
/>
<p className="text-xs text-muted-foreground">
For questions, category ideas, and pack ideas
</p>
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="model-generate">Generation Model</Label>
<ModelCombobox
value={formData.model_generate || ''}
onChange={(value) => handleChange('model_generate', value)}
placeholder="Uses default model"
/>
<p className="text-xs text-muted-foreground">
For questions, category ideas, and pack ideas
</p>
</div>
<div className="space-y-2">
<Label htmlFor="generate-temperature">Temp</Label>
<Input
id="generate-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.temperature_generate ?? ''}
onChange={(e) => handleChange('temperature_generate', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.9"
/>
</div>
</div>
) : (
/* Council mode - multiple generators + reviewer */
@@ -698,10 +755,21 @@ export function AiSettingsPage() {
</div>
<ModelCombobox
value={gen.model}
onChange={(value) => updateGeneratorModel(index, value)}
onChange={(value) => updateGenerator(index, { model: value })}
placeholder={index === 0 ? "anthropic/claude-3.5-sonnet" : "Select model..."}
className="flex-1"
/>
<Input
type="number"
step="0.1"
min="0"
max="2"
value={gen.temperature ?? ''}
onChange={(e) => updateGenerator(index, { temperature: e.target.value === '' ? undefined : Number(e.target.value) })}
placeholder="0.9"
className="w-[80px]"
aria-label={`Generator ${index + 1} temperature`}
/>
<Button
type="button"
variant="ghost"
@@ -717,16 +785,31 @@ export function AiSettingsPage() {
</div>
{/* Reviewer */}
<div className="space-y-2">
<Label htmlFor="council-reviewer" className="text-sm">Reviewer Model</Label>
<ModelCombobox
value={formData.council_reviewer_model || ''}
onChange={(value) => handleChange('council_reviewer_model', value)}
placeholder="google/gemini-pro-1.5"
/>
<p className="text-xs text-muted-foreground">
Reviews outputs and selects the best
</p>
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="council-reviewer" className="text-sm">Reviewer Model</Label>
<ModelCombobox
value={formData.council_reviewer_model || ''}
onChange={(value) => handleChange('council_reviewer_model', value)}
placeholder="google/gemini-pro-1.5"
/>
<p className="text-xs text-muted-foreground">
Reviews outputs and selects the best
</p>
</div>
<div className="space-y-2">
<Label htmlFor="reviewer-temperature" className="text-sm">Temp</Label>
<Input
id="reviewer-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.council_reviewer_temperature ?? ''}
onChange={(e) => handleChange('council_reviewer_temperature', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.3"
/>
</div>
</div>
{/* Selection Mode */}
@@ -794,16 +877,31 @@ export function AiSettingsPage() {
{/* Ideas Model - shown when council mode is ON (since council only applies to questions) */}
{formData.council_enabled && (
<div className="space-y-2">
<Label htmlFor="model-generate">Ideas Model</Label>
<ModelCombobox
value={formData.model_generate || ''}
onChange={(value) => handleChange('model_generate', value)}
placeholder="Uses default model"
/>
<p className="text-xs text-muted-foreground">
For category ideas and pack ideas (council mode only applies to questions)
</p>
<div className="grid grid-cols-[1fr,120px] gap-4">
<div className="space-y-2">
<Label htmlFor="model-generate">Ideas Model</Label>
<ModelCombobox
value={formData.model_generate || ''}
onChange={(value) => handleChange('model_generate', value)}
placeholder="Uses default model"
/>
<p className="text-xs text-muted-foreground">
For category ideas and pack ideas (council mode only applies to questions)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ideas-temperature">Temp</Label>
<Input
id="ideas-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.temperature_generate ?? ''}
onChange={(e) => handleChange('temperature_generate', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.9"
/>
</div>
</div>
)}
</div>
@@ -812,28 +910,58 @@ export function AiSettingsPage() {
{/* Other models - always visible */}
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="model-fix">Analysis Model</Label>
<ModelCombobox
value={formData.model_fix || ''}
onChange={(value) => handleChange('model_fix', value)}
placeholder="Uses default model"
/>
<p className="text-xs text-muted-foreground">
For analyzing & fixing content
</p>
<div className="grid grid-cols-[1fr,100px] gap-2">
<div className="space-y-2">
<Label htmlFor="model-fix">Analysis Model</Label>
<ModelCombobox
value={formData.model_fix || ''}
onChange={(value) => handleChange('model_fix', value)}
placeholder="Uses default model"
/>
<p className="text-xs text-muted-foreground">
For analyzing & fixing content
</p>
</div>
<div className="space-y-2">
<Label htmlFor="fix-temperature">Temp</Label>
<Input
id="fix-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.temperature_fix ?? ''}
onChange={(e) => handleChange('temperature_fix', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.5"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="model-polish">Polish Model</Label>
<ModelCombobox
value={formData.model_polish || ''}
onChange={(value) => handleChange('model_polish', value)}
placeholder="Uses default model"
/>
<p className="text-xs text-muted-foreground">
For polishing & improving text
</p>
<div className="grid grid-cols-[1fr,100px] gap-2">
<div className="space-y-2">
<Label htmlFor="model-polish">Polish Model</Label>
<ModelCombobox
value={formData.model_polish || ''}
onChange={(value) => handleChange('model_polish', value)}
placeholder="Uses default model"
/>
<p className="text-xs text-muted-foreground">
For polishing & improving text
</p>
</div>
<div className="space-y-2">
<Label htmlFor="polish-temperature">Temp</Label>
<Input
id="polish-temperature"
type="number"
step="0.1"
min="0"
max="2"
value={formData.temperature_polish ?? ''}
onChange={(e) => handleChange('temperature_polish', e.target.value === '' ? null : Number(e.target.value))}
placeholder="0.7"
/>
</div>
</div>
</div>
+93 -34
View File
@@ -3,11 +3,13 @@ import { Link } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { useEntityForm } from '@/hooks/useEntityForm';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
@@ -36,6 +38,7 @@ interface Category {
icon: string | null;
sort_order: number | null;
created_at: string | null;
is_public: boolean;
pack_count?: number;
}
@@ -43,6 +46,7 @@ interface CategoryFormData {
name: string;
description: string;
icon: string;
is_public: boolean;
}
// =============================================================================
@@ -60,11 +64,12 @@ export function CategoriesPage() {
// Form state using the new hook
const form = useEntityForm<CategoryFormData, Category>(
{ name: '', description: '', icon: 'bookmark-outline' },
{ name: '', description: '', icon: 'bookmark-outline', is_public: true },
(category) => ({
name: category.name,
description: category.description || '',
icon: category.icon || 'bookmark-outline',
is_public: category.is_public ?? true,
})
);
@@ -139,6 +144,7 @@ export function CategoriesPage() {
name: idea.name,
description: idea.description || '',
icon: idea.icon || 'bookmark-outline',
is_public: true,
});
};
@@ -154,6 +160,7 @@ export function CategoriesPage() {
name: form.formData.name,
description: form.formData.description || null,
icon: form.formData.icon || null,
is_public: form.formData.is_public,
};
if (form.editingItem) {
@@ -207,14 +214,19 @@ export function CategoriesPage() {
const currentOrder = category.sort_order ?? currentIndex;
const targetOrder = targetCategory.sort_order ?? targetIndex;
await Promise.all([
const [currentUpdate, targetUpdate] = await Promise.all([
auditedSupabase.update('categories', category.id, { sort_order: targetOrder }),
auditedSupabase.update('categories', targetCategory.id, { sort_order: currentOrder }),
]);
if (currentUpdate.error || targetUpdate.error) {
throw currentUpdate.error ?? targetUpdate.error ?? new Error('Failed to update category order');
}
// Optimistic update
const newCategories = [...categories];
[newCategories[currentIndex], newCategories[targetIndex]] = [newCategories[targetIndex], newCategories[currentIndex]];
newCategories[currentIndex] = { ...targetCategory, sort_order: currentOrder };
newCategories[targetIndex] = { ...category, sort_order: targetOrder };
setCategories(newCategories);
} catch (error) {
toast.error('Failed to reorder categories');
@@ -223,6 +235,27 @@ export function CategoriesPage() {
}
};
const handleToggleVisibility = async (category: Category) => {
try {
// Optimistic update
setCategories(categories.map(c =>
c.id === category.id ? { ...c, is_public: !c.is_public } : c
));
const { error } = await auditedSupabase.update('categories', category.id, {
is_public: !category.is_public
});
if (error) throw error;
toast.success(category.is_public ? 'Category hidden' : 'Category made public');
} catch (error) {
toast.error('Failed to update visibility');
console.error(error);
fetchCategories(); // Revert
}
};
// =============================================================================
// Render
// =============================================================================
@@ -321,6 +354,19 @@ export function CategoriesPage() {
rows={3}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Public</Label>
<p className="text-sm text-muted-foreground">
Visible to users in the app
</p>
</div>
<Switch
checked={form.formData.is_public}
onCheckedChange={(checked) => form.setField('is_public', checked)}
/>
</div>
</div>
<DialogFooter>
@@ -359,38 +405,60 @@ export function CategoriesPage() {
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{categories.map((category, index) => (
<Card key={category.id} className="group hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="flex flex-col opacity-0 group-hover:opacity-100 transition-opacity">
<Card key={category.id} className="group flex flex-col hover:border-primary/50 transition-colors">
<CardHeader className="flex-row items-start justify-between space-y-0 pb-2">
<div className="rounded-lg bg-primary/10 p-2.5">
<IconPreview value={category.icon} className="h-6 w-6 text-primary" />
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
{category.is_public ? 'Public' : 'Hidden'}
</span>
<Switch
checked={category.is_public}
onCheckedChange={() => handleToggleVisibility(category)}
className="scale-75 origin-right"
/>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-4">
<div className="space-y-1">
<CardTitle className="text-lg">{category.name}</CardTitle>
<CardDescription className="line-clamp-2 h-10">
{category.description || 'No description'}
</CardDescription>
</div>
<div className="flex items-center justify-between mt-auto">
<Badge variant="secondary" className="font-normal">
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
</Badge>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex flex-col gap-0.5 mr-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
className="h-5 w-5 rounded-full"
onClick={() => handleMove(category, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-4 w-4" />
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
className="h-5 w-5 rounded-full"
onClick={() => handleMove(category, 'down')}
disabled={index === categories.length - 1}
>
<ChevronDown className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div className="text-4xl mb-2">
<IconPreview value={category.icon} fallback="bookmark-outline" className="text-4xl" />
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => form.openEdit(category)}
>
<Pencil className="h-4 w-4" />
@@ -398,28 +466,19 @@ export function CategoriesPage() {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(category)}
>
<Trash2 className="h-4 w-4 text-destructive" />
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<CardTitle>{category.name}</CardTitle>
<CardDescription className="line-clamp-2">
{category.description || 'No description'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
</span>
<Link to={`/categories/${category.id}/packs`}>
<Button variant="outline" size="sm">
View Packs
</Button>
</Link>
</div>
<Link to={`/categories/${category.id}/packs`}>
<Button variant="outline" className="w-full">
View Packs
</Button>
</Link>
</CardContent>
</Card>
))}
@@ -249,14 +249,19 @@ export function DarePacksPage() {
const currentOrder = pack.sort_order ?? currentIndex;
const targetOrder = targetPack.sort_order ?? targetIndex;
await Promise.all([
const [currentUpdate, targetUpdate] = await Promise.all([
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }),
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
]);
if (currentUpdate.error || targetUpdate.error) {
throw currentUpdate.error ?? targetUpdate.error ?? new Error('Failed to update dare pack order');
}
// Optimistic update
const newPacks = [...darePacks];
[newPacks[currentIndex], newPacks[targetIndex]] = [newPacks[targetIndex], newPacks[currentIndex]];
newPacks[currentIndex] = { ...targetPack, sort_order: currentOrder };
newPacks[targetIndex] = { ...pack, sort_order: targetOrder };
setDarePacks(newPacks);
} catch (error) {
toast.error('Failed to reorder dare packs');
+21 -4
View File
@@ -22,7 +22,7 @@ import { IconPreview } from '@/components/ui/icon-picker';
interface Category {
id: string;
name: string;
icon: string | null;
icon?: string | null;
}
interface Topic {
@@ -54,6 +54,7 @@ export function PacksPage() {
// Data state
const [category, setCategory] = useState<Category | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [packs, setPacks] = useState<QuestionPack[]>([]);
const [loading, setLoading] = useState(true);
const [aiDialogOpen, setAiDialogOpen] = useState(false);
@@ -74,6 +75,7 @@ export function PacksPage() {
is_premium: false,
is_public: false,
is_explicit: false,
category_id: categoryId ?? '',
},
(pack) => ({
name: pack.name,
@@ -82,6 +84,7 @@ export function PacksPage() {
is_premium: pack.is_premium,
is_public: pack.is_public,
is_explicit: pack.is_explicit,
category_id: pack.category_id || '',
})
);
@@ -102,6 +105,12 @@ export function PacksPage() {
setCategory(cat);
}
const { data: categoriesData } = await supabase
.from('categories')
.select('id, name')
.order('name');
setCategories(categoriesData || []);
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
@@ -221,6 +230,7 @@ export function PacksPage() {
is_premium: form.formData.is_premium,
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
});
if (error) throw error;
packId = form.editingItem.id;
@@ -234,7 +244,7 @@ export function PacksPage() {
is_premium: form.formData.is_premium,
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: categoryId || null,
category_id: form.formData.category_id || null,
sort_order: totalCount,
})
.select('id')
@@ -294,6 +304,7 @@ export function PacksPage() {
is_premium: false,
is_public: false,
is_explicit: false,
category_id: categoryId ?? '',
});
setAiDialogOpen(false);
setSelectedTopicIds(new Set());
@@ -312,14 +323,19 @@ export function PacksPage() {
const currentOrder = pack.sort_order ?? currentIndex;
const targetOrder = targetPack.sort_order ?? targetIndex;
await Promise.all([
const [currentUpdate, targetUpdate] = await Promise.all([
auditedSupabase.update('question_packs', pack.id, { sort_order: targetOrder }),
auditedSupabase.update('question_packs', targetPack.id, { sort_order: currentOrder }),
]);
if (currentUpdate.error || targetUpdate.error) {
throw currentUpdate.error ?? targetUpdate.error ?? new Error('Failed to update pack order');
}
// Optimistic update
const newPacks = [...packs];
[newPacks[currentIndex], newPacks[targetIndex]] = [newPacks[targetIndex], newPacks[currentIndex]];
newPacks[currentIndex] = { ...targetPack, sort_order: currentOrder };
newPacks[targetIndex] = { ...pack, sort_order: targetOrder };
setPacks(newPacks);
} catch (error) {
toast.error('Failed to reorder packs');
@@ -391,6 +407,7 @@ export function PacksPage() {
onSave={handleSave}
saving={form.saving}
isEditing={form.isEditing}
categories={categories}
allTopics={allTopics}
selectedTopicIds={selectedTopicIds}
onTopicsChange={setSelectedTopicIds}
@@ -0,0 +1,821 @@
import { useEffect, useState } from 'react';
import { supabase } from '@/config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
import { Crown, MessageCircle, PieChart, Sparkles, Tag } from 'lucide-react';
interface QuestionRow {
id: string;
pack_id: string;
intensity: number | null;
partner_text: string | null;
}
interface PackRow {
id: string;
name: string;
is_premium: boolean;
is_explicit: boolean;
category_id: string | null;
category?: { name: string } | { name: string }[] | null;
}
interface TopicRow {
id: string;
name: string;
}
interface PackTopicRow {
pack_id: string;
topic_id: string;
topics: TopicRow | TopicRow[] | null;
}
interface BreakdownItem {
id: string;
name: string;
count: number;
}
interface PackBreakdownItem {
id: string;
name: string;
count: number;
isPremium: boolean;
category: string;
}
interface ChartDatum {
name: string;
value: number;
color: string;
[key: string]: string | number;
}
type SegmentKey = 'all' | 'premium' | 'free';
const intensityPalette = ['#22c55e', '#84cc16', '#facc15', '#f97316', '#ef4444'];
const intensityLabels = ['Gentle', 'Warm', 'Playful', 'Steamy', 'Intense'];
const formatPercent = (count: number, total: number) => (total > 0 ? Math.round((count / total) * 100) : 0);
const getCategoryName = (category?: PackRow['category']) => {
if (!category) return 'Uncategorized';
if (Array.isArray(category)) {
return category[0]?.name || 'Uncategorized';
}
return category.name || 'Uncategorized';
};
const normalizeTopics = (topics?: PackTopicRow['topics']) => {
if (!topics) return [] as TopicRow[];
return Array.isArray(topics) ? topics : [topics];
};
export function QuestionAnalyticsPage() {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({
totalQuestions: 0,
premiumQuestions: 0,
freeQuestions: 0,
taggedQuestions: 0,
});
const [premiumBreakdown, setPremiumBreakdown] = useState<ChartDatum[]>([]);
const [intensityBreakdown, setIntensityBreakdown] = useState<Record<SegmentKey, ChartDatum[]>>({
all: [],
premium: [],
free: [],
});
const [topicBreakdown, setTopicBreakdown] = useState<Record<SegmentKey, BreakdownItem[]>>({
all: [],
premium: [],
free: [],
});
const [categoryBreakdown, setCategoryBreakdown] = useState<Record<SegmentKey, BreakdownItem[]>>({
all: [],
premium: [],
free: [],
});
const [packBreakdown, setPackBreakdown] = useState<Record<SegmentKey, PackBreakdownItem[]>>({
all: [],
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');
useEffect(() => {
const fetchAllQuestions = async () => {
const pageSize = 1000;
let page = 0;
const allQuestions: QuestionRow[] = [];
while (true) {
const from = page * pageSize;
const to = from + pageSize - 1;
const { data, error } = await supabase
.from('questions')
.select('id, pack_id, intensity, partner_text')
.range(from, to);
if (error) throw error;
const batch = data || [];
allQuestions.push(...batch);
if (batch.length < pageSize) {
break;
}
page += 1;
}
return allQuestions;
};
const fetchAnalytics = async () => {
setLoading(true);
try {
const [packsResult, topicsResult, packTopicsResult, questions] = await Promise.all([
supabase
.from('question_packs')
.select('id, name, is_premium, is_explicit, category_id, category:categories(name)'),
supabase
.from('topics')
.select('id, name')
.order('name'),
supabase
.from('pack_topics')
.select('pack_id, topic_id, topics(id, name)'),
fetchAllQuestions(),
]);
if (packsResult.error) throw packsResult.error;
if (topicsResult.error) throw topicsResult.error;
if (packTopicsResult.error) throw packTopicsResult.error;
const packs = (packsResult.data || []) as PackRow[];
const topics = (topicsResult.data || []) as TopicRow[];
const packTopics = (packTopicsResult.data || []) as PackTopicRow[];
const packById = new Map<string, PackRow>();
packs.forEach((pack) => {
packById.set(pack.id, pack);
});
const seedTopicCounts = () => new Map<string, BreakdownItem>(
topics.map((topic) => [topic.id, { id: topic.id, name: topic.name, count: 0 }])
);
const topicCountsBySegment: Record<SegmentKey, Map<string, BreakdownItem>> = {
all: seedTopicCounts(),
premium: seedTopicCounts(),
free: seedTopicCounts(),
};
const packTopicsMap = new Map<string, TopicRow[]>();
packTopics.forEach((relation) => {
const topicsForPack = packTopicsMap.get(relation.pack_id) || [];
const topicsList = normalizeTopics(relation.topics);
if (topicsList.length === 0) return;
topicsForPack.push(...topicsList);
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 categoryCountsBySegment: Record<SegmentKey, Map<string, BreakdownItem>> = {
all: new Map(),
premium: new Map(),
free: new Map(),
};
const packCountsBySegment: Record<SegmentKey, Map<string, PackBreakdownItem>> = {
all: new Map(),
premium: new Map(),
free: new Map(),
};
let premiumCount = 0;
let freeCount = 0;
let taggedCount = 0;
questions.forEach((question) => {
const pack = packById.get(question.pack_id);
const isPremium = pack?.is_premium ?? false;
const segmentKey: SegmentKey = isPremium ? 'premium' : 'free';
const categoryName = getCategoryName(pack?.category);
const categoryId = pack?.category_id || 'uncategorized';
if (isPremium) {
premiumCount += 1;
} else {
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) || {
id: question.pack_id,
name: pack?.name || 'Unknown Pack',
count: 0,
isPremium: isPremium,
category: categoryName,
};
packEntry.count += 1;
map.set(question.pack_id, packEntry);
};
updatePackCounts('all');
updatePackCounts(segmentKey);
const updateCategoryCounts = (key: SegmentKey) => {
const map = categoryCountsBySegment[key];
const categoryEntry = map.get(categoryId) || {
id: categoryId,
name: categoryName,
count: 0,
};
categoryEntry.count += 1;
map.set(categoryId, categoryEntry);
};
updateCategoryCounts('all');
updateCategoryCounts(segmentKey);
const topicsForPack = packTopicsMap.get(question.pack_id) || [];
if (topicsForPack.length > 0) {
taggedCount += 1;
topicsForPack.forEach((topic) => {
const updateCounts = (map: Map<string, BreakdownItem>) => {
const topicEntry = map.get(topic.id) || {
id: topic.id,
name: topic.name,
count: 0,
};
topicEntry.count += 1;
map.set(topic.id, topicEntry);
};
updateCounts(topicCountsBySegment.all);
updateCounts(topicCountsBySegment[segmentKey]);
});
} else {
untaggedCounts.all += 1;
untaggedCounts[segmentKey] += 1;
}
});
const totalQuestions = questions.length;
const premiumData: ChartDatum[] = [
{ name: 'Premium', value: premiumCount, color: '#f59e0b' },
{ name: 'Free', value: freeCount, color: '#22c55e' },
];
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) {
data.push({ id: 'untagged', name: 'Untagged', count: untaggedCounts[key] });
}
data.sort((a, b) => b.count - a.count);
return data;
};
const intensityData = {
all: buildIntensityData('all'),
premium: buildIntensityData('premium'),
free: buildIntensityData('free'),
};
const topicData = {
all: buildTopicData('all'),
premium: buildTopicData('premium'),
free: buildTopicData('free'),
};
const buildCategoryData = (key: SegmentKey) =>
Array.from(categoryCountsBySegment[key].values()).sort((a, b) => b.count - a.count);
const buildPackData = (key: SegmentKey) =>
Array.from(packCountsBySegment[key].values()).sort((a, b) => b.count - a.count);
const categoryData = {
all: buildCategoryData('all'),
premium: buildCategoryData('premium'),
free: buildCategoryData('free'),
};
const packData = {
all: buildPackData('all'),
premium: buildPackData('premium'),
free: buildPackData('free'),
};
setStats({
totalQuestions,
premiumQuestions: premiumCount,
freeQuestions: freeCount,
taggedQuestions: taggedCount,
});
setPremiumBreakdown(premiumData);
setIntensityBreakdown(intensityData);
setTopicBreakdown(topicData);
setCategoryBreakdown(categoryData);
setPackBreakdown(packData);
} catch (error) {
console.error('Failed to fetch question analytics:', error);
} finally {
setLoading(false);
}
};
fetchAnalytics();
}, []);
const premiumPercent = formatPercent(stats.premiumQuestions, stats.totalQuestions);
const freePercent = formatPercent(stats.freeQuestions, stats.totalQuestions);
const taggedPercent = formatPercent(stats.taggedQuestions, stats.totalQuestions);
const segmentOptions: { key: SegmentKey; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'premium', label: 'Premium' },
{ key: 'free', label: 'Free' },
];
const segmentLabelByKey: Record<SegmentKey, string> = {
all: 'All',
premium: 'Premium',
free: 'Free',
};
const totalBySegment: Record<SegmentKey, number> = {
all: stats.totalQuestions,
premium: stats.premiumQuestions,
free: stats.freeQuestions,
};
const segmentShareByKey: Record<SegmentKey, string> = {
all: '100',
premium: stats.totalQuestions ? ((stats.premiumQuestions / stats.totalQuestions) * 100).toFixed(0) : '0',
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];
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<PieChart className="h-8 w-8 text-primary" />
Question Analytics
</h1>
<p className="text-muted-foreground">
Deep dive into premium coverage, tags, categories, and intensity distribution
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Questions</CardTitle>
<MessageCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<div className="text-2xl font-bold">
{stats.totalQuestions.toLocaleString()}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Premium Questions</CardTitle>
<Crown className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<>
<div className="text-2xl font-bold">
{stats.premiumQuestions.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
{premiumPercent}% of all questions
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Free Questions</CardTitle>
<Sparkles className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<>
<div className="text-2xl font-bold">
{stats.freeQuestions.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
{freePercent}% of all questions
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Tagged Questions</CardTitle>
<Tag className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<>
<div className="text-2xl font-bold">
{stats.taggedQuestions.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
{taggedPercent}% of all questions
</p>
</>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Premium vs Free</CardTitle>
<CardDescription>
Question count split by pack premium status
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-[300px] w-full" />
) : stats.totalQuestions === 0 ? (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
No question data available yet
</div>
) : (
<div className="h-[300px] flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart>
<Pie
data={premiumBreakdown}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
label={({ name, percent }) =>
`${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
}
>
{premiumBreakdown.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
</RechartsPieChart>
</ResponsiveContainer>
</div>
)}
</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>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Tag Breakdown</CardTitle>
<CardDescription>
Tags are derived from pack topics; questions in multi-tag packs count toward each tag
</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={tagSegment === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setTagSegment(option.key)}
className="rounded-full"
>
{option.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{segmentLabelByKey[tagSegment]}: {totalBySegment[tagSegment].toLocaleString()} questions ({segmentShareByKey[tagSegment]}%)
</p>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-14 w-full" />
))}
</div>
) : totalBySegment[tagSegment] === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No questions in this segment yet
</div>
) : tagData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No tag data available yet
</div>
) : (
<div className="space-y-4">
{tagData.map((item) => {
const percentage = formatPercent(item.count, totalBySegment[tagSegment]);
return (
<div
key={item.id}
className="flex items-center gap-4 rounded-lg border px-4 py-3"
>
<div className="flex-1">
<div className="flex items-center justify-between">
<p className="font-medium">{item.name}</p>
<span className="text-sm text-muted-foreground">
{item.count.toLocaleString()}
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary/70"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
<div className="w-12 text-right text-sm text-muted-foreground">
{percentage}%
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Category Breakdown</CardTitle>
<CardDescription>
Distribution of questions across pack categories
</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={categorySegment === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setCategorySegment(option.key)}
className="rounded-full"
>
{option.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{segmentLabelByKey[categorySegment]}: {totalBySegment[categorySegment].toLocaleString()} questions ({segmentShareByKey[categorySegment]}%)
</p>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
) : totalBySegment[categorySegment] === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No questions in this segment yet
</div>
) : categoryData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No category data available yet
</div>
) : (
<div className="space-y-3">
{categoryData.map((item) => {
const percentage = formatPercent(item.count, totalBySegment[categorySegment]);
return (
<div
key={item.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<div>
<p className="font-medium">{item.name}</p>
<p className="text-xs text-muted-foreground">{percentage}% of questions</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold">{item.count.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">questions</p>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Top Packs</CardTitle>
<CardDescription>
Packs with the most questions right now
</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={packSegment === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setPackSegment(option.key)}
className="rounded-full"
>
{option.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{segmentLabelByKey[packSegment]}: {totalBySegment[packSegment].toLocaleString()} questions ({segmentShareByKey[packSegment]}%)
</p>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
) : totalBySegment[packSegment] === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No questions in this segment yet
</div>
) : packData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No pack data available yet
</div>
) : (
<div className="space-y-3">
{packData.slice(0, 8).map((pack) => (
<div
key={pack.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<div className="min-w-0">
<p className="font-medium truncate">{pack.name}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{pack.category}</span>
{pack.isPremium && (
<Badge variant="secondary" className="text-[10px]">
Premium
</Badge>
)}
</div>
</div>
<div className="text-right">
<p className="text-lg font-semibold">{pack.count.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">questions</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+66 -4
View File
@@ -6,6 +6,7 @@ import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
@@ -51,6 +52,7 @@ interface Question {
intensity: number;
allowed_couple_genders: string[] | null;
target_user_genders: string[] | null;
required_props?: string[] | null;
created_at: string | null;
}
@@ -67,6 +69,8 @@ export function QuestionsPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [aiDialogOpen, setAiDialogOpen] = useState(false);
const [reviewOpen, setReviewOpen] = useState(false);
const [reviewQuestions, setReviewQuestions] = useState<Question[]>([]);
const [reviewLoading, setReviewLoading] = useState(false);
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
const [saving, setSaving] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -82,12 +86,14 @@ export function QuestionsPage() {
intensity: number;
allowed_couple_genders: string[];
target_user_genders: string[];
required_props: string;
}>({
text: '',
partner_text: '',
intensity: 1,
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
});
const fetchData = useCallback(async () => {
@@ -161,6 +167,8 @@ export function QuestionsPage() {
useEffect(() => {
setPage(1);
setSelectedIds(new Set());
setReviewQuestions([]);
setReviewOpen(false);
}, [packId]);
useEffect(() => {
@@ -169,7 +177,14 @@ export function QuestionsPage() {
const openCreateDialog = () => {
setEditingQuestion(null);
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
setFormData({
text: '',
partner_text: '',
intensity: 1,
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
});
setDialogOpen(true);
};
@@ -181,6 +196,7 @@ export function QuestionsPage() {
intensity: question.intensity,
allowed_couple_genders: question.allowed_couple_genders || [],
target_user_genders: question.target_user_genders || [],
required_props: question.required_props?.join(', ') ?? '',
});
setDialogOpen(true);
};
@@ -191,6 +207,12 @@ export function QuestionsPage() {
return;
}
const requiredProps = formData.required_props
.split(/[,;\n]/)
.map(value => value.trim())
.filter(Boolean);
const requiredPropsValue = requiredProps.length > 0 ? requiredProps : null;
setSaving(true);
try {
if (editingQuestion) {
@@ -200,6 +222,7 @@ export function QuestionsPage() {
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,
});
if (error) throw error;
@@ -212,6 +235,7 @@ export function QuestionsPage() {
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,
});
if (error) throw error;
@@ -272,6 +296,28 @@ export function QuestionsPage() {
setAiDialogOpen(false);
};
const handleReviewOpen = async () => {
if (!packId) return;
setReviewLoading(true);
try {
const { data, error } = await supabase
.from('questions')
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, created_at')
.eq('pack_id', packId)
.order('created_at', { ascending: true });
if (error) throw error;
setReviewQuestions(data || []);
setReviewOpen(true);
} catch (error) {
toast.error('Failed to load questions for review');
console.error(error);
} finally {
setReviewLoading(false);
}
};
// Selection handlers
const toggleSelection = (id: string) => {
const next = new Set(selectedIds);
@@ -348,8 +394,12 @@ export function QuestionsPage() {
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setReviewOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
<Button variant="outline" onClick={handleReviewOpen} disabled={reviewLoading}>
{reviewLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
Review Questions
</Button>
<Button variant="outline" onClick={() => setAiDialogOpen(true)}>
@@ -442,6 +492,18 @@ export function QuestionsPage() {
</div>
</div>
<div className="space-y-2">
<Label htmlFor="required_props">Required Props (optional)</Label>
<Input
id="required_props"
value={formData.required_props}
onChange={(e) => setFormData(d => ({ ...d, required_props: e.target.value }))}
placeholder="e.g., vibrator, restraints"
/>
<p className="text-xs text-muted-foreground">
Comma-separated list of props/accessories needed for this question.
</p>
</div>
<div className="space-y-2">
<Label>Target Couples (optional)</Label>
@@ -580,7 +642,7 @@ export function QuestionsPage() {
<ReviewQuestionsDialog
open={reviewOpen}
onOpenChange={setReviewOpen}
questions={questions}
questions={reviewQuestions}
isExplicit={pack?.is_explicit ?? false}
onUpdated={fetchData}
/>
+459
View File
@@ -0,0 +1,459 @@
import { useCallback, useEffect, useState } from 'react';
import { supabase } from '@/config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { CheckCircle2, Loader2, Sparkles, Tags } from 'lucide-react';
import { extractTopicsFromPack } from '@/lib/openai';
interface TopicRow {
id: string;
name: string;
}
interface PackTopicRow {
pack_id: string;
topic_id: string;
}
interface PackRow {
id: string;
name: string;
description: string | null;
is_public: boolean;
category?: { name: string } | { name: string }[] | null;
}
interface ActiveTag {
id: string;
name: string;
packCount: number;
}
interface UntaggedPack {
id: string;
name: string;
description: string | null;
is_public: boolean;
categoryName: string;
questionCount: number;
}
const getCategoryName = (category?: PackRow['category']) => {
if (!category) return 'Uncategorized';
if (Array.isArray(category)) {
return category[0]?.name || 'Uncategorized';
}
return category.name || 'Uncategorized';
};
export function TagsPage() {
const [activeTags, setActiveTags] = useState<ActiveTag[]>([]);
const [untaggedPacks, setUntaggedPacks] = useState<UntaggedPack[]>([]);
const [loading, setLoading] = useState(true);
const [taggingAll, setTaggingAll] = useState(false);
const [taggingProgress, setTaggingProgress] = useState<{
current: number;
total: number;
packName: string;
} | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [topicsResult, packTopicsResult, packsResult] = await Promise.all([
supabase.from('topics').select('id, name').order('name'),
supabase.from('pack_topics').select('pack_id, topic_id'),
supabase
.from('question_packs')
.select('id, name, description, is_public, category:categories(name)')
.order('name'),
]);
if (topicsResult.error) throw topicsResult.error;
if (packTopicsResult.error) throw packTopicsResult.error;
if (packsResult.error) throw packsResult.error;
const topics = (topicsResult.data || []) as TopicRow[];
const packTopics = (packTopicsResult.data || []) as PackTopicRow[];
const packs = (packsResult.data || []) as PackRow[];
const topicCounts = new Map<string, number>();
const packIdsWithTopics = new Set<string>();
packTopics.forEach((relation) => {
if (!relation.topic_id || !relation.pack_id) return;
packIdsWithTopics.add(relation.pack_id);
topicCounts.set(relation.topic_id, (topicCounts.get(relation.topic_id) || 0) + 1);
});
const activeTagList = topics
.filter((topic) => topicCounts.has(topic.id))
.map((topic) => ({
id: topic.id,
name: topic.name,
packCount: topicCounts.get(topic.id) || 0,
}))
.sort((a, b) => {
if (b.packCount !== a.packCount) return b.packCount - a.packCount;
return a.name.localeCompare(b.name);
});
const untagged = packs.filter((pack) => !packIdsWithTopics.has(pack.id));
const untaggedPackIds = untagged.map((pack) => pack.id);
const questionCounts: Record<string, number> = {};
if (untaggedPackIds.length > 0) {
const { data: questions, error: questionsError } = await supabase
.from('questions')
.select('pack_id')
.in('pack_id', untaggedPackIds);
if (questionsError) throw questionsError;
questions?.forEach((question) => {
if (!question.pack_id) return;
questionCounts[question.pack_id] = (questionCounts[question.pack_id] || 0) + 1;
});
}
setActiveTags(activeTagList);
setUntaggedPacks(
untagged.map((pack) => ({
id: pack.id,
name: pack.name,
description: pack.description,
is_public: pack.is_public,
categoryName: getCategoryName(pack.category),
questionCount: questionCounts[pack.id] || 0,
}))
);
} catch (error) {
toast.error('Failed to load tags');
console.error(error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const eligiblePackCount = untaggedPacks.filter((pack) => pack.questionCount > 0).length;
const handleTagAll = async () => {
if (taggingAll || eligiblePackCount === 0) return;
const eligiblePacks = untaggedPacks.filter((pack) => pack.questionCount > 0);
const skippedNoQuestions = untaggedPacks.length - eligiblePacks.length;
if (eligiblePacks.length === 0) {
toast('No untagged packs with questions to tag');
return;
}
const shouldProceed = confirm(
`Tag ${eligiblePacks.length} untagged pack${eligiblePacks.length !== 1 ? 's' : ''} with AI?` +
(skippedNoQuestions > 0
? ` ${skippedNoQuestions} pack${skippedNoQuestions !== 1 ? 's' : ''} without questions will be skipped.`
: '')
);
if (!shouldProceed) return;
setTaggingAll(true);
setTaggingProgress({ current: 0, total: eligiblePacks.length, packName: '' });
try {
const { data: topicsData, error: topicsError } = await supabase
.from('topics')
.select('id, name')
.order('name');
if (topicsError) throw topicsError;
const existingTopics: TopicRow[] = [...(topicsData || [])];
let taggedCount = 0;
let skippedCount = 0;
let failedCount = 0;
for (let index = 0; index < eligiblePacks.length; index += 1) {
const pack = eligiblePacks[index];
setTaggingProgress({
current: index + 1,
total: eligiblePacks.length,
packName: pack.name,
});
try {
const { data: questions, error: questionsError } = await supabase
.from('questions')
.select('text')
.eq('pack_id', pack.id);
if (questionsError) throw questionsError;
if (!questions || questions.length === 0) {
skippedCount += 1;
continue;
}
const result = await extractTopicsFromPack(
pack.name,
pack.description,
questions.map((question) => question.text),
existingTopics
);
if (!result.topics || result.topics.length === 0) {
skippedCount += 1;
continue;
}
const existingByName = new Map(
existingTopics.map((topic) => [topic.name.toLowerCase(), topic.id])
);
const newTopics: { name: string }[] = [];
const topicIds: string[] = [];
result.topics.forEach((topic) => {
const normalizedName = topic.name.trim();
if (!normalizedName) return;
if (!topic.isNew && topic.existingTopicId) {
topicIds.push(topic.existingTopicId);
return;
}
const existingId = existingByName.get(normalizedName.toLowerCase());
if (existingId) {
topicIds.push(existingId);
return;
}
newTopics.push({ name: normalizedName });
});
if (newTopics.length > 0) {
const { data: createdTopics, error: createError } = await supabase
.from('topics')
.insert(newTopics)
.select('id, name');
if (createError) throw createError;
createdTopics?.forEach((topic) => {
topicIds.push(topic.id);
existingTopics.push({ id: topic.id, name: topic.name });
});
}
await supabase.from('pack_topics').delete().eq('pack_id', pack.id);
if (topicIds.length === 0) {
skippedCount += 1;
continue;
}
const { error: linkError } = await supabase
.from('pack_topics')
.insert(topicIds.map((topicId) => ({
pack_id: pack.id,
topic_id: topicId,
})));
if (linkError) throw linkError;
taggedCount += 1;
} catch (error) {
failedCount += 1;
console.error(`Failed to tag pack ${pack.name}:`, error);
}
}
if (failedCount > 0) {
toast.error(`Tagged ${taggedCount} pack${taggedCount !== 1 ? 's' : ''}. ${failedCount} failed.`);
} else {
toast.success(
`Tagged ${taggedCount} pack${taggedCount !== 1 ? 's' : ''}.` +
(skippedCount > 0 ? ` ${skippedCount} skipped.` : '')
);
}
} catch (error) {
toast.error('Failed to tag all packs');
console.error(error);
} finally {
setTaggingAll(false);
setTaggingProgress(null);
fetchData();
}
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-10 w-40" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-64" />
<Skeleton className="h-64" />
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Tags className="h-8 w-8 text-primary" />
Tags
</h1>
<p className="text-muted-foreground">
Active tags and packs that still need topic coverage
</p>
</div>
<div className="flex flex-col items-start gap-2 sm:items-end">
<Button
onClick={handleTagAll}
disabled={taggingAll || eligiblePackCount === 0}
>
{taggingAll ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{taggingAll && taggingProgress
? `Tagging ${taggingProgress.current}/${taggingProgress.total}`
: 'Tag All Untagged'}
</Button>
{taggingProgress?.packName && (
<p className="text-xs text-muted-foreground">
Working on {taggingProgress.packName}
</p>
)}
{eligiblePackCount === 0 && untaggedPacks.length > 0 && (
<p className="text-xs text-muted-foreground">
Add questions to enable auto-tagging.
</p>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Tags</CardTitle>
<Tags className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{activeTags.length.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">Tags assigned to at least one pack</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Untagged Packs</CardTitle>
<Sparkles className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{untaggedPacks.length.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">Packs missing any tag coverage</p>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Active Tags</CardTitle>
<CardDescription>Tag usage across question packs</CardDescription>
</CardHeader>
<CardContent>
{activeTags.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<CheckCircle2 className="h-10 w-10 mb-2" />
No active tags yet
</div>
) : (
<div className="space-y-3">
{activeTags.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<p className="font-medium">{tag.name}</p>
<Badge variant="secondary">
{tag.packCount} pack{tag.packCount !== 1 ? 's' : ''}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Untagged Packs</CardTitle>
<CardDescription>These packs still need topics</CardDescription>
</div>
<Badge variant="outline" className="text-xs">
{untaggedPacks.length} total
</Badge>
</CardHeader>
<CardContent>
{untaggedPacks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<CheckCircle2 className="h-10 w-10 mb-2" />
All packs are tagged
</div>
) : (
<div className="space-y-3">
{untaggedPacks.map((pack) => (
<div
key={pack.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{pack.name}</p>
{pack.is_public ? (
<Badge variant="outline" className="border-green-500 text-green-400">
Published
</Badge>
) : (
<Badge variant="secondary">Draft</Badge>
)}
{pack.questionCount === 0 && (
<Badge variant="outline" className="border-yellow-500 text-yellow-400">
No questions
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{pack.categoryName}</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold">{pack.questionCount}</p>
<p className="text-xs text-muted-foreground">questions</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}