Partials.jsx
import * as React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { apiGet, apiPost, apiPatch, apiDelete } from '../hooks/useAPI.js';
import { useChat } from '../layouts/AdminLayout.jsx';
export function Partials() {
const { toggleChat } = useChat() || {};
const [partials, setPartials] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editPartial, setEditPartial] = useState(null);
const [saving, setSaving] = useState(false);
const fetchPartials = useCallback(() => {
setLoading(true);
apiGet('/api/admin/partials')
.then((data) => setPartials(data?.items || data || []))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
fetchPartials();
}, [fetchPartials]);
const handleSubmit = async (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
setSaving(true);
try {
if (editPartial) {
await apiPatch(`/api/admin/partials/${editPartial.id}`, {
name: formData.get('name'),
description: formData.get('description'),
html: formData.get('html'),
published: formData.get('published') === 'on',
});
} else {
await apiPost('/api/admin/partials', {
name: formData.get('name'),
slug: formData.get('slug') || undefined,
description: formData.get('description'),
html: formData.get('html'),
published: formData.get('published') === 'on',
});
}
setShowModal(false);
setEditPartial(null);
form.reset();
fetchPartials();
} finally {
setSaving(false);
}
};
const openEdit = (partial) => {
setEditPartial(partial);
setShowModal(true);
};
const openNew = () => {
setEditPartial(null);
setShowModal(true);
};
const deletePartial = async (id) => {
if (!confirm('Delete this partial?')) return;
await apiDelete(`/api/admin/partials/${id}`);
fetchPartials();
};
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">Partials</h2>
<p className="text-xs text-base-content/50">Reusable components</p>
</div>
<div className="flex items-center gap-2">
<Link to="/pages" 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="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>
Pages
</Link>
<button onClick={openNew} 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>
Partial
</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>
{/* Content */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex justify-center py-12">
<span className="loading loading-spinner loading-lg" />
</div>
) : partials.length ? (
<table className="table table-pin-rows table-sm">
<thead>
<tr className="bg-base-200">
<th className="font-medium text-base-content/70">Name</th>
<th className="font-medium text-base-content/70">Description</th>
<th className="font-medium text-base-content/70">Usage</th>
<th className="font-medium text-base-content/70">Status</th>
<th className="w-24"></th>
</tr>
</thead>
<tbody>
{partials.map((p) => (
<tr key={p.id} className="hover:bg-base-200/50 cursor-pointer" onClick={() => openEdit(p)}>
<td className="font-medium">{p.name}</td>
<td className="max-w-xs truncate text-base-content/70">{p.description}</td>
<td className="font-mono text-xs text-base-content/50">{`{{partial "${p.id}"}}`}</td>
<td>
{p.published ? (
<span className="badge badge-success badge-xs">Published</span>
) : (
<span className="badge badge-ghost badge-xs">Draft</span>
)}
</td>
<td>
<div className="flex gap-1 justify-end">
<button
onClick={(e) => { e.stopPropagation(); openEdit(p); }}
className="btn btn-ghost btn-xs"
>
Edit
</button>
<button
onClick={(e) => { e.stopPropagation(); deletePartial(p.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 px-6">
<div className="w-16 h-16 rounded-2xl bg-base-200 flex items-center justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-base-content/30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-1">No partials yet</h3>
<p className="text-base-content/50 text-sm mb-4">Create reusable HTML components for your pages.</p>
<button onClick={openNew} className="btn btn-primary btn-sm">Create First Partial</button>
</div>
)}
</div>
{/* New/Edit Partial Modal */}
{showModal && (
<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={() => { setShowModal(false); setEditPartial(null); }}>✕</button>
<h3 className="font-bold text-lg mb-4">{editPartial ? 'Edit Partial' : 'New Partial'}</h3>
<form onSubmit={handleSubmit} 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 className="text-error">*</span></span>
<input type="text" name="name" placeholder="Header" className="input input-bordered w-full" required autoFocus maxLength="100" defaultValue={editPartial?.name || ''} />
</label>
</div>
{!editPartial && (
<div className="form-control">
<label className="floating-label">
<span>Slug (ID)</span>
<input type="text" name="slug" placeholder="header" className="input input-bordered w-full" maxLength="50" pattern="[a-z0-9-]+" />
</label>
<label className="label">
<span className="label-text-alt text-base-content/50">URL-friendly ID (auto-generated if empty)</span>
</label>
</div>
)}
{editPartial && (
<div className="form-control">
<label className="floating-label">
<span>Slug (ID)</span>
<input type="text" className="input input-bordered w-full bg-base-200" value={editPartial.id} readOnly />
</label>
<label className="label">
<span className="label-text-alt text-base-content/50">ID cannot be changed</span>
</label>
</div>
)}
</div>
<div className="form-control">
<label className="floating-label">
<span>Description</span>
<input type="text" name="description" placeholder="Site header with navigation" className="input input-bordered w-full" maxLength="200" defaultValue={editPartial?.description || ''} />
</label>
<label className="label">
<span className="label-text-alt text-base-content/50">Helps AI understand what this partial is for</span>
</label>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">HTML Content <span className="text-error">*</span></span>
</label>
<textarea
name="html"
placeholder="<header>...</header>"
className="textarea textarea-bordered w-full font-mono text-sm bg-base-100"
rows="12"
required
defaultValue={editPartial?.html || ''}
/>
</div>
<div className="form-control">
<label className="label cursor-pointer justify-start gap-4">
<input type="checkbox" name="published" className="checkbox checkbox-primary checkbox-sm" defaultChecked={editPartial?.published ?? true} />
<span className="label-text">Published (available for use in pages)</span>
</label>
</div>
<div className="flex justify-end gap-2 pt-4">
{editPartial && (
<button
type="button"
className="btn btn-ghost btn-sm text-error mr-auto"
onClick={() => { deletePartial(editPartial.id); setShowModal(false); setEditPartial(null); }}
>
Delete
</button>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setShowModal(false); setEditPartial(null); }}>Cancel</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? <span className="loading loading-spinner loading-sm" /> : (editPartial ? 'Save Changes' : 'Create Partial')}
</button>
</div>
</form>
</div>
<div className="modal-backdrop" onClick={() => { setShowModal(false); setEditPartial(null); }} />
</dialog>
)}
</div>
);
}