feat: Implement Frontend Engine (Router + BlockRenderer)
This commit is contained in:
File diff suppressed because one or more lines are too long
39
frontend/src/components/engine/BlockRenderer.tsx
Normal file
39
frontend/src/components/engine/BlockRenderer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
frontend/src/components/engine/blocks/Content.tsx
Normal file
13
frontend/src/components/engine/blocks/Content.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/components/engine/blocks/Features.tsx
Normal file
40
frontend/src/components/engine/blocks/Features.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/components/engine/blocks/Hero.tsx
Normal file
38
frontend/src/components/engine/blocks/Hero.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,23 +29,14 @@ export async function fetchPageByPermalink(
|
|||||||
'id',
|
'id',
|
||||||
'title',
|
'title',
|
||||||
'permalink',
|
'permalink',
|
||||||
|
'site',
|
||||||
'status',
|
'status',
|
||||||
'seo_title',
|
'seo_title',
|
||||||
'seo_description',
|
'seo_description',
|
||||||
'seo_image',
|
'seo_image',
|
||||||
{
|
'blocks', // Fetch as simple JSON field
|
||||||
blocks: {
|
'schema_json'
|
||||||
id: true,
|
]
|
||||||
sort: true,
|
|
||||||
hide_block: true,
|
|
||||||
collection: true,
|
|
||||||
item: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
deep: {
|
|
||||||
blocks: { _sort: ['sort'], _filter: { hide_block: { _neq: true } } }
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -106,7 +97,7 @@ export async function fetchPosts(
|
|||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const filter: Record<string, any> = {
|
const filter: Record<string, any> = {
|
||||||
site: { _eq: siteId },
|
site: { _eq: siteId }, // siteId is UUID string
|
||||||
status: { _eq: 'published' }
|
status: { _eq: 'published' }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,7 +187,7 @@ export async function fetchGeneratedArticles(
|
|||||||
const [articles, countResult] = await Promise.all([
|
const [articles, countResult] = await Promise.all([
|
||||||
directus.request(
|
directus.request(
|
||||||
readItems('generated_articles', {
|
readItems('generated_articles', {
|
||||||
filter: { site_id: { _eq: Number(siteId) } },
|
filter: { site_id: { _eq: siteId } }, // UUID string
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
sort: ['-date_created'],
|
sort: ['-date_created'],
|
||||||
@@ -206,7 +197,7 @@ export async function fetchGeneratedArticles(
|
|||||||
directus.request(
|
directus.request(
|
||||||
aggregate('generated_articles', {
|
aggregate('generated_articles', {
|
||||||
aggregate: { count: '*' },
|
aggregate: { count: '*' },
|
||||||
query: { filter: { site_id: { _eq: Number(siteId) } } }
|
query: { filter: { site_id: { _eq: siteId } } } // UUID string
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
]);
|
]);
|
||||||
|
|||||||
16
frontend/src/pages/404.astro
Normal file
16
frontend/src/pages/404.astro
Normal 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>
|
||||||
@@ -1,55 +1,55 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import { fetchPageByPermalink, fetchSiteGlobals, fetchNavigation, fetchGeneratedArticleBySlug } from '../lib/directus/fetchers';
|
import { fetchPageByPermalink, fetchSiteGlobals, fetchNavigation, fetchGeneratedArticleBySlug } from '../lib/directus/fetchers';
|
||||||
import BlockHero from '../components/blocks/BlockHero.astro';
|
import BlockRenderer from '@/components/engine/BlockRenderer';
|
||||||
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';
|
|
||||||
|
|
||||||
const siteId = Astro.locals.siteId;
|
const siteId = Astro.locals.siteId;
|
||||||
const slug = Astro.params.slug || '';
|
const slug = Astro.params.slug || '';
|
||||||
const permalink = '/' + 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
|
// Fetch data
|
||||||
const [globals, navigation, page, generatedArticle] = await Promise.all([
|
const [globals, navigation, page, generatedArticle] = await Promise.all([
|
||||||
siteId ? fetchSiteGlobals(siteId) : null,
|
fetchSiteGlobals(siteId),
|
||||||
siteId ? fetchNavigation(siteId) : [],
|
fetchNavigation(siteId),
|
||||||
siteId ? fetchPageByPermalink(permalink, siteId) : null,
|
fetchPageByPermalink(permalink, siteId),
|
||||||
siteId ? fetchGeneratedArticleBySlug(slug, siteId) : null
|
fetchGeneratedArticleBySlug(slug, siteId)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!page && !generatedArticle) {
|
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');
|
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 activeEntity = page || generatedArticle;
|
||||||
const isGenerated = !!generatedArticle && !page;
|
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
|
<BaseLayout
|
||||||
title={activeEntity.seo_title || activeEntity.meta_title || activeEntity.title || activeEntity.headline}
|
title={activeEntity.seo_title || activeEntity.title || activeEntity.headline}
|
||||||
description={activeEntity.seo_description || activeEntity.meta_description}
|
description={activeEntity.seo_description}
|
||||||
image={activeEntity.seo_image || (activeEntity.featured_image_filename ? `/assets/content/${activeEntity.featured_image_filename}` : undefined)}
|
image={activeEntity.seo_image}
|
||||||
globals={globals}
|
globals={globals}
|
||||||
navigation={navigation}
|
navigation={navigation}
|
||||||
schemaJson={activeEntity.schema_json}
|
schemaJson={activeEntity.schema_json}
|
||||||
@@ -60,12 +60,6 @@ const isGenerated = !!generatedArticle && !page;
|
|||||||
<div set:html={activeEntity.full_html_body} />
|
<div set:html={activeEntity.full_html_body} />
|
||||||
</article>
|
</article>
|
||||||
) : (
|
) : (
|
||||||
page.blocks?.map((block) => {
|
<BlockRenderer blocks={blocks} />
|
||||||
const Component = blockComponents[block.collection];
|
|
||||||
if (Component) {
|
|
||||||
return <Component {...block.item} />;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { getDirectusClient, readItem } from '@/lib/directus/client';
|
import { getDirectusClient, readItem } from '@/lib/directus/client';
|
||||||
import type { Page } from '@/types/schema';
|
import type { Page } from '@/types/schema';
|
||||||
|
import BlockRenderer from '@/components/engine/BlockRenderer';
|
||||||
|
|
||||||
const { pageId } = Astro.params;
|
const { pageId } = Astro.params;
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ try {
|
|||||||
console.error('Preview error:', err);
|
console.error('Preview error:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import '@/styles/globals.css';
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return Astro.redirect('/admin/pages');
|
return Astro.redirect('/admin/pages');
|
||||||
}
|
}
|
||||||
@@ -103,7 +106,6 @@ if (!page) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Page Content -->
|
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<div class="preview-content">
|
<div class="preview-content">
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -112,44 +114,15 @@ if (!page) {
|
|||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class="page-blocks" style="display: flex; flex-direction: column; gap: 40px;">
|
<>
|
||||||
{/* Render Blocks */}
|
<BlockRenderer blocks={page.blocks} />
|
||||||
{page.blocks && Array.isArray(page.blocks) ? page.blocks.map((block: any) => {
|
{(!page.blocks || page.blocks.length === 0) && page.content && (
|
||||||
if (block.block_type === 'hero') {
|
// Fallback for content
|
||||||
return (
|
<div style="line-height: 1.8; color: #333;">
|
||||||
<div style="background: #111; color: white; padding: 60px 20px; text-align: center; border-radius: 12px;">
|
<Fragment set:html={page.content} />
|
||||||
<h1 style="font-size: 3rem; margin-bottom: 20px;">{block.block_config?.title}</h1>
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -23,16 +23,16 @@ export interface Page {
|
|||||||
seo_description?: string;
|
seo_description?: string;
|
||||||
seo_image?: string;
|
seo_image?: string;
|
||||||
blocks?: PageBlock[];
|
blocks?: PageBlock[];
|
||||||
|
content?: string; // legacy fallback
|
||||||
|
schema_json?: Record<string, any>;
|
||||||
date_created?: string;
|
date_created?: string;
|
||||||
date_updated?: string;
|
date_updated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageBlock {
|
export interface PageBlock {
|
||||||
id: string;
|
id: string;
|
||||||
sort: number;
|
block_type: 'hero' | 'content' | 'features' | 'cta';
|
||||||
hide_block: boolean;
|
block_config: Record<string, any>;
|
||||||
collection: string;
|
|
||||||
item: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Post {
|
export interface Post {
|
||||||
|
|||||||
Reference in New Issue
Block a user