feat: Complete Intelligence Library full CRUD + Fix Jumpstart error

Intelligence Library:
- Add full CRUD for Avatar Variants, Geo Intelligence, Spintax, Cartesian
- Create reusable DataTable, CRUDModal, DeleteConfirm components
- Add TanStack Table for advanced sorting/filtering/pagination
- Add React Hook Form + Zod for validated forms
- Add export, search, sort, filter capabilities

Jumpstart Fix:
- Fix 'Error: undefined' when creating generation jobs
- Store config instead of full inventory (1456 posts)
- Improve error logging
- Engine fetches posts directly from WordPress

All pages tested and ready for deployment.
This commit is contained in:
cawcenter
2025-12-13 18:08:31 -05:00
parent b2d548c5fb
commit d400aac5c2
19 changed files with 2362 additions and 304 deletions

View File

@@ -20,6 +20,7 @@
"@directus/sdk": "^17.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.2.2",
"@nanostores/react": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
@@ -29,6 +30,7 @@
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.13",
"@tiptap/extension-placeholder": "^3.13.0",
@@ -2495,6 +2497,17 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
@@ -5232,6 +5245,15 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.91.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz",
"integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
@@ -5247,6 +5269,22 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.91.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.1.tgz",
"integrity": "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==",
"dependencies": {
"@tanstack/query-devtools": "5.91.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.90.10",
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",

View File

@@ -22,6 +22,7 @@
"@directus/sdk": "^17.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.2.2",
"@nanostores/react": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
@@ -31,6 +32,7 @@
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.13",
"@tiptap/extension-placeholder": "^3.13.0",

View File

@@ -0,0 +1,369 @@
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>
);
}

View File

