CollectionEditor.jsx
import * as React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../hooks/useAPI.js';
import { useChat } from '../layouts/AdminLayout.jsx';
const FIELD_TYPES = ['string', 'text', 'number', 'bool', 'date', 'email', 'url', 'select', 'json'];
export function CollectionEditor() {
const { toggleChat } = useChat() || {};
const { id } = useParams();
const navigate = useNavigate();
const [collection, setCollection] = useState(null);
const [documents, setDocuments] = useState([]);
const [fields, setFields] = useState([]);
const [loading, setLoading] = useState(true);
const [showEditModal, setShowEditModal] = useState(false);
const [showDocModal, setShowDocModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [saving, setSaving] = useState(false);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState(null);
const [editFields, setEditFields] = useState([]);
const [editDoc, setEditDoc] = useState(null);
const fetchData = useCallback(() => {
if (!id) return;
setLoading(true);
Promise.all([
apiGet(`/api/admin/collections/${id}`),
apiGet(`/api/admin/collections/${id}/documents`),
]).then(([colData, docsData]) => {
setCollection(colData);
setDocuments(docsData?.items || []);
const schemaFields = colData?.schema ? JSON.parse(colData.schema) : [];
setFields(schemaFields);
setEditFields(schemaFields);
}).finally(() => setLoading(false));
}, [id]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleEditSubmit = async (e) => {
e.preventDefault();
if (!id) return;
const form = e.target;
const formData = new FormData(form);
setSaving(true);
try {
await apiPut(`/api/admin/collections/${id}`, {
name: formData.get('name'),
description: formData.get('description'),
schema: JSON.stringify(editFields),
});
setShowEditModal(false);
fetchData();
} finally {
setSaving(false);
}
};
const handleDocSubmit = async (e) => {
e.preventDefault();
if (!id) return;
const form = e.target;
const formData = new FormData(form);
const data = {};
fields.forEach((field) => {
const value = formData.get(field.name);
if (field.type === 'bool') {
data[field.name] = value === 'on';
} else if (field.type === 'number') {
data[field.name] = value ? parseFloat(value) : 0;
} else if (field.type === 'json') {
try {
data[field.name] = value ? JSON.parse(value) : null;
} catch {
data[field.name] = value || '';
}
} else {
data[field.name] = value || '';
}
});
setSaving(true);
try {
if (editDoc) {
await apiPatch(`/api/admin/collections/${id}/documents/${editDoc.id}`, { data });
} else {
await apiPost(`/api/admin/collections/${id}/documents`, { data });
}
setShowDocModal(false);
setEditDoc(null);
form.reset();
fetchData();
} finally {
setSaving(false);
}
};
const openEditDoc = (doc) => {
setEditDoc(doc);
setShowDocModal(true);
};
const openNewDoc = () => {
setEditDoc(null);
setShowDocModal(true);
};
const deleteDocument = async (docId) => {
if (!confirm('Delete this document?')) return;
await apiDelete(`/api/admin/collections/${id}/documents/${docId}`);
fetchData();
};
const deleteCollection = async () => {
if (!confirm('Delete this collection and all its documents?')) return;
await apiDelete(`/api/admin/collections/${id}`);
navigate('/collections');
};
const handleImportSubmit = async (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
setImporting(true);
setImportResult(null);
try {
const res = await fetch(`/admin/collections/${id}/import`, {
method: 'POST',
body: formData,
});
const text = await res.text();
if (res.ok) {
// Parse the success message from the HTML response
const match = text.match(/(\d+) inserted, (\d+) updated, (\d+) skipped/);
if (match) {
setImportResult({ success: true, message: `${match[1]} inserted, ${match[2]} updated, ${match[3]} skipped` });
} else {
setImportResult({ success: true, message: 'Import completed successfully' });
}
fetchData();
} else {
setImportResult({ success: false, message: 'Import failed. Check your file format.' });
}
} catch (err) {
setImportResult({ success: false, message: err.message });
} finally {
setImporting(false);
}
};
const addField = () => {
setEditFields([...editFields, { name: '', type: 'string', required: false }]);
};
const updateField = (index, key, value) => {
const newFields = [...editFields];
newFields[index] = { ...newFields[index], [key]: value };
setEditFields(newFields);
};
const removeField = (index) => {
setEditFields(editFields.filter((_, i) => i !== index));
};
const formatValue = (doc, field) => {
const val = doc.data?.[field.name];
if (field.type === 'bool') {
return val ? (
<span className="badge badge-success badge-xs">Yes</span>
) : (
<span className="badge badge-ghost badge-xs">No</span>
);
}
if (field.type === 'date') {
if (val) {
return new Date(val).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
return '';
}
if (field.type === 'json') {
return typeof val === 'object' ? JSON.stringify(val).substring(0, 50) + '...' : String(val || '');
}
return val || '';
};
if (loading || !collection) {
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b border-base-300 bg-base-200/50">
<div className="skeleton h-6 w-48" />
</div>
<div className="flex-1 flex justify-center items-center">
<span className="loading loading-spinner loading-lg" />
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-base-300 bg-base-200/50">
<div>
<h2 className="font-semibold">{collection.name}</h2>
<p className="text-xs text-base-content/50">Collection documents{collection.system && ' · System'}</p>
</div>
<div className="flex items-center gap-2">
{!collection.system && (
<button onClick={() => { setEditFields(fields); setShowEditModal(true); }} className="btn btn-ghost btn-sm gap-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Schema
</button>
)}
{/* Import/Export Dropdown */}
<div className="dropdown dropdown-end">
<label tabIndex={0} className="btn btn-ghost btn-sm gap-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Import/Export
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</label>
<ul tabIndex={0} className="dropdown-content menu bg-base-200 rounded-box z-50 w-56 p-2 shadow-xl border border-base-300">
<li className="menu-title text-xs text-base-content/50 px-2 pt-1 pb-2">Export</li>
<li><a href={`/admin/collections/${id}/export?format=json`} target="_blank" className="gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export as JSON
</a></li>
<li><a href={`/admin/collections/${id}/export?format=csv`} target="_blank" className="gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export as CSV
</a></li>
<li><a href={`/admin/collections/${id}/backup`} target="_blank" className="gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
Full Backup (with schema)
</a></li>
<li className="menu-title text-xs text-base-content/50 px-2 pt-3 pb-2">Import</li>
<li><a onClick={() => setShowImportModal(true)} className="gap-2 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Import Data
</a></li>
</ul>
</div>
<button onClick={openNewDoc} className="btn btn-ghost btn-sm gap-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
Add Document
</button>
{toggleChat && (
<button onClick={toggleChat} className="btn btn-soft btn-primary btn-sm gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
AI Assistant
</button>
)}
</div>
</div>
{collection.description && (
<div className="px-4 py-2 text-sm text-base-content/70 border-b border-base-300">
{collection.description}
</div>
)}
{/* Data Table */}
<div className="flex-1 overflow-auto">
{documents.length ? (
<table className="table table-pin-rows table-sm">
<thead>
<tr className="bg-base-200">
{fields.slice(0, 5).map((field) => (
<th key={field.name} className="font-medium text-base-content/70">
{field.name}
{field.required && <span className="text-error ml-1">*</span>}
</th>
))}
<th className="w-24"></th>
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id} className="hover:bg-base-200/50 cursor-pointer" onClick={() => openEditDoc(doc)}>
{fields.slice(0, 5).map((field) => (
<td key={field.name} className="max-w-xs truncate">
{formatValue(doc, field)}
</td>
))}
<td>
<div className="flex gap-1 justify-end">
<button
onClick={(e) => { e.stopPropagation(); deleteDocument(doc.id); }}
className="btn btn-ghost btn-xs text-error"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<div className="text-6xl mb-4 opacity-20">
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-lg font-medium mb-2">No documents yet</h3>
<p className="text-base-content/60 mb-4">Add your first document to this collection</p>
<button onClick={openNewDoc} className="btn btn-primary btn-sm">
Add Document
</button>
</div>
)}
</div>
{/* Edit Collection Modal */}
{showEditModal && (
<dialog className="modal modal-open">
<div className="modal-box w-11/12 max-w-3xl max-h-[90vh]">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={() => setShowEditModal(false)}>✕</button>
<h3 className="font-bold text-lg mb-4">Edit Collection</h3>
<form onSubmit={handleEditSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="floating-label">
<span>Name</span>
<input type="text" name="name" placeholder="Blog Posts" className="input input-bordered w-full" defaultValue={collection.name} required autoFocus />
</label>
</div>
<div className="form-control">
<label className="floating-label">
<span>Description</span>
<input type="text" name="description" placeholder="A collection of blog posts" className="input input-bordered w-full" defaultValue={collection.description || ''} />
</label>
</div>
</div>
<div className="form-control">
<label className="label">
<span className="label-text text-sm font-medium">Schema Fields</span>
</label>
<div className="space-y-2">
{editFields.map((field, index) => (
<div key={index} className="schema-field flex items-center gap-2 bg-base-200/50 rounded-lg p-2">
<input
type="text"
className="input input-bordered input-sm flex-1"
placeholder="Field name"
value={field.name}
onChange={(e) => updateField(index, 'name', e.target.value)}
required
/>
<select
className="select select-bordered select-sm w-32"
value={field.type}
onChange={(e) => updateField(index, 'type', e.target.value)}
>
{FIELD_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
<label className="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={field.required || false}
onChange={(e) => updateField(index, 'required', e.target.checked)}
/>
<span className="text-xs">Req</span>
</label>
<button type="button" onClick={() => removeField(index)} className="btn btn-ghost btn-xs btn-square text-error">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
<button type="button" onClick={addField} className="btn btn-ghost btn-sm btn-block mt-2 gap-1 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
Add Field
</button>
</div>
<div className="flex justify-end gap-2 pt-4">
<button type="button" onClick={deleteCollection} className="btn btn-ghost btn-sm text-error mr-auto">Delete</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setShowEditModal(false)}>Cancel</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? <span className="loading loading-spinner loading-sm" /> : 'Save Changes'}
</button>
</div>
</form>
</div>
<div className="modal-backdrop" onClick={() => setShowEditModal(false)} />
</dialog>
)}
{/* New/Edit Document Modal */}
{showDocModal && (
<dialog className="modal modal-open">
<div className="modal-box w-11/12 max-w-2xl max-h-[90vh]">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={() => { setShowDocModal(false); setEditDoc(null); }}>✕</button>
<h3 className="font-bold text-lg mb-4">{editDoc ? 'Edit Document' : 'New Document'}</h3>
<form onSubmit={handleDocSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
{fields.length ? fields.map((field) => (
<div key={field.name} className="form-control">
{field.type === 'bool' ? (
<label className="label cursor-pointer justify-start gap-4 py-0">
<input
type="checkbox"
name={field.name}
className="checkbox checkbox-primary checkbox-sm"
defaultChecked={editDoc?.data?.[field.name] || false}
/>
<span className="text-sm">{field.name} {field.required && <span className="text-error">*</span>}</span>
</label>
) : field.type === 'text' ? (
<label className="floating-label">
<span>{field.name} {field.required && <span className="text-error">*</span>}</span>
<textarea
name={field.name}
placeholder={`Enter ${field.name}`}
className="textarea textarea-bordered w-full bg-base-100"
rows="3"
defaultValue={editDoc?.data?.[field.name] || ''}
required={field.required}
/>
</label>
) : field.type === 'number' ? (
<label className="floating-label">
<span>{field.name} {field.required && <span className="text-error">*</span>}</span>
<input
type="number"
name={field.name}
placeholder="0"
step="any"
className="input input-bordered w-full"
defaultValue={editDoc?.data?.[field.name] || ''}
required={field.required}
/>
</label>
) : field.type === 'date' ? (
<label className="floating-label">
<span>{field.name} {field.required && <span className="text-error">*</span>}</span>
<input
type="date"
name={field.name}
className="input input-bordered w-full"
defaultValue={editDoc?.data?.[field.name]?.split('T')[0] || ''}
required={field.required}
/>
</label>
) : field.type === 'email' ? (
<label className="floating-label">
<span>{field.name} {field.required && <span className="text-error">*</span>}</span>
<input
type="email"
name={field.name}
placeholder="email@example.com"
className="input input-bordered w-full"
defaultValue={editDoc?.data?.[field.name] || ''}
required={field.required}
/>
</label>
) : field.type === 'url' ? (
<label className="floating-label">
<span>{field.name} {field.required && <span className="text-error">*</span>}</span>
<input
type="url"
name={field.name}
placeholder="https://example.com"
className="input input-bordered w-full"
defaultValue={editDoc?.data?.[field.name] || ''}
required={field.required}
/>
</label>
) : field.type === 'json' ? (
<label className="floating-label">
<span>{field.name} {field.required && <span className="text-error">*</span>}</span>
<textarea
name={field.name}
placeholder='{"key": "value"}'
className="textarea textarea-bordered w-full font-mono text-sm bg-base-100"
rows="4"
defaultValue={editDoc?.data?.[field.name] ? JSON.stringify(editDoc.data[field.name], null, 2) : ''}
required={field.required}
/>
</label>
) : (
<label className="floating-label">
<span>{field.name} {field.required && <span className="text-error">*</span>}</span>
<input
type="text"
name={field.name}
placeholder={`Enter ${field.name}`}
className="input input-bordered w-full"
defaultValue={editDoc?.data?.[field.name] || ''}
required={field.required}
/>
</label>
)}
</div>
)) : (
<div className="text-center py-8 text-base-content/50">
<p className="mb-2">No fields defined in schema.</p>
<button
type="button"
className="btn btn-ghost btn-sm gap-1"
onClick={() => { setShowDocModal(false); setEditDoc(null); setEditFields(fields); setShowEditModal(true); }}
>
Edit collection schema
</button>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setShowDocModal(false); setEditDoc(null); }}>Cancel</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving || !fields.length}>
{saving ? <span className="loading loading-spinner loading-sm" /> : (editDoc ? 'Save Changes' : 'Create Document')}
</button>
</div>
</form>
</div>
<div className="modal-backdrop" onClick={() => { setShowDocModal(false); setEditDoc(null); }} />
</dialog>
)}
{/* Import Modal */}
{showImportModal && (
<dialog className="modal modal-open">
<div className="modal-box w-11/12 max-w-lg">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={() => { setShowImportModal(false); setImportResult(null); }}>✕</button>
<h3 className="font-bold text-lg mb-4">Import Data</h3>
<form onSubmit={handleImportSubmit} encType="multipart/form-data" className="space-y-4">
<div className="form-control">
<label className="label">
<span className="label-text">Select File</span>
<span className="label-text-alt text-base-content/50">JSON or CSV</span>
</label>
<input
type="file"
name="file"
accept=".json,.csv"
className="file-input file-input-bordered w-full"
required
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Import Mode</span>
</label>
<select name="mode" className="select select-bordered w-full">
<option value="insert">Insert Only - Skip existing IDs</option>
<option value="upsert">Upsert - Update existing, insert new</option>
<option value="replace">Replace All - Delete all then import</option>
</select>
<label className="label">
<span className="label-text-alt text-base-content/50">How to handle documents that already exist</span>
</label>
</div>
<div className="form-control">
<label className="label cursor-pointer justify-start gap-4">
<input type="checkbox" name="skip_invalid" className="checkbox checkbox-sm" defaultChecked />
<div>
<span className="label-text">Skip invalid records</span>
<p className="text-xs text-base-content/50">Continue import even if some records fail validation</p>
</div>
</label>
</div>
{importResult && (
<div className={`alert ${importResult.success ? 'alert-success' : 'alert-error'}`}>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={importResult.success ? "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" : "M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"} />
</svg>
<span>{importResult.message}</span>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setShowImportModal(false); setImportResult(null); }}>Cancel</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={importing}>
{importing ? <span className="loading loading-spinner loading-sm" /> : 'Import'}
</button>
</div>
</form>
</div>
<div className="modal-backdrop" onClick={() => { setShowImportModal(false); setImportResult(null); }} />
</dialog>
)}
</div>
);
}