Add page/post management UI: editors, admin pages, and preview routes

This commit is contained in:
cawcenter
2025-12-16 12:15:45 -05:00
parent 35632f87b8
commit d33676311f
6 changed files with 620 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
// Simple Page Editor Component
import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface PageEditorProps {
siteId: string;
pageId?: string;
initialData?: {
name: string;
route: string;
html_content: string;
meta_title?: string;
meta_description?: string;
status: string;
};
onSave?: (page: any) => void;
onCancel?: () => void;
}
export default function PageEditor({ siteId, pageId, initialData, onSave, onCancel }: PageEditorProps) {
const [formData, setFormData] = useState(initialData || {
name: '',
route: '/',
html_content: '',
meta_title: '',
meta_description: '',
status: 'draft'
});
const queryClient = useQueryClient();
const saveMutation = useMutation({
mutationFn: async (data: typeof formData) => {
const url = pageId
? `/api/shim/pages/${pageId}`
: `/api/shim/pages/create`;
const method = pageId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${import.meta.env.PUBLIC_GOD_MODE_TOKEN || 'local-dev-token'}`
},
body: JSON.stringify({ ...data, site_id: siteId })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to save page');
}
return response.json();
},
onSuccess: (page) => {
queryClient.invalidateQueries({ queryKey: ['pages'] });
if (onSave) onSave(page);
}
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveMutation.mutate(formData);
};
const handlePreview = () => {
if (pageId) {
window.open(`/preview/page/${pageId}`, '_blank');
} else {
alert('Please save the page first to preview it');
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Page Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-blue-500"
required
/>
</div>
{/* Route */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Route (must start with /)
</label>
<input
type="text"
value={formData.route}
onChange={(e) => setFormData({ ...formData, route: e.target.value })}
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-blue-500"
pattern="^\/[a-z0-9-\/]*$"
placeholder="/about"
required
/>
<p className="mt-1 text-xs text-slate-500">Example: /about, /contact, /services/web</p>
</div>
{/* HTML Content */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
HTML Content
</label>
<textarea
value={formData.html_content}
onChange={(e) => setFormData({ ...formData, html_content: e.target.value })}
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-blue-500 font-mono text-sm"
rows={15}
placeholder="<h1>Welcome</h1><p>Your content here...</p>"
/>
</div>
{/* SEO Section */}
<div className="border-t border-slate-700 pt-6">
<h3 className="text-lg font-semibold text-white mb-4">SEO (Optional)</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Meta Title (max 70 chars)
</label>
<input
type="text"
value={formData.meta_title}
onChange={(e) => setFormData({ ...formData, meta_title: e.target.value })}
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-blue-500"
maxLength={70}
/>
<p className="mt-1 text-xs text-slate-500">{formData.meta_title?.length || 0}/70</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Meta Description (max 160 chars)
</label>
<textarea
value={formData.meta_description}
onChange={(e) => setFormData({ ...formData, meta_description: e.target.value })}
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-blue-500"
rows={3}
maxLength={160}
/>
<p className="mt-1 text-xs text-slate-500">{formData.meta_description?.length || 0}/160</p>
</div>
</div>
</div>
{/* Status */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="archived">Archived</option>
</select>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={saveMutation.isPending}
className="px-6 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-600 text-white rounded-lg font-medium transition"
>
{saveMutation.isPending ? 'Saving...' : (pageId ? 'Update Page' : 'Create Page')}
</button>
{pageId && (
<button
type="button"
onClick={handlePreview}
className="px-6 py-2 bg-slate-600 hover:bg-slate-500 text-white rounded-lg font-medium transition"
>
Preview
</button>
)}
{onCancel && (
<button
type="button"
onClick={onCancel}
className="px-6 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-medium transition"
>
Cancel
</button>
)}
</div>
{/* Error Display */}
{saveMutation.isError && (
<div className="p-4 bg-red-900/20 border border-red-700 rounded-lg text-red-400">
Error: {saveMutation.error.message}
</div>
)}
{/* Success Display */}
{saveMutation.isSuccess && (
<div className="p-4 bg-green-900/20 border border-green-700 rounded-lg text-green-400">
Page saved successfully!
</div>
)}
</form>
);
}

View File

@@ -0,0 +1,220 @@
// Simple Post Editor Component
import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface PostEditorProps {
siteId: string;
postId?: string;
initialData?: {
name: string;
slug: string;
html_content: string;
meta_title?: string;
meta_description?: string;
status: string;
};
onSave?: (post: any) => void;
onCancel?: () => void;
}
export default function PostEditor({ siteId, postId, initialData, onSave, onCancel }: PostEditorProps) {
const [formData, setFormData] = useState(initialData || {
name: '',
slug: '/',
html_content: '',
meta_title: '',
meta_description: '',
status: 'draft'
});
const queryClient = useQueryClient();
const saveMutation = useMutation({
mutationFn: async (data: typeof formData) => {
const url = postId
? `/api/shim/posts/${postId}`
: `/api/shim/posts/create`;
const method = postId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${import.meta.env.PUBLIC_GOD_MODE_TOKEN || 'local-dev-token'}`
},
body: JSON.stringify({ ...data, site_id: siteId })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to save post');
}
return response.json();
},
onSuccess: (post) => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
if (onSave) onSave(post);
}
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveMutation.mutate(formData);
};
const handlePreview = () => {
if (postId) {
window.open(`/preview/post/${postId}`, '_blank');
} else {
alert('Please save the post first to preview it');
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Post Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-blue-500"
required
/>
</div>
{/* Slug */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Slug (must start with /)
</label>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-blue-500"
pattern="^\/[a-z0-9-\/]*$"
placeholder="/about"
required
/>
<p className="mt-1 text-xs text-slate-500">Example: /about, /contact, /services/web</p>
</div>
{/* Content (HTML or Markdown) */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Content (HTML or Markdown)
</label>
<textarea
value={formData.html_content}
onChange={(e) => setFormData({ ...formData, html_content: e.target.value })}
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-blue-500 font-mono text-sm"
rows={15}
placeholder="<h1>Welcome</h1><p>Your content here...</p>"
/>
</div>
{/* SEO Section */}
<div className="border-t border-slate-700 pt-6">
<h3 className="text-lg font-semibold text-white mb-4">SEO (Optional)</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Meta Title (max 70 chars)
</label>
<input
type="text"
value={formData.meta_title}
onChange={(e) => setFormData({ ...formData, meta_title: e.target.value })}
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-blue-500"
maxLength={70}
/>
<p className="mt-1 text-xs text-slate-500">{formData.meta_title?.length || 0}/70</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Meta Description (max 160 chars)
</label>
<textarea
value={formData.meta_description}
onChange={(e) => setFormData({ ...formData, meta_description: e.target.value })}
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-blue-500"
rows={3}
maxLength={160}
/>
<p className="mt-1 text-xs text-slate-500">{formData.meta_description?.length || 0}/160</p>
</div>
</div>
</div>
{/* Status */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="archived">Archived</option>
</select>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={saveMutation.isPending}
className="px-6 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-600 text-white rounded-lg font-medium transition"
>
{saveMutation.isPending ? 'Saving...' : (postId ? 'Update Post' : 'Create Post')}
</button>
{postId && (
<button
type="button"
onClick={handlePreview}
className="px-6 py-2 bg-slate-600 hover:bg-slate-500 text-white rounded-lg font-medium transition"
>
Preview
</button>
)}
{onCancel && (
<button
type="button"
onClick={onCancel}
className="px-6 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-medium transition"
>
Cancel
</button>
)}
</div>
{/* Error Display */}
{saveMutation.isError && (
<div className="p-4 bg-red-900/20 border border-red-700 rounded-lg text-red-400">
Error: {saveMutation.error.message}
</div>
)}
{/* Success Display */}
{saveMutation.isSuccess && (
<div className="p-4 bg-green-900/20 border border-green-700 rounded-lg text-green-400">
Post saved successfully!
</div>
)}
</form>
);
}

View File

@@ -0,0 +1,39 @@
---
// Admin: Create New Page
import AdminLayout from '@/layouts/AdminLayout.astro';
import PageEditor from '@/components/shim/PageEditor';
// Get first site ID (for demo, you'd select from a dropdown)
const { rows: sites } = await (await import('@/lib/db')).pool.query<{id: string}>('SELECT id FROM sites LIMIT 1');
const siteId = sites[0]?.id;
if (!siteId) {
return Astro.redirect('/admin/sites');
}
---
<AdminLayout title="Create New Page">
<div class="space-y-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-white">Create New Page</h1>
<p class="text-slate-400 mt-1">Add a new static page to your site</p>
</div>
<a href="/admin/pages" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg">
← Back to Pages
</a>
</div>
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6">
<PageEditor
client:load
siteId={siteId}
onSave={(page) => {
window.location.href = `/admin/pages/${page.id}/edit`;
}}
/>
</div>
</div>
</AdminLayout>

View File

@@ -0,0 +1,39 @@
---
// Admin: Create New Post
import AdminLayout from '@/layouts/AdminLayout.astro';
import PostEditor from '@/components/shim/PostEditor';
// Get first site ID
const { rows: sites } = await (await import('@/lib/db')).pool.query<{id: string}>('SELECT id FROM sites LIMIT 1');
const siteId = sites[0]?.id;
if (!siteId) {
return Astro.redirect('/admin/sites');
}
---
<AdminLayout title="Create New Post">
<div class="space-y-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-white">Create New Post</h1>
<p class="text-slate-400 mt-1">Add a new blog post</p>
</div>
<a href="/admin/posts" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg">
← Back to Posts
</a>
</div>
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6">
<PostEditor
client:load
siteId={siteId}
onSave={(post) => {
window.location.href = `/admin/posts/${post.id}/edit`;
}}
/>
</div>
</div>
</AdminLayout>

View File

@@ -0,0 +1,49 @@
---
// Preview Page by ID
import { getPageById } from '@/lib/shim/pages';
const { id } = Astro.params;
const page = await getPageById(id!);
if (!page) {
return Astro.redirect('/404');
}
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{page.meta_title || page.name}</title>
<meta name="description" content={page.meta_description || ''} />
<meta name="robots" content="noindex, nofollow" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.preview-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 20px;
text-align: center;
font-weight: 600;
font-size: 14px;
z-index: 9999;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.content { margin-top: 60px; }
</style>
</head>
<body>
<div class="preview-banner">
🔍 PREVIEW MODE - Page: {page.name} - Status: {page.status}
</div>
<div class="content">
<Fragment set:html={page.html_content || '<p>No content yet</p>'} />
</div>
</body>
</html>

View File

@@ -0,0 +1,53 @@
---
// Preview Post by ID
import { getPostById } from '@/lib/shim/posts';
const { id } = Astro.params;
const post = await getPostById(id!);
if (!post) {
return Astro.redirect('/404');
}
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{post.meta_title || post.title}</title>
<meta name="description" content={post.meta_description || post.excerpt || ''} />
<meta name="robots" content="noindex, nofollow" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; }
.preview-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 20px;
text-align: center;
font-weight: 600;
font-size: 14px;
z-index: 9999;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.content { max-width: 800px; margin: 80px auto 40px; padding: 0 20px; }
.content h1 { font-size: 2.5rem; margin-bottom: 1rem; color: #1a1a1a; }
.content p { margin-bottom: 1rem; color: #4a4a4a; }
</style>
</head>
<body>
<div class="preview-banner">
🔍 PREVIEW MODE - Post: {post.title} - Status: {post.status}
</div>
<article class="content">
<h1>{post.title}</h1>
{post.excerpt && <p class="excerpt" style="font-size: 1.2rem; color: #666;">{post.excerpt}</p>}
<div set:html={post.content || '<p>No content yet</p>'} />
</article>
</body>
</html>