@@ -0,0 +1,406 @@
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';
// Validation schema
const geoIntelligenceSchema = z.object({
location_key: z.string().min(1, 'Location key is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(1, 'State is required'),
county: z.string().optional(),
zip_code: z.string().optional(),
population: z.number().int().positive().optional(),
median_income: z.number().positive().optional(),
keywords: z.string().optional(),
local_modifiers: z.string().optional(),
});
type GeoIntelligenceFormData = z.infer<typeof geoIntelligenceSchema>;
interface GeoIntelligence {
id: string;
location_key: string;
city: string;
state: string;
county?: string;
zip_code?: string;
population?: number;
median_income?: number;
keywords?: string;
local_modifiers?: string;
}
export default function GeoIntelligenceManager() {
const [locations, setLocations] = useState<GeoIntelligence[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [editingLocation, setEditingLocation] = useState<GeoIntelligence | null>(null);
const [deletingLocation, setDeletingLocation] = useState<GeoIntelligence | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<GeoIntelligenceFormData>({
resolver: zodResolver(geoIntelligenceSchema),
});
// Load data
const loadLocations = async () => {
setIsLoading(true);
try {
const client = getDirectusClient();
const data = await client.request(
readItems('geo_intelligence', {
fields: ['*'],
sort: ['state', 'city'],
})
);
setLocations(data as GeoIntelligence[]);
} catch (error) {
console.error('Error loading geo intelligence:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadLocations();
}, []);
// Handle create/edit
const onSubmit = async (data: GeoIntelligenceFormData) => {
setIsSubmitting(true);
try {
const client = getDirectusClient();
if (editingLocation) {
await client.request(
updateItem('geo_intelligence', editingLocation.id, data)
);
} else {
await client.request(createItem('geo_intelligence', data));
}
await loadLocations();
setIsModalOpen(false);
reset();
setEditingLocation(null);
} catch (error) {
console.error('Error saving location:', error);
alert('Failed to save location');
} finally {
setIsSubmitting(false);
}
};
// Handle delete
const handleDelete = async () => {
if (!deletingLocation) return;
setIsSubmitting(true);
try {
const client = getDirectusClient();
await client.request(deleteItem('geo_intelligence', deletingLocation.id));
await loadLocations();
setIsDeleteOpen(false);
setDeletingLocation(null);
} catch (error) {
console.error('Error deleting location:', error);
alert('Failed to delete location');
} finally {
setIsSubmitting(false);
}
};
// Handle edit click
const handleEdit = (location: GeoIntelligence) => {
setEditingLocation(location);
Object.keys(location).forEach((key) => {
setValue(key as any, (location as any)[key]);
});
setIsModalOpen(true);
};
// Handle add click
const handleAdd = () => {
setEditingLocation(null);
reset();
setIsModalOpen(true);
};
// Handle delete click
const handleDeleteClick = (location: GeoIntelligence) => {
setDeletingLocation(location);
setIsDeleteOpen(true);
};
// Export data
const handleExport = () => {
const dataStr = JSON.stringify(locations, 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 = `geo-intelligence-${new Date().toISOString().split('T')[0]}.json`;
link.click();
};
// Table columns
const columns: ColumnDef<GeoIntelligence>[] = [
{
accessorKey: 'location_key',
header: 'Location Key',
cell: ({ row }) => (
<span className="font-medium text-white font-mono">{row.original.location_key}</span>
),
},
{
accessorKey: 'city',
header: 'City',
cell: ({ row }) => (
<span className="text-white">{row.original.city}</span>
),
},
{
accessorKey: 'state',
header: 'State',
},
{
accessorKey: 'county',
header: 'County',
cell: ({ row }) => row.original.county || '—',
},
{
accessorKey: 'population',
header: 'Population',
cell: ({ row }) =>
row.original.population
? row.original.population.toLocaleString()
: '—',
},
{
accessorKey: 'median_income',
header: 'Median Income',
cell: ({ row }) =>
row.original.median_income
? `$${row.original.median_income.toLocaleString()}`
: '—',
},
{
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-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 Locations</div>
<div className="text-3xl font-bold text-white">{locations.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">States Covered</div>
<div className="text-3xl font-bold text-green-400">
{new Set(locations.map((l) => l.state)).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 Population</div>
<div className="text-3xl font-bold text-blue-400">
{locations.filter(l => l.population).length > 0
? Math.round(
locations.reduce((sum, l) => sum + (l.population || 0), 0) /
locations.filter(l => l.population).length
).toLocaleString()
: '—'}
</div>
</div>
</div>
{/* Data Table */}
<DataTable
data={locations}
columns={columns}
onAdd={handleAdd}
onExport={handleExport}
searchPlaceholder="Search locations..."
addButtonText="Add Location"
isLoading={isLoading}
/>
{/* Create/Edit Modal */}
<CRUDModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setEditingLocation(null);
reset();
}}
title={editingLocation ? 'Edit Location' : 'Add Location'}
description="Configure geographic intelligence data"
onSubmit={handleSubmit(onSubmit)}
isSubmitting={isSubmitting}
>
<form className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="location_key">Location Key</Label>
<Input
id="location_key"
{...register('location_key')}
placeholder="e.g., austin-tx"
className="bg-slate-900 border-slate-700"
/>
{errors.location_key && (
<p className="text-red-400 text-sm mt-1">{errors.location_key.message}</p>
)}
</div>
<div>
<Label htmlFor="city">City</Label>
<Input
id="city"
{...register('city')}
placeholder="e.g., Austin"
className="bg-slate-900 border-slate-700"
/>
{errors.city && (
<p className="text-red-400 text-sm mt-1">{errors.city.message}</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="state">State</Label>
<Input
id="state"
{...register('state')}
placeholder="e.g., TX"
className="bg-slate-900 border-slate-700"
/>
{errors.state && (
<p className="text-red-400 text-sm mt-1">{errors.state.message}</p>
)}
</div>
<div>
<Label htmlFor="county">County (Optional)</Label>
<Input
id="county"
{...register('county')}
placeholder="e.g., Travis"
className="bg-slate-900 border-slate-700"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="zip_code">ZIP Code (Optional)</Label>
<Input
id="zip_code"
{...register('zip_code')}
placeholder="e.g., 78701"
className="bg-slate-900 border-slate-700"
/>
</div>
<div>
<Label htmlFor="population">Population (Optional)</Label>
<Input
id="population"
type="number"
{...register('population', { valueAsNumber: true })}
placeholder="e.g., 950000"
className="bg-slate-900 border-slate-700"
/>
</div>
</div>
<div>
<Label htmlFor="median_income">Median Income (Optional)</Label>
<Input
id="median_income"
type="number"
{...register('median_income', { valueAsNumber: true })}
placeholder="e.g., 75000"
className="bg-slate-900 border-slate-700"
/>
</div>
<div>
<Label htmlFor="keywords">Keywords (Optional)</Label>
<Textarea
id="keywords"
{...register('keywords')}
placeholder="e.g., tech hub, live music, BBQ"
className="bg-slate-900 border-slate-700"
rows={2}
/>
</div>
<div>
<Label htmlFor="local_modifiers">Local Modifiers (Optional)</Label>
<Textarea
id="local_modifiers"
{...register('local_modifiers')}
placeholder="e.g., Keep Austin Weird, Silicon Hills"
className="bg-slate-900 border-slate-700"
rows={2}
/>
</div>
</form>
</CRUDModal>
{/* Delete Confirmation */}
<DeleteConfirm
isOpen={isDeleteOpen}
onClose={() => {
setIsDeleteOpen(false);
setDeletingLocation(null);
}}
onConfirm={handleDelete}
itemName={deletingLocation ? `${deletingLocation.city}, ${deletingLocation.state}` : undefined}
isDeleting={isSubmitting}
/>
</div>
);
}

View File

@@ -0,0 +1,354 @@
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>
);
}

View File

@@ -0,0 +1,333 @@
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>
);
}

View File

@@ -160,11 +160,14 @@ export default function JumpstartWizard() {
const job = await client.request(createItem('generation_jobs', {
site_id: siteId,
status: 'Pending',
type: 'Refactor', // or Import
type: 'Refactor',
target_quantity: inventory.total_posts,
filters: {
items: inventory.items, // Store the full list to process
mode: 'refactor'
config: {
wordpress_url: siteUrl,
wordpress_auth: appPassword ? `${username}:${appPassword}` : null,
mode: 'refactor',
batch_size: 5,
total_posts: inventory.total_posts
}
}));
const newJobId = job.id;
@@ -190,7 +193,9 @@ export default function JumpstartWizard() {
addLog(`❌ Ignition Error: ${err.message || err.error}`);
}
} catch (e) {
addLog(`❌ Error: ${e.message}`);
const errorMsg = e?.message || e?.error || e?.toString() || 'Unknown error';
addLog(`❌ Error: ${errorMsg}`);
console.error('Full Jumpstart error:', e);
}
};

View File

@@ -0,0 +1,76 @@
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>
);
}

View File

@@ -0,0 +1,202 @@
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>
);
}

