fix: Remove Intelligence Library components causing build failure
- Remove incomplete CRUD components that require Shadcn UI - Keep Jumpstart fix and frontend plugin upgrades - Diagnostic test confirms all API connections working - Build now succeeds and ready for deployment Note: Intelligence Library UI components will be added in next phase after Shadcn UI components are properly set up.
This commit is contained in:
341
backend/scripts/diagnostic_test.ts
Normal file
341
backend/scripts/diagnostic_test.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { createDirectus, rest, authentication, readItems, readCollections } from '@directus/sdk';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||||
|
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_PUBLIC_URL || 'https://spark.jumpstartscaling.com';
|
||||||
|
const EMAIL = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||||
|
const PASSWORD = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
const client = createDirectus(DIRECTUS_URL).with(authentication()).with(rest());
|
||||||
|
|
||||||
|
interface TestResult {
|
||||||
|
name: string;
|
||||||
|
status: 'PASS' | 'FAIL' | 'WARN';
|
||||||
|
message: string;
|
||||||
|
details?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: TestResult[] = [];
|
||||||
|
|
||||||
|
function logTest(result: TestResult) {
|
||||||
|
const icon = result.status === 'PASS' ? '✅' : result.status === 'WARN' ? '⚠️' : '❌';
|
||||||
|
console.log(`${icon} ${result.name}: ${result.message}`);
|
||||||
|
if (result.details) {
|
||||||
|
console.log(` Details:`, result.details);
|
||||||
|
}
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDiagnostics() {
|
||||||
|
console.log('\n🔍 SPARK PLATFORM - FULL DIAGNOSTIC TEST\n');
|
||||||
|
console.log(`📡 Testing Directus API at: ${DIRECTUS_URL}\n`);
|
||||||
|
|
||||||
|
// TEST 1: Authentication
|
||||||
|
try {
|
||||||
|
console.log('--- TEST 1: Authentication ---');
|
||||||
|
await client.login(EMAIL!, PASSWORD!);
|
||||||
|
logTest({
|
||||||
|
name: 'Authentication',
|
||||||
|
status: 'PASS',
|
||||||
|
message: 'Successfully authenticated with Directus'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logTest({
|
||||||
|
name: 'Authentication',
|
||||||
|
status: 'FAIL',
|
||||||
|
message: 'Failed to authenticate',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
console.log('\n❌ Cannot proceed without authentication. Exiting.\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEST 2: Collections Exist
|
||||||
|
console.log('\n--- TEST 2: Collections ---');
|
||||||
|
try {
|
||||||
|
const collections = await client.request(readCollections());
|
||||||
|
const collectionNames = collections.map((c: any) => c.collection);
|
||||||
|
|
||||||
|
const requiredCollections = [
|
||||||
|
'sites',
|
||||||
|
'avatars',
|
||||||
|
'avatar_variants',
|
||||||
|
'geo_clusters',
|
||||||
|
'geo_locations',
|
||||||
|
'spintax_dictionaries',
|
||||||
|
'cartesian_patterns',
|
||||||
|
'generation_jobs',
|
||||||
|
'generated_articles',
|
||||||
|
'work_log'
|
||||||
|
];
|
||||||
|
|
||||||
|
let allExist = true;
|
||||||
|
for (const col of requiredCollections) {
|
||||||
|
if (collectionNames.includes(col)) {
|
||||||
|
logTest({
|
||||||
|
name: `Collection: ${col}`,
|
||||||
|
status: 'PASS',
|
||||||
|
message: 'Exists'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logTest({
|
||||||
|
name: `Collection: ${col}`,
|
||||||
|
status: 'FAIL',
|
||||||
|
message: 'Missing'
|
||||||
|
});
|
||||||
|
allExist = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allExist) {
|
||||||
|
logTest({
|
||||||
|
name: 'All Collections',
|
||||||
|
status: 'PASS',
|
||||||
|
message: `All ${requiredCollections.length} required collections exist`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logTest({
|
||||||
|
name: 'Collections Check',
|
||||||
|
status: 'FAIL',
|
||||||
|
message: 'Failed to read collections',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEST 3: Geo Intelligence Data
|
||||||
|
console.log('\n--- TEST 3: Geo Intelligence ---');
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const clusters = await client.request(readItems('geo_clusters', { limit: -1 }));
|
||||||
|
// @ts-ignore
|
||||||
|
const locations = await client.request(readItems('geo_locations', { limit: -1 }));
|
||||||
|
|
||||||
|
logTest({
|
||||||
|
name: 'Geo Clusters',
|
||||||
|
status: clusters.length > 0 ? 'PASS' : 'WARN',
|
||||||
|
message: `Found ${clusters.length} clusters`,
|
||||||
|
details: clusters.length > 0 ? clusters.map((c: any) => c.cluster_name) : 'No data'
|
||||||
|
});
|
||||||
|
|
||||||
|
logTest({
|
||||||
|
name: 'Geo Locations',
|
||||||
|
status: locations.length > 0 ? 'PASS' : 'WARN',
|
||||||
|
message: `Found ${locations.length} locations`,
|
||||||
|
details: locations.length > 0 ? `${new Set(locations.map((l: any) => l.state)).size} states` : 'No data'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logTest({
|
||||||
|
name: 'Geo Intelligence',
|
||||||
|
status: 'FAIL',
|
||||||
|
message: 'Failed to read geo data',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEST 4: Avatar Variants
|
||||||
|
console.log('\n--- TEST 4: Avatar Variants ---');
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const variants = await client.request(readItems('avatar_variants', { limit: -1 }));
|
||||||
|
|
||||||
|
logTest({
|
||||||
|
name: 'Avatar Variants',
|
||||||
|
status: variants.length > 0 ? 'PASS' : 'WARN',
|
||||||
|
message: `Found ${variants.length} variants`,
|
||||||
|
details: variants.length > 0 ? {
|
||||||
|
sample: variants[0],
|
||||||
|
total: variants.length
|
||||||
|
} : 'No data'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logTest({
|
||||||
|
name: 'Avatar Variants',
|
||||||
|
status: 'FAIL',
|
||||||
|
message: 'Failed to read avatar variants',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEST 5: Spintax Dictionaries
|
||||||
|
console.log('\n--- TEST 5: Spintax Dictionaries ---');
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const dictionaries = await client.request(readItems('spintax_dictionaries', { limit: -1 }));
|
||||||
|
|
||||||
|
logTest({
|
||||||
|
name: 'Spintax Dictionaries',
|
||||||
|
status: dictionaries.length > 0 ? 'PASS' : 'WARN',
|
||||||
|
message: `Found ${dictionaries.length} dictionaries`,
|
||||||
|
details: dictionaries.length > 0 ? {
|
||||||
|
categories: dictionaries.map((d: any) => d.category),
|
||||||
|
total_terms: dictionaries.reduce((sum: number, d: any) => sum + (d.words?.length || d.data?.length || 0), 0)
|
||||||
|
} : 'No data'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logTest({
|
||||||
|
name: 'Spintax Dictionaries',
|
||||||
|
status: 'FAIL',
|
||||||
|
message: 'Failed to read spintax dictionaries',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEST 6: Cartesian Patterns
|
||||||
|
console.log('\n--- TEST 6: Cartesian Patterns ---');
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const patterns = await client.request(readItems('cartesian_patterns', { limit: -1 }));
|
||||||
|
|
||||||
|
logTest({
|
||||||
|
name: 'Cartesian Patterns',
|
||||||
|
status: patterns.length > 0 ? 'PASS' : 'WARN',
|
||||||
|
message: `Found ${patterns.length} patterns`,
|
||||||
|
details: patterns.length > 0 ? {
|
||||||
|
categories: [...new Set(patterns.map((p: any) => p.category))],
|
||||||
|
sample: patterns[0]
|
||||||
|
} : 'No data'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logTest({
|
||||||
|
name: 'Cartesian Patterns',
|
||||||
|
status: 'FAIL',
|
||||||
|
message: 'Failed to read cartesian patterns',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEST 7: Sites
|
||||||
|
console.log('\n--- TEST 7: Sites ---');
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const sites = await client.request(readItems('sites', { limit: -1 }));
|
||||||
|
|
||||||
|
logTest({
|
||||||
|
name: 'Sites',
|
||||||
|
status: sites.length > 0 ? 'PASS' : 'WARN',
|
||||||
|
message: `Found ${sites.length} sites`,
|
||||||
|
details: sites.length > 0 ? sites.map((s: any) => ({ name: s.name, url: s.url })) : 'No data'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logTest({
|
||||||
|
name: 'Sites',
|
||||||
|
status: 'FAIL',
|
||||||
|
message: 'Failed to read sites',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEST 8: Generation Jobs
|
||||||
|
console.log('\n--- TEST 8: Generation Jobs ---');
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const jobs = await client.request(readItems('generation_jobs', { limit: 10, sort: ['-date_created'] }));
|
||||||
|
|
||||||
|
logTest({
|
||||||
|
name: 'Generation Jobs',
|
||||||
|
status: 'PASS',
|
||||||
|
message: `Found ${jobs.length} recent jobs`,
|
||||||
|
details: jobs.length > 0 ? {
|
||||||
|
recent: jobs.slice(0, 3).map((j: any) => ({
|
||||||
|
id: j.id,
|
||||||
|
status: j.status,
|
||||||
|
type: j.type
|
||||||
|
}))
|
||||||
|
} : 'No jobs yet'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logTest({
|
||||||
|
name: 'Generation Jobs',
|
||||||
|
status: 'FAIL',
|
||||||
|
message: 'Failed to read generation jobs',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEST 9: Generated Articles
|
||||||
|
console.log('\n--- TEST 9: Generated Articles ---');
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const articles = await client.request(readItems('generated_articles', { limit: 10, sort: ['-date_created'] }));
|
||||||
|
|
||||||
|
logTest({
|
||||||
|
name: 'Generated Articles',
|
||||||
|
status: 'PASS',
|
||||||
|
message: `Found ${articles.length} recent articles`,
|
||||||
|
details: articles.length > 0 ? {
|
||||||
|
recent: articles.slice(0, 3).map((a: any) => ({
|
||||||
|
id: a.id,
|
||||||
|
title: a.title,
|
||||||
|
slug: a.slug
|
||||||
|
}))
|
||||||
|
} : 'No articles yet'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logTest({
|
||||||
|
name: 'Generated Articles',
|
||||||
|
status: 'FAIL',
|
||||||
|
message: 'Failed to read generated articles',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEST 10: Work Log
|
||||||
|
console.log('\n--- TEST 10: Work Log ---');
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const logs = await client.request(readItems('work_log', { limit: 10, sort: ['-date_created'] }));
|
||||||
|
|
||||||
|
logTest({
|
||||||
|
name: 'Work Log',
|
||||||
|
status: 'PASS',
|
||||||
|
message: `Found ${logs.length} recent log entries`,
|
||||||
|
details: logs.length > 0 ? {
|
||||||
|
recent: logs.slice(0, 3).map((l: any) => ({
|
||||||
|
action: l.action,
|
||||||
|
status: l.status
|
||||||
|
}))
|
||||||
|
} : 'No logs yet'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logTest({
|
||||||
|
name: 'Work Log',
|
||||||
|
status: 'FAIL',
|
||||||
|
message: 'Failed to read work log',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SUMMARY
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log('📊 DIAGNOSTIC SUMMARY');
|
||||||
|
console.log('='.repeat(60) + '\n');
|
||||||
|
|
||||||
|
const passed = results.filter(r => r.status === 'PASS').length;
|
||||||
|
const warned = results.filter(r => r.status === 'WARN').length;
|
||||||
|
const failed = results.filter(r => r.status === 'FAIL').length;
|
||||||
|
|
||||||
|
console.log(`✅ PASSED: ${passed}`);
|
||||||
|
console.log(`⚠️ WARNINGS: ${warned}`);
|
||||||
|
console.log(`❌ FAILED: ${failed}`);
|
||||||
|
console.log(`📝 TOTAL TESTS: ${results.length}\n`);
|
||||||
|
|
||||||
|
if (failed === 0 && warned === 0) {
|
||||||
|
console.log('🎉 ALL SYSTEMS OPERATIONAL!\n');
|
||||||
|
console.log('Your Spark Platform is fully connected and ready to use.\n');
|
||||||
|
} else if (failed === 0) {
|
||||||
|
console.log('✅ CORE SYSTEMS OPERATIONAL\n');
|
||||||
|
console.log('⚠️ Some collections are empty but functional.\n');
|
||||||
|
console.log('💡 Run the data import script to populate:\n');
|
||||||
|
console.log(' cd backend && npx ts-node scripts/init_schema.ts\n');
|
||||||
|
} else {
|
||||||
|
console.log('❌ ISSUES DETECTED\n');
|
||||||
|
console.log('Please review the failed tests above.\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('='.repeat(60) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
runDiagnostics().catch(console.error);
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,369 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { getDirectusClient, readItems, createItem, updateItem, deleteItem } from '@/lib/directus/client';
|
|
||||||
import { DataTable } from '../shared/DataTable';
|
|
||||||
import { CRUDModal } from '../shared/CRUDModal';
|
|
||||||
import { DeleteConfirm } from '../shared/DeleteConfirm';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
|
|
||||||
// Validation schema
|
|
||||||
const avatarVariantSchema = z.object({
|
|
||||||
avatar_key: z.string().min(1, 'Avatar key is required'),
|
|
||||||
variant_type: z.enum(['male', 'female', 'neutral']),
|
|
||||||
pronoun: z.string().min(1, 'Pronoun is required'),
|
|
||||||
identity: z.string().min(1, 'Identity is required'),
|
|
||||||
tone_modifiers: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type AvatarVariantFormData = z.infer<typeof avatarVariantSchema>;
|
|
||||||
|
|
||||||
interface AvatarVariant {
|
|
||||||
id: string;
|
|
||||||
avatar_key: string;
|
|
||||||
variant_type: 'male' | 'female' | 'neutral';
|
|
||||||
pronoun: string;
|
|
||||||
identity: string;
|
|
||||||
tone_modifiers?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AvatarVariantManager() {
|
|
||||||
const [variants, setVariants] = useState<AvatarVariant[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
|
||||||
const [editingVariant, setEditingVariant] = useState<AvatarVariant | null>(null);
|
|
||||||
const [deletingVariant, setDeletingVariant] = useState<AvatarVariant | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
setValue,
|
|
||||||
watch,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<AvatarVariantFormData>({
|
|
||||||
resolver: zodResolver(avatarVariantSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const variantType = watch('variant_type');
|
|
||||||
|
|
||||||
// Load data
|
|
||||||
const loadVariants = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const client = getDirectusClient();
|
|
||||||
const data = await client.request(
|
|
||||||
readItems('avatar_variants', {
|
|
||||||
fields: ['*'],
|
|
||||||
sort: ['avatar_key', 'variant_type'],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setVariants(data as AvatarVariant[]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading avatar variants:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadVariants();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle create/edit
|
|
||||||
const onSubmit = async (data: AvatarVariantFormData) => {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const client = getDirectusClient();
|
|
||||||
|
|
||||||
if (editingVariant) {
|
|
||||||
await client.request(
|
|
||||||
updateItem('avatar_variants', editingVariant.id, data)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await client.request(createItem('avatar_variants', data));
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadVariants();
|
|
||||||
setIsModalOpen(false);
|
|
||||||
reset();
|
|
||||||
setEditingVariant(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving variant:', error);
|
|
||||||
alert('Failed to save variant');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle delete
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deletingVariant) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const client = getDirectusClient();
|
|
||||||
await client.request(deleteItem('avatar_variants', deletingVariant.id));
|
|
||||||
await loadVariants();
|
|
||||||
setIsDeleteOpen(false);
|
|
||||||
setDeletingVariant(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting variant:', error);
|
|
||||||
alert('Failed to delete variant');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle edit click
|
|
||||||
const handleEdit = (variant: AvatarVariant) => {
|
|
||||||
setEditingVariant(variant);
|
|
||||||
setValue('avatar_key', variant.avatar_key);
|
|
||||||
setValue('variant_type', variant.variant_type);
|
|
||||||
setValue('pronoun', variant.pronoun);
|
|
||||||
setValue('identity', variant.identity);
|
|
||||||
setValue('tone_modifiers', variant.tone_modifiers || '');
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle add click
|
|
||||||
const handleAdd = () => {
|
|
||||||
setEditingVariant(null);
|
|
||||||
reset();
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle delete click
|
|
||||||
const handleDeleteClick = (variant: AvatarVariant) => {
|
|
||||||
setDeletingVariant(variant);
|
|
||||||
setIsDeleteOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export data
|
|
||||||
const handleExport = () => {
|
|
||||||
const dataStr = JSON.stringify(variants, null, 2);
|
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(dataBlob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `avatar-variants-${new Date().toISOString().split('T')[0]}.json`;
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Table columns
|
|
||||||
const columns: ColumnDef<AvatarVariant>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: 'avatar_key',
|
|
||||||
header: 'Avatar',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-medium text-white">{row.original.avatar_key}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'variant_type',
|
|
||||||
header: 'Type',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const type = row.original.variant_type;
|
|
||||||
const colors = {
|
|
||||||
male: 'bg-blue-500/20 text-blue-400',
|
|
||||||
female: 'bg-pink-500/20 text-pink-400',
|
|
||||||
neutral: 'bg-purple-500/20 text-purple-400',
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Badge className={colors[type]}>
|
|
||||||
{type}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'pronoun',
|
|
||||||
header: 'Pronoun',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'identity',
|
|
||||||
header: 'Identity',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'tone_modifiers',
|
|
||||||
header: 'Tone Modifiers',
|
|
||||||
cell: ({ row }) => row.original.tone_modifiers || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: 'Actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEdit(row.original)}
|
|
||||||
className="text-blue-400 hover:text-blue-300 hover:bg-blue-500/10"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDeleteClick(row.original)}
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">Total Variants</div>
|
|
||||||
<div className="text-3xl font-bold text-white">{variants.length}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">Male</div>
|
|
||||||
<div className="text-3xl font-bold text-blue-400">
|
|
||||||
{variants.filter((v) => v.variant_type === 'male').length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">Female</div>
|
|
||||||
<div className="text-3xl font-bold text-pink-400">
|
|
||||||
{variants.filter((v) => v.variant_type === 'female').length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">Neutral</div>
|
|
||||||
<div className="text-3xl font-bold text-purple-400">
|
|
||||||
{variants.filter((v) => v.variant_type === 'neutral').length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Table */}
|
|
||||||
<DataTable
|
|
||||||
data={variants}
|
|
||||||
columns={columns}
|
|
||||||
onAdd={handleAdd}
|
|
||||||
onExport={handleExport}
|
|
||||||
searchPlaceholder="Search variants..."
|
|
||||||
addButtonText="Add Variant"
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
|
||||||
<CRUDModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsModalOpen(false);
|
|
||||||
setEditingVariant(null);
|
|
||||||
reset();
|
|
||||||
}}
|
|
||||||
title={editingVariant ? 'Edit Avatar Variant' : 'Create Avatar Variant'}
|
|
||||||
description="Configure gender and tone variations for an avatar"
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
>
|
|
||||||
<form className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="avatar_key">Avatar Key</Label>
|
|
||||||
<Input
|
|
||||||
id="avatar_key"
|
|
||||||
{...register('avatar_key')}
|
|
||||||
placeholder="e.g., dr_smith"
|
|
||||||
className="bg-slate-900 border-slate-700"
|
|
||||||
/>
|
|
||||||
{errors.avatar_key && (
|
|
||||||
<p className="text-red-400 text-sm mt-1">{errors.avatar_key.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="variant_type">Variant Type</Label>
|
|
||||||
<Select
|
|
||||||
value={variantType}
|
|
||||||
onValueChange={(value) => setValue('variant_type', value as any)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="bg-slate-900 border-slate-700">
|
|
||||||
<SelectValue placeholder="Select type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="bg-slate-800 border-slate-700">
|
|
||||||
<SelectItem value="male">Male</SelectItem>
|
|
||||||
<SelectItem value="female">Female</SelectItem>
|
|
||||||
<SelectItem value="neutral">Neutral</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{errors.variant_type && (
|
|
||||||
<p className="text-red-400 text-sm mt-1">{errors.variant_type.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="pronoun">Pronoun</Label>
|
|
||||||
<Input
|
|
||||||
id="pronoun"
|
|
||||||
{...register('pronoun')}
|
|
||||||
placeholder="e.g., he/him, she/her, they/them"
|
|
||||||
className="bg-slate-900 border-slate-700"
|
|
||||||
/>
|
|
||||||
{errors.pronoun && (
|
|
||||||
<p className="text-red-400 text-sm mt-1">{errors.pronoun.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="identity">Identity</Label>
|
|
||||||
<Input
|
|
||||||
id="identity"
|
|
||||||
{...register('identity')}
|
|
||||||
placeholder="e.g., Dr. Sarah Smith"
|
|
||||||
className="bg-slate-900 border-slate-700"
|
|
||||||
/>
|
|
||||||
{errors.identity && (
|
|
||||||
<p className="text-red-400 text-sm mt-1">{errors.identity.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="tone_modifiers">Tone Modifiers (Optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="tone_modifiers"
|
|
||||||
{...register('tone_modifiers')}
|
|
||||||
placeholder="e.g., professional, friendly"
|
|
||||||
className="bg-slate-900 border-slate-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CRUDModal>
|
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
|
||||||
<DeleteConfirm
|
|
||||||
isOpen={isDeleteOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsDeleteOpen(false);
|
|
||||||
setDeletingVariant(null);
|
|
||||||
}}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
itemName={deletingVariant?.identity}
|
|
||||||
isDeleting={isSubmitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
|
|
||||||
interface GeoLocation {
|
|
||||||
id: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
zip_focus?: string;
|
|
||||||
neighborhood?: string;
|
|
||||||
cluster: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GeoCluster {
|
|
||||||
id: number;
|
|
||||||
cluster_name: string;
|
|
||||||
locations?: GeoLocation[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GeoIntelligenceManager() {
|
|
||||||
const [clusters, setClusters] = useState<GeoCluster[]>([]);
|
|
||||||
const [locations, setLocations] = useState<GeoLocation[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
// Load data
|
|
||||||
const loadData = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const client = getDirectusClient();
|
|
||||||
|
|
||||||
// Load clusters
|
|
||||||
const clustersData = await client.request(
|
|
||||||
readItems('geo_clusters', {
|
|
||||||
fields: ['*'],
|
|
||||||
sort: ['cluster_name'],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load locations
|
|
||||||
const locationsData = await client.request(
|
|
||||||
readItems('geo_locations', {
|
|
||||||
fields: ['*'],
|
|
||||||
sort: ['state', 'city'],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setClusters(clustersData as GeoCluster[]);
|
|
||||||
setLocations(locationsData as GeoLocation[]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading geo intelligence:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Group locations by cluster
|
|
||||||
const locationsByCluster = locations.reduce((acc, loc) => {
|
|
||||||
if (!acc[loc.cluster]) acc[loc.cluster] = [];
|
|
||||||
acc[loc.cluster].push(loc);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<number, GeoLocation[]>);
|
|
||||||
|
|
||||||
const totalCities = locations.length;
|
|
||||||
const totalStates = new Set(locations.map(l => l.state)).size;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">Total Clusters</div>
|
|
||||||
<div className="text-3xl font-bold text-white">{clusters.length}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">Total Cities</div>
|
|
||||||
<div className="text-3xl font-bold text-blue-400">{totalCities}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">States Covered</div>
|
|
||||||
<div className="text-3xl font-bold text-green-400">{totalStates}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Clusters */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{clusters.map((cluster) => {
|
|
||||||
const clusterLocations = locationsByCluster[cluster.id] || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={cluster.id} className="bg-slate-800 border-slate-700">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-white flex justify-between items-center">
|
|
||||||
<span className="flex items-center gap-3">
|
|
||||||
🗺️ {cluster.cluster_name}
|
|
||||||
</span>
|
|
||||||
<Badge className="bg-blue-600">
|
|
||||||
{clusterLocations.length} Cities
|
|
||||||
</Badge>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
||||||
{clusterLocations.map((loc) => (
|
|
||||||
<div
|
|
||||||
key={loc.id}
|
|
||||||
className="bg-slate-900 border border-slate-700 rounded-lg p-4 hover:border-blue-500/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="font-medium text-white mb-1">
|
|
||||||
{loc.city}, {loc.state}
|
|
||||||
</div>
|
|
||||||
{loc.neighborhood && (
|
|
||||||
<div className="text-xs text-slate-400 mb-1">
|
|
||||||
📍 {loc.neighborhood}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{loc.zip_focus && (
|
|
||||||
<div className="text-xs text-slate-500">
|
|
||||||
ZIP: {loc.zip_focus}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{clusters.length === 0 && (
|
|
||||||
<Card className="bg-slate-800 border-slate-700">
|
|
||||||
<CardContent className="p-12 text-center">
|
|
||||||
<p className="text-slate-400 mb-4">No geographic clusters found.</p>
|
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
Run the schema initialization script to import geo intelligence data.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { getDirectusClient, readItems, createItem, updateItem, deleteItem } from '@/lib/directus/client';
|
|
||||||
import { DataTable } from '../shared/DataTable';
|
|
||||||
import { CRUDModal } from '../shared/CRUDModal';
|
|
||||||
import { DeleteConfirm } from '../shared/DeleteConfirm';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
|
|
||||||
// Validation schema
|
|
||||||
const cartesianSchema = z.object({
|
|
||||||
pattern_key: z.string().min(1, 'Pattern key is required'),
|
|
||||||
pattern_type: z.string().min(1, 'Pattern type is required'),
|
|
||||||
formula: z.string().min(1, 'Formula is required'),
|
|
||||||
example_output: z.string().optional(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type CartesianFormData = z.infer<typeof cartesianSchema>;
|
|
||||||
|
|
||||||
interface CartesianPattern {
|
|
||||||
id: string;
|
|
||||||
pattern_key: string;
|
|
||||||
pattern_type: string;
|
|
||||||
formula: string;
|
|
||||||
example_output?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CartesianManager() {
|
|
||||||
const [patterns, setPatterns] = useState<CartesianPattern[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
|
||||||
const [editingPattern, setEditingPattern] = useState<CartesianPattern | null>(null);
|
|
||||||
const [deletingPattern, setDeletingPattern] = useState<CartesianPattern | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
setValue,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<CartesianFormData>({
|
|
||||||
resolver: zodResolver(cartesianSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load data
|
|
||||||
const loadPatterns = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const client = getDirectusClient();
|
|
||||||
const data = await client.request(
|
|
||||||
readItems('cartesian_patterns', {
|
|
||||||
fields: ['*'],
|
|
||||||
sort: ['pattern_type', 'pattern_key'],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setPatterns(data as CartesianPattern[]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading cartesian patterns:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadPatterns();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle create/edit
|
|
||||||
const onSubmit = async (data: CartesianFormData) => {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const client = getDirectusClient();
|
|
||||||
|
|
||||||
if (editingPattern) {
|
|
||||||
await client.request(
|
|
||||||
updateItem('cartesian_patterns', editingPattern.id, data)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await client.request(createItem('cartesian_patterns', data));
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadPatterns();
|
|
||||||
setIsModalOpen(false);
|
|
||||||
reset();
|
|
||||||
setEditingPattern(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving pattern:', error);
|
|
||||||
alert('Failed to save pattern');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle delete
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deletingPattern) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const client = getDirectusClient();
|
|
||||||
await client.request(deleteItem('cartesian_patterns', deletingPattern.id));
|
|
||||||
await loadPatterns();
|
|
||||||
setIsDeleteOpen(false);
|
|
||||||
setDeletingPattern(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting pattern:', error);
|
|
||||||
alert('Failed to delete pattern');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle edit click
|
|
||||||
const handleEdit = (pattern: CartesianPattern) => {
|
|
||||||
setEditingPattern(pattern);
|
|
||||||
Object.keys(pattern).forEach((key) => {
|
|
||||||
setValue(key as any, (pattern as any)[key]);
|
|
||||||
});
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle add click
|
|
||||||
const handleAdd = () => {
|
|
||||||
setEditingPattern(null);
|
|
||||||
reset();
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle delete click
|
|
||||||
const handleDeleteClick = (pattern: CartesianPattern) => {
|
|
||||||
setDeletingPattern(pattern);
|
|
||||||
setIsDeleteOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export data
|
|
||||||
const handleExport = () => {
|
|
||||||
const dataStr = JSON.stringify(patterns, null, 2);
|
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(dataBlob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `cartesian-patterns-${new Date().toISOString().split('T')[0]}.json`;
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Table columns
|
|
||||||
const columns: ColumnDef<CartesianPattern>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: 'pattern_key',
|
|
||||||
header: 'Pattern Key',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-medium text-white font-mono">{row.original.pattern_key}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'pattern_type',
|
|
||||||
header: 'Type',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge className="bg-purple-600">
|
|
||||||
{row.original.pattern_type}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'formula',
|
|
||||||
header: 'Formula',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<code className="text-xs text-green-400 bg-slate-900 px-2 py-1 rounded">
|
|
||||||
{row.original.formula.length > 50
|
|
||||||
? row.original.formula.substring(0, 50) + '...'
|
|
||||||
: row.original.formula}
|
|
||||||
</code>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'example_output',
|
|
||||||
header: 'Example',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm italic text-slate-400">
|
|
||||||
{row.original.example_output
|
|
||||||
? (row.original.example_output.length > 40
|
|
||||||
? row.original.example_output.substring(0, 40) + '...'
|
|
||||||
: row.original.example_output)
|
|
||||||
: '—'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: 'Actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEdit(row.original)}
|
|
||||||
className="text-blue-400 hover:text-blue-300 hover:bg-blue-500/10"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDeleteClick(row.original)}
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const patternTypes = new Set(patterns.map(p => p.pattern_type));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">Total Patterns</div>
|
|
||||||
<div className="text-3xl font-bold text-white">{patterns.length}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">Pattern Types</div>
|
|
||||||
<div className="text-3xl font-bold text-purple-400">{patternTypes.size}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">Avg Formula Length</div>
|
|
||||||
<div className="text-3xl font-bold text-green-400">
|
|
||||||
{patterns.length > 0
|
|
||||||
? Math.round(patterns.reduce((sum, p) => sum + p.formula.length, 0) / patterns.length)
|
|
||||||
: 0}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Table */}
|
|
||||||
<DataTable
|
|
||||||
data={patterns}
|
|
||||||
columns={columns}
|
|
||||||
onAdd={handleAdd}
|
|
||||||
onExport={handleExport}
|
|
||||||
searchPlaceholder="Search patterns..."
|
|
||||||
addButtonText="Add Pattern"
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
|
||||||
<CRUDModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsModalOpen(false);
|
|
||||||
setEditingPattern(null);
|
|
||||||
reset();
|
|
||||||
}}
|
|
||||||
title={editingPattern ? 'Edit Cartesian Pattern' : 'Create Cartesian Pattern'}
|
|
||||||
description="Define content generation patterns using Cartesian product logic"
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
>
|
|
||||||
<form className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="pattern_key">Pattern Key</Label>
|
|
||||||
<Input
|
|
||||||
id="pattern_key"
|
|
||||||
{...register('pattern_key')}
|
|
||||||
placeholder="e.g., headline_template_1"
|
|
||||||
className="bg-slate-900 border-slate-700"
|
|
||||||
/>
|
|
||||||
{errors.pattern_key && (
|
|
||||||
<p className="text-red-400 text-sm mt-1">{errors.pattern_key.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="pattern_type">Pattern Type</Label>
|
|
||||||
<Input
|
|
||||||
id="pattern_type"
|
|
||||||
{...register('pattern_type')}
|
|
||||||
placeholder="e.g., headline, intro, cta"
|
|
||||||
className="bg-slate-900 border-slate-700"
|
|
||||||
/>
|
|
||||||
{errors.pattern_type && (
|
|
||||||
<p className="text-red-400 text-sm mt-1">{errors.pattern_type.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="formula">Formula</Label>
|
|
||||||
<Textarea
|
|
||||||
id="formula"
|
|
||||||
{...register('formula')}
|
|
||||||
placeholder="e.g., {adjective} {noun} in {location}"
|
|
||||||
className="bg-slate-900 border-slate-700 font-mono text-sm"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
{errors.formula && (
|
|
||||||
<p className="text-red-400 text-sm mt-1">{errors.formula.message}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
|
||||||
Use {'{'}curly braces{'}'} for variables that will be replaced
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="example_output">Example Output (Optional)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="example_output"
|
|
||||||
{...register('example_output')}
|
|
||||||
placeholder="e.g., Amazing Services in Austin"
|
|
||||||
className="bg-slate-900 border-slate-700"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description">Description (Optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
{...register('description')}
|
|
||||||
placeholder="e.g., Main headline template for service pages"
|
|
||||||
className="bg-slate-900 border-slate-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CRUDModal>
|
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
|
||||||
<DeleteConfirm
|
|
||||||
isOpen={isDeleteOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsDeleteOpen(false);
|
|
||||||
setDeletingPattern(null);
|
|
||||||
}}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
itemName={deletingPattern?.pattern_key}
|
|
||||||
isDeleting={isSubmitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { getDirectusClient, readItems, createItem, updateItem, deleteItem } from '@/lib/directus/client';
|
|
||||||
import { DataTable } from '../shared/DataTable';
|
|
||||||
import { CRUDModal } from '../shared/CRUDModal';
|
|
||||||
import { DeleteConfirm } from '../shared/DeleteConfirm';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
|
|
||||||
// Validation schema
|
|
||||||
const spintaxSchema = z.object({
|
|
||||||
category: z.string().min(1, 'Category is required'),
|
|
||||||
terms: z.string().min(1, 'At least one term is required'),
|
|
||||||
description: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type SpintaxFormData = z.infer<typeof spintaxSchema>;
|
|
||||||
|
|
||||||
interface SpintaxDictionary {
|
|
||||||
id: string;
|
|
||||||
category: string;
|
|
||||||
data: string[];
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SpintaxManager() {
|
|
||||||
const [dictionaries, setDictionaries] = useState<SpintaxDictionary[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
|
||||||
const [editingDict, setEditingDict] = useState<SpintaxDictionary | null>(null);
|
|
||||||
const [deletingDict, setDeletingDict] = useState<SpintaxDictionary | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
setValue,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<SpintaxFormData>({
|
|
||||||
resolver: zodResolver(spintaxSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load data
|
|
||||||
const loadDictionaries = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const client = getDirectusClient();
|
|
||||||
const data = await client.request(
|
|
||||||
readItems('spintax_dictionaries', {
|
|
||||||
fields: ['*'],
|
|
||||||
sort: ['category'],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setDictionaries(data as SpintaxDictionary[]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading spintax dictionaries:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDictionaries();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle create/edit
|
|
||||||
const onSubmit = async (formData: SpintaxFormData) => {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const client = getDirectusClient();
|
|
||||||
|
|
||||||
// Convert comma-separated terms to array
|
|
||||||
const termsArray = formData.terms
|
|
||||||
.split(',')
|
|
||||||
.map(t => t.trim())
|
|
||||||
.filter(t => t.length > 0);
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
category: formData.category,
|
|
||||||
data: termsArray,
|
|
||||||
description: formData.description,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editingDict) {
|
|
||||||
await client.request(
|
|
||||||
updateItem('spintax_dictionaries', editingDict.id, data)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await client.request(createItem('spintax_dictionaries', data));
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadDictionaries();
|
|
||||||
setIsModalOpen(false);
|
|
||||||
reset();
|
|
||||||
setEditingDict(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving dictionary:', error);
|
|
||||||
alert('Failed to save dictionary');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle delete
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deletingDict) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const client = getDirectusClient();
|
|
||||||
await client.request(deleteItem('spintax_dictionaries', deletingDict.id));
|
|
||||||
await loadDictionaries();
|
|
||||||
setIsDeleteOpen(false);
|
|
||||||
setDeletingDict(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting dictionary:', error);
|
|
||||||
alert('Failed to delete dictionary');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle edit click
|
|
||||||
const handleEdit = (dict: SpintaxDictionary) => {
|
|
||||||
setEditingDict(dict);
|
|
||||||
setValue('category', dict.category);
|
|
||||||
setValue('terms', (dict.data || []).join(', '));
|
|
||||||
setValue('description', dict.description || '');
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle add click
|
|
||||||
const handleAdd = () => {
|
|
||||||
setEditingDict(null);
|
|
||||||
reset();
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle delete click
|
|
||||||
const handleDeleteClick = (dict: SpintaxDictionary) => {
|
|
||||||
setDeletingDict(dict);
|
|
||||||
setIsDeleteOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export data
|
|
||||||
const handleExport = () => {
|
|
||||||
const dataStr = JSON.stringify(dictionaries, null, 2);
|
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(dataBlob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `spintax-dictionaries-${new Date().toISOString().split('T')[0]}.json`;
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Table columns
|
|
||||||
const columns: ColumnDef<SpintaxDictionary>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: 'category',
|
|
||||||
header: 'Category',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-medium text-white">{row.original.category}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'data',
|
|
||||||
header: 'Terms',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex flex-wrap gap-1 max-w-md">
|
|
||||||
{(row.original.data || []).slice(0, 5).map((term, i) => (
|
|
||||||
<Badge key={i} variant="outline" className="text-xs">
|
|
||||||
{term}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{(row.original.data || []).length > 5 && (
|
|
||||||
<Badge variant="outline" className="text-xs text-slate-500">
|
|
||||||
+{(row.original.data || []).length - 5} more
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'count',
|
|
||||||
header: 'Count',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge className="bg-blue-600">
|
|
||||||
{(row.original.data || []).length}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'description',
|
|
||||||
header: 'Description',
|
|
||||||
cell: ({ row }) => row.original.description || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: 'Actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEdit(row.original)}
|
|
||||||
className="text-blue-400 hover:text-blue-300 hover:bg-blue-500/10"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDeleteClick(row.original)}
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const totalTerms = dictionaries.reduce((sum, d) => sum + (d.data || []).length, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">Total Dictionaries</div>
|
|
||||||
<div className="text-3xl font-bold text-white">{dictionaries.length}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">Total Terms</div>
|
|
||||||
<div className="text-3xl font-bold text-blue-400">{totalTerms}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-6">
|
|
||||||
<div className="text-sm text-slate-400 mb-2">Avg Terms/Dict</div>
|
|
||||||
<div className="text-3xl font-bold text-green-400">
|
|
||||||
{dictionaries.length > 0 ? Math.round(totalTerms / dictionaries.length) : 0}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Table */}
|
|
||||||
<DataTable
|
|
||||||
data={dictionaries}
|
|
||||||
columns={columns}
|
|
||||||
onAdd={handleAdd}
|
|
||||||
onExport={handleExport}
|
|
||||||
searchPlaceholder="Search dictionaries..."
|
|
||||||
addButtonText="Add Dictionary"
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
|
||||||
<CRUDModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsModalOpen(false);
|
|
||||||
setEditingDict(null);
|
|
||||||
reset();
|
|
||||||
}}
|
|
||||||
title={editingDict ? 'Edit Spintax Dictionary' : 'Create Spintax Dictionary'}
|
|
||||||
description="Manage synonym variations for content generation"
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
>
|
|
||||||
<form className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="category">Category</Label>
|
|
||||||
<Input
|
|
||||||
id="category"
|
|
||||||
{...register('category')}
|
|
||||||
placeholder="e.g., adjectives, verbs, locations"
|
|
||||||
className="bg-slate-900 border-slate-700"
|
|
||||||
/>
|
|
||||||
{errors.category && (
|
|
||||||
<p className="text-red-400 text-sm mt-1">{errors.category.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="terms">Terms (comma-separated)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="terms"
|
|
||||||
{...register('terms')}
|
|
||||||
placeholder="e.g., great, excellent, amazing, fantastic"
|
|
||||||
className="bg-slate-900 border-slate-700"
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
{errors.terms && (
|
|
||||||
<p className="text-red-400 text-sm mt-1">{errors.terms.message}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
|
||||||
Separate terms with commas. Each term will be a variation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description">Description (Optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
{...register('description')}
|
|
||||||
placeholder="e.g., Positive adjectives for product descriptions"
|
|
||||||
className="bg-slate-900 border-slate-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CRUDModal>
|
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
|
||||||
<DeleteConfirm
|
|
||||||
isOpen={isDeleteOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsDeleteOpen(false);
|
|
||||||
setDeletingDict(null);
|
|
||||||
}}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
itemName={deletingDict?.category}
|
|
||||||
isDeleting={isSubmitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
|
|
||||||
interface CRUDModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
onSubmit: () => void;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
submitText?: string;
|
|
||||||
cancelText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CRUDModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
onSubmit,
|
|
||||||
isSubmitting = false,
|
|
||||||
children,
|
|
||||||
submitText = 'Save',
|
|
||||||
cancelText = 'Cancel',
|
|
||||||
}: CRUDModalProps) {
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="bg-slate-800 border-slate-700 text-white max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold text-white">{title}</DialogTitle>
|
|
||||||
{description && (
|
|
||||||
<DialogDescription className="text-slate-400">
|
|
||||||
{description}
|
|
||||||
</DialogDescription>
|
|
||||||
)}
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="py-4">{children}</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="bg-slate-700 border-slate-600 hover:bg-slate-600"
|
|
||||||
>
|
|
||||||
{cancelText}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onSubmit}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
||||||
Saving...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
submitText
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
useReactTable,
|
|
||||||
getCoreRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
flexRender,
|
|
||||||
type ColumnDef,
|
|
||||||
type SortingState,
|
|
||||||
type ColumnFiltersState,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
|
|
||||||
interface DataTableProps<TData> {
|
|
||||||
data: TData[];
|
|
||||||
columns: ColumnDef<TData>[];
|
|
||||||
onAdd?: () => void;
|
|
||||||
onEdit?: (row: TData) => void;
|
|
||||||
onDelete?: (row: TData) => void;
|
|
||||||
onExport?: () => void;
|
|
||||||
searchPlaceholder?: string;
|
|
||||||
addButtonText?: string;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTable<TData>({
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
onAdd,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onExport,
|
|
||||||
searchPlaceholder = 'Search...',
|
|
||||||
addButtonText = 'Add New',
|
|
||||||
isLoading = false,
|
|
||||||
}: DataTableProps<TData>) {
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
||||||
const [globalFilter, setGlobalFilter] = useState('');
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
onColumnFiltersChange: setColumnFilters,
|
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
|
||||||
state: {
|
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
globalFilter,
|
|
||||||
},
|
|
||||||
initialState: {
|
|
||||||
pagination: {
|
|
||||||
pageSize: 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex-1 max-w-sm">
|
|
||||||
<Input
|
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
value={globalFilter ?? ''}
|
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
||||||
className="bg-slate-900 border-slate-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{onExport && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onExport}
|
|
||||||
className="bg-slate-800 border-slate-700 hover:bg-slate-700"
|
|
||||||
>
|
|
||||||
📤 Export
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onAdd && (
|
|
||||||
<Button
|
|
||||||
onClick={onAdd}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
✨ {addButtonText}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div className="rounded-lg border border-slate-700 bg-slate-800 overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-slate-900">
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<tr key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<th
|
|
||||||
key={header.id}
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider cursor-pointer hover:text-slate-200"
|
|
||||||
onClick={header.column.getToggleSortingHandler()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
{{
|
|
||||||
asc: ' 🔼',
|
|
||||||
desc: ' 🔽',
|
|
||||||
}[header.column.getIsSorted() as string] ?? null}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-700">
|
|
||||||
{isLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="px-6 py-12 text-center text-slate-500"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : table.getRowModel().rows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="px-6 py-12 text-center text-slate-500"
|
|
||||||
>
|
|
||||||
No data found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<tr
|
|
||||||
key={row.id}
|
|
||||||
className="hover:bg-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td key={cell.id} className="px-6 py-4 text-sm text-slate-300">
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{table.getPageCount() > 1 && (
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 bg-slate-900 border-t border-slate-700">
|
|
||||||
<div className="text-sm text-slate-400">
|
|
||||||
Showing {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} to{' '}
|
|
||||||
{Math.min(
|
|
||||||
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
|
|
||||||
table.getFilteredRowModel().rows.length
|
|
||||||
)}{' '}
|
|
||||||
of {table.getFilteredRowModel().rows.length} results
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
className="bg-slate-800 border-slate-700"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
className="bg-slate-800 border-slate-700"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from '@/components/ui/alert-dialog';
|
|
||||||
|
|
||||||
interface DeleteConfirmProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
itemName?: string;
|
|
||||||
isDeleting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteConfirm({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
title = 'Are you sure?',
|
|
||||||
description,
|
|
||||||
itemName,
|
|
||||||
isDeleting = false,
|
|
||||||
}: DeleteConfirmProps) {
|
|
||||||
const defaultDescription = itemName
|
|
||||||
? `This will permanently delete "${itemName}". This action cannot be undone.`
|
|
||||||
: 'This action cannot be undone. This will permanently delete the item.';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AlertDialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<AlertDialogContent className="bg-slate-800 border-slate-700">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle className="text-white">{title}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription className="text-slate-400">
|
|
||||||
{description || defaultDescription}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="bg-slate-700 border-slate-600 hover:bg-slate-600 text-white"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="bg-red-600 hover:bg-red-700 text-white"
|
|
||||||
>
|
|
||||||
{isDeleting ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
||||||
Deleting...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'Delete'
|
|
||||||
)}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
|
||||||
import AvatarVariantManager from '@/components/admin/collections/AvatarVariantManager';
|
|
||||||
---
|
|
||||||
|
|
||||||
<AdminLayout title="Avatar Variants">
|
|
||||||
<div class="p-8">
|
|
||||||
<div class="mb-6">
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">🎭 Avatar Variants</h1>
|
|
||||||
<p class="text-slate-400">Manage gender and tone variations for avatars</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AvatarVariantManager client:load />
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
|
||||||
import GeoIntelligenceManager from '@/components/admin/collections/GeoIntelligenceManager';
|
|
||||||
---
|
|
||||||
|
|
||||||
<AdminLayout title="Geo Intelligence">
|
|
||||||
<div class="p-8">
|
|
||||||
<div class="mb-6">
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">🗺️ Geo Intelligence</h1>
|
|
||||||
<p class="text-slate-400">Location targeting and geographic data</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GeoIntelligenceManager client:load />
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
|
||||||
import CartesianManagerEnhanced from '@/components/admin/content/CartesianManagerEnhanced';
|
|
||||||
---
|
|
||||||
|
|
||||||
<AdminLayout title="Cartesian Patterns">
|
|
||||||
<div class="p-8">
|
|
||||||
<div class="mb-6">
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">🔀 Cartesian Patterns</h1>
|
|
||||||
<p class="text-slate-400">Define content generation patterns using Cartesian product logic</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CartesianManagerEnhanced client:load />
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
|
||||||
import SpintaxManagerEnhanced from '@/components/admin/content/SpintaxManagerEnhanced';
|
|
||||||
---
|
|
||||||
|
|
||||||
<AdminLayout title="Spintax Dictionaries">
|
|
||||||
<div class="p-8">
|
|
||||||
<div class="mb-6">
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">📚 Spintax Dictionaries</h1>
|
|
||||||
<p class="text-slate-400">Manage synonym variations for content generation</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SpintaxManagerEnhanced client:load />
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
Reference in New Issue
Block a user