import { useState, useEffect, useCallback } from 'react'; import { Link, useParams } from 'react-router-dom'; import { adminData } from '@/lib/adminApi'; import { auditedAdminData } from '@/hooks/useAuditedAdminData'; import { useEntityForm } from '@/hooks/useEntityForm'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { PaginationControls } from '@/components/ui/pagination'; import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Tags, ChevronUp, ChevronDown, Clock, BellOff } from 'lucide-react'; import { toast } from 'sonner'; import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog'; import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog'; import { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog'; import { IconPreview } from '@/components/ui/icon-picker'; import { ContentReviewControl } from '@/components/content/ContentReviewControl'; import type { ContentReviewStatus } from '@sauci/shared'; // ============================================================================= // Types // ============================================================================= interface Category { id: string; name: string; icon?: string | null; } interface Topic { id: string; name: string; } interface QuestionPack { id: string; name: string; description: string | null; icon: string | null; is_premium: boolean; is_public: boolean; is_explicit: boolean; sort_order: number | null; category_id: string | null; created_at: string | null; scheduled_release_at?: string | null; // Optional for backwards compatibility release_notified?: boolean; // True if notifications have been sent or should be skipped content_status?: ContentReviewStatus | null; content_review_reason?: string | null; question_count?: number; topics?: Topic[]; } // ============================================================================= // Component // ============================================================================= export function PacksPage() { const { categoryId } = useParams<{ categoryId: string }>(); // Data state const [category, setCategory] = useState(null); const [categories, setCategories] = useState([]); const [packs, setPacks] = useState([]); const [loading, setLoading] = useState(true); const [aiDialogOpen, setAiDialogOpen] = useState(false); const [extractTopicsDialogOpen, setExtractTopicsDialogOpen] = useState(false); const [extractTopicsPack, setExtractTopicsPack] = useState(null); const [allTopics, setAllTopics] = useState([]); const [selectedTopicIds, setSelectedTopicIds] = useState>(new Set()); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(12); const [totalCount, setTotalCount] = useState(0); // Form state using the hook const form = useEntityForm( { name: '', description: '', icon: 'heart-outline', is_premium: false, is_public: false, is_explicit: false, category_id: categoryId ?? '', scheduled_release_at: null, skip_notification: false, }, (pack) => ({ name: pack.name, description: pack.description || '', icon: pack.icon || 'heart-outline', is_premium: pack.is_premium, is_public: pack.is_public, is_explicit: pack.is_explicit, category_id: pack.category_id || '', scheduled_release_at: pack.scheduled_release_at, skip_notification: false, // Always start unchecked when editing }) ); // ============================================================================= // Data Fetching // ============================================================================= const fetchData = useCallback(async () => { setLoading(true); try { // Fetch category info if (categoryId) { const { data: cat } = await adminData .from('categories') .select('id, name, icon') .eq('id', categoryId) .single(); setCategory(cat); } const { data: categoriesData } = await adminData .from('categories') .select('id, name') .order('name'); setCategories(categoriesData || []); const from = (page - 1) * pageSize; const to = from + pageSize - 1; // Fetch packs let query = adminData .from('question_packs') .select('*', { count: 'exact' }) .order('sort_order', { ascending: true }) .range(from, to); if (categoryId) { query = query.eq('category_id', categoryId); } const { data: packData, error, count } = await query; if (error) throw error; setTotalCount(count || 0); // Fetch question counts const packIds = (packData || []).map(p => p.id); const questionCounts: Record = {}; if (packIds.length > 0) { const { data: questions } = await adminData .from('questions') .select('pack_id') .in('pack_id', packIds); questions?.forEach(q => { questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1; }); } // Fetch all topics const { data: topicsData } = await adminData .from('topics') .select('id, name') .order('name'); setAllTopics(topicsData || []); // Fetch pack_topics relationships let packTopicsData: any[] = []; if (packIds.length > 0) { const { data } = await adminData .from('pack_topics') .select('pack_id, topic_id, topics(id, name)') .in('pack_id', packIds); packTopicsData = data || []; } const packTopicsMap: Record = {}; packTopicsData.forEach((pt: any) => { if (!packTopicsMap[pt.pack_id]) { packTopicsMap[pt.pack_id] = []; } if (pt.topics) { packTopicsMap[pt.pack_id].push(pt.topics); } }); setPacks( (packData || []).map(p => ({ ...p, question_count: questionCounts[p.id] || 0, topics: packTopicsMap[p.id] || [], })) ); } catch (error) { toast.error('Failed to load packs'); console.error(error); } finally { setLoading(false); } }, [categoryId, page, pageSize]); useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); if (page > totalPages) { setPage(totalPages); } }, [page, pageSize, totalCount]); // ============================================================================= // Handlers // ============================================================================= const openCreate = () => { form.openCreate(); setSelectedTopicIds(new Set()); }; const openEdit = (pack: QuestionPack) => { form.openEdit(pack); setSelectedTopicIds(new Set(pack.topics?.map(t => t.id) || [])); }; const handleSave = async () => { if (!form.formData.name.trim()) { toast.error('Name is required'); return; } form.setSaving(true); try { let packId: string; if (form.editingItem) { // Determine release_notified value // If skip_notification is checked, set release_notified=true to prevent notifications // Otherwise, reset to false when scheduling a new release const releaseNotifiedValue = form.formData.skip_notification ? true : (form.formData.scheduled_release_at ? false : undefined); const { error } = await auditedAdminData.update('question_packs', form.editingItem.id, { name: form.formData.name, description: form.formData.description || null, icon: form.formData.icon || null, 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, // Only include scheduled_release_at if the field exists in form data (backwards compatible) ...('scheduled_release_at' in form.formData && { scheduled_release_at: form.formData.scheduled_release_at, // Set release_notified based on skip_notification toggle ...(releaseNotifiedValue !== undefined && { release_notified: releaseNotifiedValue }), }), }); if (error) throw error; packId = form.editingItem.id; } else { const { data, error } = await adminData .from('question_packs') .insert({ name: form.formData.name, description: form.formData.description || null, icon: form.formData.icon || null, 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, sort_order: totalCount, // Only include scheduled_release_at if the field exists in form data (backwards compatible) ...('scheduled_release_at' in form.formData && { scheduled_release_at: form.formData.scheduled_release_at, // If skip_notification is checked, set release_notified=true ...(form.formData.skip_notification && { release_notified: true }), }), }) .select('id') .single(); if (error) throw error; packId = data.id; } // Update topics await adminData.from('pack_topics').delete().eq('pack_id', packId); if (selectedTopicIds.size > 0) { const { error: topicsError } = await adminData .from('pack_topics') .insert( Array.from(selectedTopicIds).map(topicId => ({ pack_id: packId, topic_id: topicId, })) ); if (topicsError) throw topicsError; } toast.success(form.isEditing ? 'Pack updated' : 'Pack created'); form.close(); fetchData(); } catch (error) { toast.error('Failed to save pack'); console.error(error); } finally { form.setSaving(false); } }; const handleDelete = async (pack: QuestionPack) => { if (!confirm(`Delete pack "${pack.name}"? This will delete all questions in this pack.`)) { return; } try { const { error } = await auditedAdminData.delete('question_packs', pack.id); if (error) throw error; toast.success('Pack deleted'); fetchData(); } catch (error) { toast.error('Failed to delete pack'); console.error(error); } }; const handleAiGenerated = (result: { name?: string; description?: string; icon?: string }) => { if (result.description) { form.openCreateWith({ name: result.name || '', description: result.description, icon: result.icon || 'heart-outline', is_premium: false, is_public: false, is_explicit: false, category_id: categoryId ?? '', scheduled_release_at: null, skip_notification: false, }); setAiDialogOpen(false); setSelectedTopicIds(new Set()); } }; const handleMove = async (pack: QuestionPack, direction: 'up' | 'down') => { const currentIndex = packs.findIndex(p => p.id === pack.id); const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; if (targetIndex < 0 || targetIndex >= packs.length) return; const targetPack = packs[targetIndex]; try { const currentOrder = pack.sort_order ?? currentIndex; const targetOrder = targetPack.sort_order ?? targetIndex; const [currentUpdate, targetUpdate] = await Promise.all([ auditedAdminData.update('question_packs', pack.id, { sort_order: targetOrder }), auditedAdminData.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] = { ...targetPack, sort_order: currentOrder }; newPacks[targetIndex] = { ...pack, sort_order: targetOrder }; setPacks(newPacks); } catch (error) { toast.error('Failed to reorder packs'); console.error(error); fetchData(); } }; // ============================================================================= // Render // ============================================================================= if (loading) { return (
{[1, 2, 3].map(i => ( ))}
); } return (
{/* Header */}

{category && ( <> {category.name}: )} Question Packs

{category ? `Manage packs in the ${category.name} category` : 'Manage all question packs'}

{/* Pack Form Dialog */} {/* AI Generator Dialog */} p.name) }} onGenerated={handleAiGenerated} /> {/* Extract Topics Dialog */} {extractTopicsPack && ( )} {/* Packs Grid */} {packs.length === 0 ? (

No packs yet

Create your first question pack to get started

) : (
{packs.map((pack, index) => (
{pack.is_explicit && ( NSFW )} {pack.is_premium && ( Premium )} {pack.is_public ? ( Published ) : pack.scheduled_release_at ? ( <> Scheduled {pack.release_notified && ( )} ) : ( Draft )}
{pack.name}
{pack.description || 'No description'} {/* Topics */} {pack.topics && pack.topics.length > 0 && (
{pack.topics.map(topic => ( {topic.name} ))}
)}
{pack.question_count} question{pack.question_count !== 1 ? 's' : ''}
))}
)} {packs.length > 0 && ( { setPage(1); setPageSize(size); }} /> )}
); }