feat: Implement Frontend Engine (Router + BlockRenderer)

This commit is contained in:
cawcenter
2025-12-13 21:15:11 -05:00
parent 93734c8966
commit f7dd7b41b5
10 changed files with 201 additions and 97 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,39 @@
import React from 'react';
import Hero from './blocks/Hero';
import Content from './blocks/Content';
import Features from './blocks/Features';
interface Block {
id: string;
block_type: string;
block_config: any;
}
interface BlockRendererProps {
blocks: Block[];
}
export default function BlockRenderer({ blocks }: BlockRendererProps) {
if (!blocks || !Array.isArray(blocks)) return null;
return (
<div className="flex flex-col">
{blocks.map(block => {
switch (block.block_type) {
case 'hero':
return <Hero key={block.id} {...block.block_config} />;
case 'content':
return <Content key={block.id} {...block.block_config} />;
case 'features':
return <Features key={block.id} {...block.block_config} />;
case 'cta':
// reuse Hero styled as CTA or simple banner
return <Hero key={block.id} {...block.block_config} bg="dark" />;
default:
console.warn(`Unknown block type: ${block.block_type}`);
return null;
}
})}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
interface ContentProps {
content: string;
}
export default function Content({ content }: ContentProps) {
return (
<section className="py-12 px-8">
<div className="prose prose-lg dark:prose-invert mx-auto" dangerouslySetInnerHTML={{ __html: content }} />
</section>
);
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CheckCircle2 } from 'lucide-react';
interface FeatureItem {
title: string;
desc: string;
icon?: string;
}
interface FeaturesProps {
items: FeatureItem[];
layout?: 'grid' | 'list';
}
export default function Features({ items, layout = 'grid' }: FeaturesProps) {
return (
<section className="py-16 px-8 bg-zinc-50 dark:bg-zinc-900/50">
<div className="max-w-6xl mx-auto">
<div className={`grid gap-8 ${layout === 'list' ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-3'}`}>
{items?.map((item, i) => (
<Card key={i} className="border-0 shadow-lg bg-white dark:bg-zinc-900 dark:border-zinc-800">
<CardHeader className="pb-2">
<div className="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4 text-blue-600 dark:text-blue-400">
<CheckCircle2 className="h-6 w-6" />
</div>
<CardTitle className="text-xl">{item.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-zinc-600 dark:text-zinc-400 leading-relaxed">
{item.desc}
</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { Button } from '@/components/ui/button';
interface HeroProps {
title: string;
subtitle?: string;
bg?: string;
ctaLabel?: string;
ctaUrl?: string;
}
export default function Hero({ title, subtitle, bg, ctaLabel, ctaUrl }: HeroProps) {
const bgClass = bg === 'dark' ? 'bg-zinc-900 text-white' :
bg === 'image' ? 'bg-zinc-800 text-white' : // Placeholder for image logic
'bg-white text-zinc-900';
return (
<section className={`py-20 px-8 text-center ${bgClass}`}>
<div className="max-w-4xl mx-auto space-y-6">
<h1 className="text-5xl md:text-6xl font-extrabold tracking-tight">
{title}
</h1>
{subtitle && (
<p className="text-xl md:text-2xl opacity-80 max-w-2xl mx-auto">
{subtitle}
</p>
)}
{(ctaLabel && ctaUrl) && (
<div className="pt-4">
<Button asChild size="lg" className="text-lg px-8 py-6 rounded-full">
<a href={ctaUrl}>{ctaLabel}</a>
</Button>
</div>
)}
</div>
</section>
);
}

View File

@@ -29,23 +29,14 @@ export async function fetchPageByPermalink(
'id',
'title',
'permalink',
'site',
'status',
'seo_title',
'seo_description',
'seo_image',
{
blocks: {
id: true,
sort: true,
hide_block: true,
collection: true,
item: true
}
}
],
deep: {
blocks: { _sort: ['sort'], _filter: { hide_block: { _neq: true } } }
}
'blocks', // Fetch as simple JSON field
'schema_json'
]
})
);
@@ -106,7 +97,7 @@ export async function fetchPosts(
const offset = (page - 1) * limit;
const filter: Record<string, any> = {
site: { _eq: siteId },
site: { _eq: siteId }, // siteId is UUID string
status: { _eq: 'published' }
};
@@ -196,7 +187,7 @@ export async function fetchGeneratedArticles(
const [articles, countResult] = await Promise.all([
directus.request(
readItems('generated_articles', {
filter: { site_id: { _eq: Number(siteId) } },
filter: { site_id: { _eq: siteId } }, // UUID string
limit,
offset,
sort: ['-date_created'],
@@ -206,7 +197,7 @@ export async function fetchGeneratedArticles(
directus.request(
aggregate('generated_articles', {
aggregate: { count: '*' },
query: { filter: { site_id: { _eq: Number(siteId) } } }
query: { filter: { site_id: { _eq: siteId } } } // UUID string
})
)
]);

View File

@@ -0,0 +1,16 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Page Not Found">
<div class="h-screen flex flex-col items-center justify-center text-center p-8 bg-zinc-50 dark:bg-zinc-950">
<h1 class="text-9xl font-extrabold text-zinc-900 dark:text-white mb-4">404</h1>
<h2 class="text-2xl font-semibold text-zinc-700 dark:text-zinc-300 mb-8">Page Not Found</h2>
<p class="text-zinc-500 max-w-md mx-auto mb-8">
The page you are looking for does not exist or has been moved.
</p>
<a href="/" class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors">
Go Home
</a>
</div>
</BaseLayout>

View File

@@ -1,55 +1,55 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { fetchPageByPermalink, fetchSiteGlobals, fetchNavigation, fetchGeneratedArticleBySlug } from '../lib/directus/fetchers';
import BlockHero from '../components/blocks/BlockHero.astro';
import BlockRichText from '../components/blocks/BlockRichText.astro';
import BlockColumns from '../components/blocks/BlockColumns.astro';
import BlockMedia from '../components/blocks/BlockMedia.astro';
import BlockSteps from '../components/blocks/BlockSteps.astro';
import BlockQuote from '../components/blocks/BlockQuote.astro';
import BlockGallery from '../components/blocks/BlockGallery.astro';
import BlockFAQ from '../components/blocks/BlockFAQ.astro';
import BlockPosts from '../components/blocks/BlockPosts.astro';
import BlockForm from '../components/blocks/BlockForm.astro';
import BlockRenderer from '@/components/engine/BlockRenderer';
const siteId = Astro.locals.siteId;
const slug = Astro.params.slug || '';
const permalink = '/' + slug;
// Safety check for siteId
if (!siteId) {
// If we can't determine the site (domain not found), 404 or show generic
// Actually, middleware might have failed or domain is unknown.
// For now, render 404.
return Astro.redirect('/404');
}
// Fetch data
const [globals, navigation, page, generatedArticle] = await Promise.all([
siteId ? fetchSiteGlobals(siteId) : null,
siteId ? fetchNavigation(siteId) : [],
siteId ? fetchPageByPermalink(permalink, siteId) : null,
siteId ? fetchGeneratedArticleBySlug(slug, siteId) : null
fetchSiteGlobals(siteId),
fetchNavigation(siteId),
fetchPageByPermalink(permalink, siteId),
fetchGeneratedArticleBySlug(slug, siteId)
]);
if (!page && !generatedArticle) {
// Try home page if slug matches site root?
// params.slug is '' for root? No, [...slug] matches root if configured?
// Astro [...slug] matches undefined for root if defined as such?
// Let's assume permalink handles root.
return Astro.redirect('/404');
}
// Block component map
const blockComponents: Record<string, any> = {
block_hero: BlockHero,
block_richtext: BlockRichText,
block_columns: BlockColumns,
block_media: BlockMedia,
block_steps: BlockSteps,
block_quote: BlockQuote,
block_gallery: BlockGallery,
block_faq: BlockFAQ,
block_posts: BlockPosts,
block_form: BlockForm,
};
const activeEntity = page || generatedArticle;
const isGenerated = !!generatedArticle && !page;
// Determine blocks
const blocks = page?.blocks || [];
// If legacy content field exists and blocks are empty, maybe create a fake content block?
if (page && (!blocks || blocks.length === 0) && page.content) {
blocks.push({
id: 'legacy-content',
block_type: 'content',
block_config: { content: page.content }
});
}
---
<BaseLayout
title={activeEntity.seo_title || activeEntity.meta_title || activeEntity.title || activeEntity.headline}
description={activeEntity.seo_description || activeEntity.meta_description}
image={activeEntity.seo_image || (activeEntity.featured_image_filename ? `/assets/content/${activeEntity.featured_image_filename}` : undefined)}
title={activeEntity.seo_title || activeEntity.title || activeEntity.headline}
description={activeEntity.seo_description}
image={activeEntity.seo_image}
globals={globals}
navigation={navigation}
schemaJson={activeEntity.schema_json}
@@ -60,12 +60,6 @@ const isGenerated = !!generatedArticle && !page;
<div set:html={activeEntity.full_html_body} />
</article>
) : (
page.blocks?.map((block) => {
const Component = blockComponents[block.collection];
if (Component) {
return <Component {...block.item} />;
}
return null;
})
<BlockRenderer blocks={blocks} />
)}
</BaseLayout>

View File

@@ -6,6 +6,7 @@
import { getDirectusClient, readItem } from '@/lib/directus/client';
import type { Page } from '@/types/schema';
import BlockRenderer from '@/components/engine/BlockRenderer';
const { pageId } = Astro.params;
@@ -25,6 +26,8 @@ try {
console.error('Preview error:', err);
}
import '@/styles/globals.css';
if (!page) {
return Astro.redirect('/admin/pages');
}
@@ -103,7 +106,6 @@ if (!page) {
</div>
</div>
<!-- Page Content -->
<!-- Page Content -->
<div class="preview-content">
{error ? (
@@ -112,44 +114,15 @@ if (!page) {
<p>{error}</p>
</div>
) : (
<div class="page-blocks" style="display: flex; flex-direction: column; gap: 40px;">
{/* Render Blocks */}
{page.blocks && Array.isArray(page.blocks) ? page.blocks.map((block: any) => {
if (block.block_type === 'hero') {
return (
<div style="background: #111; color: white; padding: 60px 20px; text-align: center; border-radius: 12px;">
<h1 style="font-size: 3rem; margin-bottom: 20px;">{block.block_config?.title}</h1>
<p style="font-size: 1.5rem; color: #ccc;">{block.block_config?.subtitle}</p>
</div>
);
}
if (block.block_type === 'content') {
return (
<div style="max-width: 800px; margin: 0 auto; line-height: 1.8; color: #333; font-size: 1.1rem;">
<Fragment set:html={block.block_config?.content || ''} />
</div>
);
}
if (block.block_type === 'features') {
return (
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 30px; padding: 40px 0;">
{block.block_config?.items?.map((item: any) => (
<div style="background: #f9f9f9; padding: 30px; border-radius: 8px; border: 1px solid #eee;">
<h3 style="margin-top: 0; font-size: 1.2rem; font-weight: bold;">{item.title}</h3>
<p style="color: #666; margin-bottom: 0;">{item.desc}</p>
</div>
))}
</div>
);
}
return null;
}) : (
// Fallback for legacy content field
<div style="line-height: 1.8; color: #333;">
<Fragment set:html={page.content || '<p>No content.</p>'} />
</div>
<>
<BlockRenderer blocks={page.blocks} />
{(!page.blocks || page.blocks.length === 0) && page.content && (
// Fallback for content
<div style="line-height: 1.8; color: #333;">
<Fragment set:html={page.content} />
</div>
)}
</div>
</>
)}
</div>
</body>

View File

@@ -23,16 +23,16 @@ export interface Page {
seo_description?: string;
seo_image?: string;
blocks?: PageBlock[];
content?: string; // legacy fallback
schema_json?: Record<string, any>;
date_created?: string;
date_updated?: string;
}
export interface PageBlock {
id: string;
sort: number;
hide_block: boolean;
collection: string;
item: any;
block_type: 'hero' | 'content' | 'features' | 'cta';
block_config: Record<string, any>;
}
export interface Post {