View File

@@ -0,0 +1,70 @@
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>
);
}

View File

@@ -1,148 +1,15 @@
---
/**
* Avatar Variants Management
* Full CRUD for avatar_variants collection
*/
import AdminLayout from '@/layouts/AdminLayout.astro';
import { getDirectusClient } from '@/lib/directus/client';
import { readItems } from '@directus/sdk';
const client = getDirectusClient();
let items = [];
let error = null;
let stats = {
total: 0,
male: 0,
female: 0,
neutral: 0,
};
try {
items = await client.request(readItems('avatar_variants', {
fields: ['*'],
sort: ['avatar_key', 'variant_type'],
}));
stats.total = items.length;
stats.male = items.filter((i: any) => i.variant_type === 'male').length;
stats.female = items.filter((i: any) => i.variant_type === 'female').length;
stats.neutral = items.filter((i: any) => i.variant_type === 'neutral').length;
} catch (e) {
console.error('Error fetching avatar variants:', e);
error = e instanceof Error ? e.message : 'Unknown error';
}
import AvatarVariantManager from '@/components/admin/collections/AvatarVariantManager';
---
<AdminLayout title="Avatar Variants">
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<div>
<h1 class="spark-heading text-3xl">🎭 Avatar Variants</h1>
<p class="text-silver mt-1">Manage gender and tone variations</p>
</div>
<div class="flex gap-3">
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('import-modal'))">
📥 Import
</button>
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'avatar_variants'}}))">
📤 Export
</button>
<a href="/admin/collections/avatar-variants/new" class="spark-btn-primary text-sm">
✨ New Variant
</a>
</div>
</div>
{error && (
<div class="spark-card p-4 border-red-500 text-red-400">
<strong>Error:</strong> {error}
</div>
)}
<!-- Stats -->
<div class="grid grid-cols-4 gap-4">
<div class="spark-card p-6">
<div class="spark-label mb-2">Total Variants</div>
<div class="spark-data text-3xl">{stats.total}</div>
</div>
<div class="spark-card p-6">
<div class="spark-label mb-2">Male</div>
<div class="spark-data text-3xl text-blue-400">{stats.male}</div>
</div>
<div class="spark-card p-6">
<div class="spark-label mb-2">Female</div>
<div class="spark-data text-3xl text-pink-400">{stats.female}</div>
</div>
<div class="spark-card p-6">
<div class="spark-label mb-2">Neutral</div>
<div class="spark-data text-3xl text-purple-400">{stats.neutral}</div>
</div>
</div>
<!-- Variants Table -->
<div class="spark-card overflow-hidden">
<div class="p-6 border-b border-edge-subtle">
<h2 class="text-white font-semibold">All Variants</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-graphite">
<tr>
<th class="text-left px-6 py-3 spark-label">Avatar</th>
<th class="text-left px-6 py-3 spark-label">Type</th>
<th class="text-left px-6 py-3 spark-label">Pronouns</th>
<th class="text-left px-6 py-3 spark-label">Identity</th>
<th class="text-right px-6 py-3 spark-label">Actions</th>
</tr>
</thead>
<tbody>
{items.map((variant: any, index: number) => (
<tr class={index % 2 === 0 ? 'bg-black/20' : ''}>
<td class="px-6 py-4 text-white">{variant.avatar_key}</td>
<td class="px-6 py-4">
<span class={`px-2 py-1 rounded text-xs ${
variant.variant_type === 'male' ? 'bg-blue-500/20 text-blue-400' :
variant.variant_type === 'female' ? 'bg-pink-500/20 text-pink-400' :
'bg-purple-500/20 text-purple-400'
}`}>
{variant.variant_type}
</span>
</td>
<td class="px-6 py-4 text-silver">{variant.pronoun}</td>
<td class="px-6 py-4 text-silver">{variant.identity}</td>
<td class="px-6 py-4 text-right">
<a href={`/admin/collections/avatar-variants/${variant.id}`} class="spark-btn-ghost text-xs px-3 py-1">
Edit
</a>
</td>
</tr>
))}
</tbody>
</table>
{items.length === 0 && !error && (
<div class="p-12 text-center">
<p class="text-silver/50">No variants found. Create your first one!</p>
</div>
)}
</div>
<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>
<script>
// Handle export
window.addEventListener('export-data', async (e: any) => {
const { collection } = e.detail;
const response = await fetch(`/api/collections/${collection}/export`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
a.click();
});
</script>

View File

@@ -1,145 +1,15 @@
---
/**
* Geo Intelligence Management
* Location targeting and geographic data
*/
import AdminLayout from '@/layouts/AdminLayout.astro';
import { getDirectusClient } from '@/lib/directus/client';
import { readItems } from '@directus/sdk';
const client = getDirectusClient();
let locations = [];
let error = null;
let stats = {
total: 0,
cities: 0,
states: 0,
countries: 0,
};
try {
locations = await client.request(readItems('geo_intelligence', {
fields: ['*'],
sort: ['state', 'city'],
}));
stats.total = locations.length;
stats.cities = new Set(locations.map((l: any) => l.city)).size;
stats.states = new Set(locations.map((l: any) => l.state)).size;
stats.countries = new Set(locations.map((l: any) => l.country)).size;
} catch (e) {
console.error('Error fetching locations:', e);
error = e instanceof Error ? e.message : 'Unknown error';
}
// Group locations by state
const byState = locations.reduce((acc: any, loc: any) => {
if (!acc[loc.state]) acc[loc.state] = [];
acc[loc.state].push(loc);
return acc;
}, {});
import GeoIntelligenceManager from '@/components/admin/collections/GeoIntelligenceManager';
---
<AdminLayout title="Geo Intelligence">
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<div>
<h1 class="spark-heading text-3xl">🗺️ Geo Intelligence</h1>
<p class="text-silver mt-1">Location targeting and geographic data</p>
</div>
<div class="flex gap-3">
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('import-modal'))">
📥 Import CSV
</button>
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'geo_intelligence'}}))">
📤 Export
</button>
<a href="/admin/collections/geo-intelligence/new" class="spark-btn-primary text-sm">
✨ New Location
</a>
</div>
</div>
{error && (
<div class="spark-card p-4 border-red-500 text-red-400">
<strong>Error:</strong> {error}
</div>
)}
<!-- Stats -->
<div class="grid grid-cols-4 gap-4">
<div class="spark-card p-6">
<div class="spark-label mb-2">Total Locations</div>
<div class="spark-data text-3xl">{stats.total}</div>
</div>
<div class="spark-card p-6">
<div class="spark-label mb-2">Cities</div>
<div class="spark-data text-3xl text-blue-400">{stats.cities}</div>
</div>
<div class="spark-card p-6">
<div class="spark-label mb-2">States</div>
<div class="spark-data text-3xl text-green-400">{stats.states}</div>
</div>
<div class="spark-card p-6">
<div class="spark-label mb-2">Countries</div>
<div class="spark-data text-3xl text-purple-400">{stats.countries}</div>
</div>
</div>
<!-- Locations by State -->
<div class="space-y-4">
{Object.entries(byState).map(([state, locs]: [string, any]) => (
<div class="spark-card overflow-hidden">
<div class="p-4 border-b border-edge-subtle bg-graphite">
<div class="flex justify-between items-center">
<h3 class="text-white font-semibold">{state}</h3>
<span class="spark-label">{locs.length} locations</span>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-6">
{locs.map((loc: any) => (
<div class="p-4 bg-black/20 rounded border border-edge-subtle hover:border-gold/30 transition-colors">
<div class="text-white font-medium">{loc.city}</div>
{loc.zip && <div class="text-silver/50 text-sm">{loc.zip}</div>}
{loc.population && (
<div class="text-silver text-xs mt-1">
Pop: {loc.population.toLocaleString()}
</div>
)}
{loc.cluster && (
<div class="mt-2">
<span class="px-2 py-0.5 bg-gold/10 text-gold text-xs rounded">
{loc.cluster}
</span>
</div>
)}
</div>
))}
</div>
</div>
))}
{locations.length === 0 && !error && (
<div class="spark-card p-12 text-center">
<p class="text-silver/50">No locations found. Import your geo data!</p>
</div>
)}
<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>
<script>
window.addEventListener('export-data', async (e: any) => {
const { collection } = e.detail;
const response = await fetch(`/api/collections/${collection}/export`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
a.click();
});
</script>

View File

@@ -1,17 +1,15 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import CartesianManager from '@/components/admin/content/CartesianManager';
import { getDirectusClient, readItems } from '@/lib/directus/client';
import AdminLayout from '@/layouts/AdminLayout.astro';
import CartesianManagerEnhanced from '@/components/admin/content/CartesianManagerEnhanced';
---
const directus = getDirectusClient();
const patterns = await directus.request(readItems('cartesian_patterns')).catch(() => []);
---
<Layout title="Cartesian Patterns">
<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-gray-400">Manage headline and content generation formulas.</p>
<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>
<CartesianManager client:load initialPatterns={patterns} />
<CartesianManagerEnhanced client:load />
</div>
</Layout>
</AdminLayout>

View File

@@ -1,17 +1,15 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import SpintaxManager from '@/components/admin/content/SpintaxManager';
import { getDirectusClient, readItems } from '@/lib/directus/client';
import AdminLayout from '@/layouts/AdminLayout.astro';
import SpintaxManagerEnhanced from '@/components/admin/content/SpintaxManagerEnhanced';
---
const directus = getDirectusClient();
const dictionaries = await directus.request(readItems('spintax_dictionaries')).catch(() => []);
---
<Layout title="Spintax Dictionaries">
<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-gray-400">Manage word variations for automated content generation.</p>
<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>
<SpintaxManager client:load initialDictionaries={dictionaries} />
<SpintaxManagerEnhanced client:load />
</div>
</Layout>
</AdminLayout>