feat: SEO schema, Word Count Goals, Internal Linking targets, and Admin UI updates
This commit is contained in:
@@ -79,15 +79,15 @@ export default function ContentFactoryDashboard() {
|
||||
{/* Header Actions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Production Overview</h2>
|
||||
<p className="text-slate-400">Monitoring Content Velocity & Integrity</p>
|
||||
<h2 className="text-xl font-bold text-white">Tactical Command Center</h2>
|
||||
<p className="text-slate-400">Shields (SEO Defense) & Weapons (Content Offense) Status</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="outline" className="text-slate-200 border-slate-700 hover:bg-slate-800" onClick={() => window.open(`${DIRECTUS_ADMIN_URL}/content/posts`, '_blank')}>
|
||||
Manage Articles (Backend)
|
||||
Manage Arsenal (Posts)
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => window.open(`${DIRECTUS_ADMIN_URL}`, '_blank')}>
|
||||
Open Directus Admin ↗
|
||||
Open HQ (Directus) ↗
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +96,7 @@ export default function ContentFactoryDashboard() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-400">Total Articles</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-slate-400">Total Units (Articles)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{stats.total}</div>
|
||||
@@ -104,7 +104,7 @@ export default function ContentFactoryDashboard() {
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800 border-l-4 border-l-purple-500">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-400">Ghost (Staged)</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-slate-400">Stealth (Ghost)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{stats.ghost}</div>
|
||||
@@ -112,7 +112,7 @@ export default function ContentFactoryDashboard() {
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800 border-l-4 border-l-green-500">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-400">Indexed (Live)</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-slate-400">Deployed (Live)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{stats.indexed}</div>
|
||||
@@ -120,7 +120,7 @@ export default function ContentFactoryDashboard() {
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800 border-l-4 border-l-blue-500">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-400">Active Jobs</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-slate-400">Active Operations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{queues.filter(q => q.status === 'Processing').length}</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ interface Props {
|
||||
globals?: Globals;
|
||||
navigation?: Navigation[];
|
||||
canonical?: string;
|
||||
schemaJson?: Record<string, any>;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -16,7 +17,8 @@ const {
|
||||
image,
|
||||
globals,
|
||||
navigation = [],
|
||||
canonical
|
||||
canonical,
|
||||
schemaJson
|
||||
} = Astro.props;
|
||||
|
||||
const siteUrl = Astro.url.origin;
|
||||
@@ -63,6 +65,11 @@ const ogImage = image || globals?.logo || '';
|
||||
<!-- Head Scripts -->
|
||||
{globals?.scripts_head && <Fragment set:html={globals.scripts_head} />}
|
||||
|
||||
<!-- JSON-LD Schema -->
|
||||
{schemaJson && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(schemaJson)} />
|
||||
)}
|
||||
|
||||
<!-- Styles -->
|
||||
<style is:global>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
@@ -218,6 +218,34 @@ export async function fetchGeneratedArticles(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single generated article by slug
|
||||
*/
|
||||
export async function fetchGeneratedArticleBySlug(
|
||||
slug: string,
|
||||
siteId: string
|
||||
): Promise<any | null> {
|
||||
try {
|
||||
const articles = await directus.request(
|
||||
readItems('generated_articles', {
|
||||
filter: {
|
||||
_and: [
|
||||
{ slug: { _eq: slug } },
|
||||
{ site: { _eq: siteId } },
|
||||
{ is_published: { _eq: true } }
|
||||
]
|
||||
},
|
||||
limit: 1,
|
||||
fields: ['*']
|
||||
})
|
||||
);
|
||||
return articles?.[0] || null;
|
||||
} catch (err) {
|
||||
console.error('Error fetching generated article:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch SEO campaigns
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { fetchPageByPermalink, fetchSiteGlobals, fetchNavigation } from '../lib/directus/fetchers';
|
||||
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';
|
||||
@@ -13,16 +13,18 @@ import BlockPosts from '../components/blocks/BlockPosts.astro';
|
||||
import BlockForm from '../components/blocks/BlockForm.astro';
|
||||
|
||||
const siteId = Astro.locals.siteId;
|
||||
const permalink = '/' + (Astro.params.slug || '');
|
||||
const slug = Astro.params.slug || '';
|
||||
const permalink = '/' + slug;
|
||||
|
||||
// Fetch data
|
||||
const [globals, navigation, page] = await Promise.all([
|
||||
const [globals, navigation, page, generatedArticle] = await Promise.all([
|
||||
siteId ? fetchSiteGlobals(siteId) : null,
|
||||
siteId ? fetchNavigation(siteId) : [],
|
||||
siteId ? fetchPageByPermalink(permalink, siteId) : null
|
||||
siteId ? fetchPageByPermalink(permalink, siteId) : null,
|
||||
siteId ? fetchGeneratedArticleBySlug(slug, siteId) : null
|
||||
]);
|
||||
|
||||
if (!page) {
|
||||
if (!page && !generatedArticle) {
|
||||
return Astro.redirect('/404');
|
||||
}
|
||||
|
||||
@@ -39,20 +41,31 @@ const blockComponents: Record<string, any> = {
|
||||
block_posts: BlockPosts,
|
||||
block_form: BlockForm,
|
||||
};
|
||||
|
||||
const activeEntity = page || generatedArticle;
|
||||
const isGenerated = !!generatedArticle && !page;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={page.seo_title || page.title}
|
||||
description={page.seo_description}
|
||||
image={page.seo_image}
|
||||
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)}
|
||||
globals={globals}
|
||||
navigation={navigation}
|
||||
schemaJson={activeEntity.schema_json}
|
||||
>
|
||||
{page.blocks?.map((block) => {
|
||||
const Component = blockComponents[block.collection];
|
||||
if (Component) {
|
||||
return <Component {...block.item} />;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{isGenerated ? (
|
||||
<article class="prose dark:prose-invert max-w-none container mx-auto px-4 py-8">
|
||||
<h1 class="text-4xl font-bold mb-6">{activeEntity.headline}</h1>
|
||||
<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;
|
||||
})
|
||||
)}
|
||||
</BaseLayout>
|
||||
|
||||
@@ -213,7 +213,26 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
: undefined
|
||||
});
|
||||
|
||||
// Create article record with featured image
|
||||
// Generate JSON-LD Schema
|
||||
const schemaJson = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": processedHeadline,
|
||||
"description": metaDescription,
|
||||
"wordCount": wordCount,
|
||||
"datePublished": new Date().toISOString(),
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": locationVars.state ? `${locationVars.state} Services` : "Local Service Provider"
|
||||
},
|
||||
"image": featuredImage.filename ? `/assets/content/${featuredImage.filename}` : undefined
|
||||
};
|
||||
|
||||
// Check Word Count Goal
|
||||
const targetWordCount = campaign.target_word_count || 1500;
|
||||
const wordCountStatus = wordCount >= targetWordCount ? 'optimal' : 'under_target';
|
||||
|
||||
// Create article record with featured image and schema
|
||||
const article = await directus.request(
|
||||
createItem('generated_articles', {
|
||||
site: siteId || campaign.site,
|
||||
@@ -223,13 +242,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
meta_description: metaDescription,
|
||||
full_html_body: fullHtmlBody,
|
||||
word_count: wordCount,
|
||||
word_count_status: wordCountStatus,
|
||||
is_published: false,
|
||||
location_city: locationVars.city || null,
|
||||
location_county: locationVars.county || null,
|
||||
location_state: locationVars.state || null,
|
||||
featured_image_svg: featuredImage.svg,
|
||||
featured_image_filename: featuredImage.filename,
|
||||
featured_image_alt: featuredImage.alt
|
||||
featured_image_alt: featuredImage.alt,
|
||||
schema_json: schemaJson
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export interface CampaignMaster {
|
||||
location_target?: string;
|
||||
batch_count?: number;
|
||||
status: 'active' | 'paused' | 'completed';
|
||||
target_word_count?: number;
|
||||
date_created?: string;
|
||||
}
|
||||
|
||||
@@ -222,6 +223,7 @@ export interface GeneratedArticle {
|
||||
meta_desc?: string;
|
||||
is_published?: boolean;
|
||||
sync_status?: string;
|
||||
schema_json?: Record<string, any>;
|
||||
date_created?: string;
|
||||
}
|
||||
|
||||
@@ -262,4 +264,18 @@ export interface SparkSchema {
|
||||
newsletter_subscribers: NewsletterSubscriber[];
|
||||
forms: Form[];
|
||||
form_submissions: FormSubmission[];
|
||||
link_targets: LinkTarget[];
|
||||
}
|
||||
|
||||
export interface LinkTarget {
|
||||
id: string;
|
||||
site: string;
|
||||
target_url?: string;
|
||||
target_post?: string;
|
||||
anchor_text: string;
|
||||
anchor_variations?: string[];
|
||||
priority?: number;
|
||||
is_active?: boolean;
|
||||
is_hub?: boolean;
|
||||
max_per_article?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user