mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
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:
1 parent
b0f1700e40
commit
4e40b92b16
71 files changed
+10511
-1008
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user