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',
|
||||
'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
|
||||
})
|
||||
)
|
||||
]);
|
||||
|
||||
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 { 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;
|
||||
|
||||
// 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
|
||||
]);
|
||||
|
||||
if (!page && !generatedArticle) {
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
// Fetch data
|
||||
const [globals, navigation, page, generatedArticle] = await Promise.all([
|
||||
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');
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
<>
|
||||
<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 || '<p>No content.</p>'} />
|
||||
<Fragment set:html={page.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user