feature: intergrated directus for posts

This commit is contained in:
libertyoms
2025-04-26 19:27:16 +02:00
parent 08965c3830
commit 809f5c6ff7
11 changed files with 530 additions and 95 deletions

View File

@ -12,48 +12,37 @@ import WhyChooseUsSection, {
import FeaturedProductSection, { import FeaturedProductSection, {
defaultObseFeatures, defaultObseFeatures,
} from "./_components/FeaturedProductSection"; } from "./_components/FeaturedProductSection";
// import HeroSectionModern from "./_components/HeroSectionModern"; import { getHome } from "@/lib/query/home";
// import HeroSectionDynamic from "./_components/HeroSectionDynamic";
export default async function HomePage() {
// Explicitly type the data variable, assuming getHome returns HeroSectionType or null/undefined
const data = await getHome();
// Handle case where data might be null or undefined
if (!data) {
// Optionally return a loading state or default content
return <div>Loading hero section...</div>;
// Or render the HeroSection with default props if preferred
}
export default function HomePage() {
return ( return (
<> <>
{" "}
{/*
<HeroSectionDynamic
title={<>Where Innovation Meets Excellence</>} // Simplified title for this layout
subtitle="We deliver cutting-edge IT solutions, empowering businesses to thrive in the ever-evolving digital landscape with unmatched industry expertise."
buttonText="Explore Solutions" // Changed button text slightly
buttonHref="/services" // Point to services maybe?
imageUrl="/hero-bg.jpg" // Use a different, high-quality background image
/>
<HeroSectionModern
title={
<>
Where Innovation <br className="hidden md:block" /> Meets
Excellence.
</>
}
subtitle="Welcome to Owethu Managed Services. We deliver cutting-edge IT solutions, empowering businesses to thrive in the ever-evolving digital landscape with unmatched industry expertise."
buttonText="Learn More"
buttonHref="/about"
imageUrl="/hero-bg.jpg" // Specify your hero image
/>
*/}
<HeroSection <HeroSection
title={ title={
<> <>
Where Innovation <br className="hidden md:block" /> Meets {data?.hero_title} <br className="hidden md:block" />
Excellence.
</> </>
} }
subtitle="Welcome to Owethu Managed Services. We deliver cutting-edge IT solutions, empowering businesses to thrive in the ever-evolving digital landscape with unmatched industry expertise." subtitle={
buttonText="Learn More" data.hero_subtitle || "Your trusted partner in technology solutions."
buttonHref="/about" } // Use optional chaining and provide a default
imageUrl="/hero-bg.jpg" // Specify your hero image buttonText={data.hero_buttons?.[0]?.label || "Learn More"} // Use optional chaining and provide a default
buttonHref={data.hero_buttons?.[0]?.link || "/about"} // Use optional chaining and provide a default
imageUrl={
data.hero_cover
? `${process.env.DIRECTUS_API_ENDPOINT}/assets/${data.hero_cover}`
: "/hero-bg.jpg"
} // Use optional chaining and provide a default
/> />
<CoreServicesSection <CoreServicesSection
title="Core Services" title="Core Services"

View File

@ -0,0 +1,294 @@
import { notFound } from "next/navigation";
import Image from "next/image";
import { getPostBySlug, getPosts } from "@/lib/query/post";
import type { Metadata } from "next";
import { format } from "date-fns";
import {
ContentBlock,
OutputData,
ParagraphData,
HeaderData,
ListData,
ImageData,
QuoteData,
CodeData,
} from "@/types";
type Props = {
params: { slug: string };
};
export async function generateStaticParams() {
const posts = await getPosts();
if (!Array.isArray(posts)) {
console.error("getPosts did not return an array:", posts);
return [];
}
return posts.map((post) => ({
slug: post.slug,
}));
}
function extractDescriptionFromBlocks(
content: OutputData | null
): string | null {
if (
!content ||
!content.blocks ||
!Array.isArray(content.blocks) ||
content.blocks.length === 0
) {
return null;
}
const firstParagraph = content.blocks.find(
(block): block is ContentBlock & { data: ParagraphData } =>
block.type === "paragraph"
);
if (firstParagraph?.data?.text) {
const plainText = firstParagraph.data.text
.replace(/<[^>]*>/g, "")
.replace(/&[^;]+;/g, "");
return plainText.substring(0, 160) + (plainText.length > 160 ? "..." : "");
}
return null;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug, {
fields: [
"slug",
"status",
"user_created",
"date_created",
"user_updated",
"date_updated",
"title",
"content",
"excerpt",
"featured_image",
],
});
if (!post) {
return {
title: "Post Not Found",
};
}
const imageUrl = post.featured_image;
const descriptionFromContent =
post.content &&
typeof post.content === "object" &&
Array.isArray(post.content.blocks)
? extractDescriptionFromBlocks(post.content as OutputData)
: null;
const description =
post.excerpt || descriptionFromContent || "Read this OMS TechTalk article.";
const publishedTime = post.date_created
? new Date(post.date_created).toISOString()
: "";
return {
title: `${post.title} | OMS TechTalk`,
description: description,
alternates: {
canonical: `/tech-talk/${post.slug}`,
},
openGraph: {
title: post.title,
description: description,
url: `https://oms.africa/tech-talk/${post.slug}`,
type: "article",
publishedTime: publishedTime,
...(imageUrl && {
images: [
{
url: imageUrl,
width: 1200,
height: 630,
alt: post.title,
},
],
}),
},
twitter: {
card: "summary_large_image",
title: post.title,
description: description,
...(imageUrl && { images: [imageUrl] }),
},
};
}
const renderBlock = (block: ContentBlock) => {
const renderListItems = (items: string[]) => {
return items.map((item, index) => (
<li
key={index}
className="mb-2"
dangerouslySetInnerHTML={{ __html: item }}
/>
));
};
switch (block.type) {
case "header":
const headerBlock = block as ContentBlock & { data: HeaderData };
const level = headerBlock.data.level || 2;
// Ensure HeaderTag has a type that JSX understands for intrinsic elements.
const HeaderTag = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
return (
<HeaderTag
key={block.id}
className={`text-${
6 - (headerBlock.data.level || 2)
}xl font-bold my-4 text-primary`}
dangerouslySetInnerHTML={{ __html: headerBlock.data.text }}
/>
);
case "paragraph":
const paragraphBlock = block as ContentBlock & { data: ParagraphData };
return (
<p
key={block.id}
className="my-4 leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraphBlock.data.text }}
/>
);
case "list":
const listBlock = block as ContentBlock & { data: ListData };
const ListTag = listBlock.data.style === "ordered" ? "ol" : "ul";
return (
<ListTag
key={block.id}
className={`my-4 pl-6 ${
listBlock.data.style === "ordered" ? "list-decimal" : "list-disc"
}`}
>
{listBlock.data.items &&
Array.isArray(listBlock.data.items) &&
renderListItems(listBlock.data.items)}
</ListTag>
);
case "image":
const imageBlock = block as ContentBlock & { data: ImageData };
const imageUrl = imageBlock.data.file?.url;
if (!imageUrl) return null;
return (
<div key={block.id} className="my-6">
<Image
src={imageUrl}
alt={imageBlock.data.caption || "Blog post image"}
width={imageBlock.data.file?.width || 800}
height={imageBlock.data.file?.height || 450}
className="rounded-md shadow-sm mx-auto"
/>
{imageBlock.data.caption && (
<p className="text-center text-sm text-muted-foreground mt-2">
{imageBlock.data.caption}
</p>
)}
</div>
);
case "quote":
const quoteBlock = block as ContentBlock & { data: QuoteData };
return (
<blockquote
key={block.id}
className="my-6 pl-4 border-l-4 border-primary italic text-muted-foreground"
>
<p dangerouslySetInnerHTML={{ __html: quoteBlock.data.text }} />
{quoteBlock.data.caption && (
<footer className="text-sm mt-2">
- {quoteBlock.data.caption}
</footer>
)}
</blockquote>
);
case "code":
const codeBlock = block as ContentBlock & { data: CodeData };
const codeContent = codeBlock.data.code || "";
return (
<pre
key={block.id}
className="my-6 p-4 bg-muted text-muted-foreground rounded-md overflow-x-auto text-sm"
>
<code>{codeContent}</code>
</pre>
);
default:
console.warn(`Unknown block type: ${block.type}`);
return null;
}
};
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
notFound();
}
const contentBlocks =
post.content &&
typeof post.content === "object" &&
Array.isArray(post.content.blocks)
? (post.content.blocks as ContentBlock[])
: null;
const coverImageUrl =
post.featured_image + "?width=1200&height=600&quality=80&fit=cover";
return (
<article className="container mx-auto px-4 py-8">
<header className="text-center mb-12 max-w-3xl mx-auto">
{coverImageUrl && (
<div className="relative w-full h-64 md:h-96 mb-6 rounded-lg overflow-hidden">
<Image
src={coverImageUrl}
alt={`Cover image for ${post.title}`}
fill={true}
style={{ objectFit: "cover" }}
className="shadow-md"
priority
/>
</div>
)}
<h1 className="text-4xl md:text-5xl font-extrabold leading-tight mb-4 text-primary">
{post.title}
</h1>
{post.date_created && (
<p className="text-muted-foreground text-sm mb-2">
Published on {format(new Date(post.date_created), "MMMM dd, yyyy")}
</p>
)}
{post.excerpt && (
<p className="text-lg text-muted-foreground italic mt-4">
{post.excerpt}
</p>
)}
</header>
<div className="container mx-auto px-4 max-w-3xl">
<div
className="prose prose-lg lg:prose-xl dark:prose-invert max-w-none mx-auto
prose-headings:text-primary prose-a:text-blue-600 hover:prose-a:text-blue-800 dark:prose-a:text-blue-400 dark:hover:prose-a:text-blue-300
prose-strong:font-semibold prose-img:rounded-md prose-img:shadow-sm"
>
{contentBlocks && contentBlocks.length > 0 ? (
contentBlocks.map((block) => renderBlock(block))
) : (
<p>Content is not available or in an unexpected format.</p>
)}
</div>
</div>
</article>
);
}

View File

@ -1,39 +1,8 @@
import React from "react"; import React from "react";
import BlogPostCard from "@/components/BlogPostCard"; import BlogPostCard from "@/components/BlogPostCard";
import { prisma } from "@/lib/prisma"; // Import Prisma client
import { auth } from "@/auth"; // Import auth to check session
import Button from "@/components/ui/Button"; // Import Button component
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getPosts } from "@/lib/query/post";
interface Post { import { Post } from "@/types";
id: string;
slug: string;
title: string;
content: string;
excerpt?: string | null;
imageUrl?: string | null;
published: boolean;
authorId: string;
createdAt: Date;
updatedAt: Date;
tags?: string[];
}
// --- Fetch Posts ---
async function getPublishedPosts() {
try {
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
// select needed fields if not all
});
return posts;
} catch (error) {
console.error("Failed to fetch posts:", error);
return []; // Return empty array on error
}
}
// --- SEO Metadata --- // --- SEO Metadata ---
export const metadata: Metadata = { export const metadata: Metadata = {
title: "OMS TechTalk | Insights & Innovation", title: "OMS TechTalk | Insights & Innovation",
@ -65,8 +34,7 @@ export const metadata: Metadata = {
// --- Page Component --- // --- Page Component ---
const TechTalkPage = async () => { const TechTalkPage = async () => {
const posts = await getPublishedPosts(); const posts: Post[] = await getPosts();
const session = await auth(); // Get session info
return ( return (
<div className="bg-background text-foreground"> <div className="bg-background text-foreground">
@ -80,28 +48,21 @@ const TechTalkPage = async () => {
Insights, trends, and discussions on the latest in technology, Insights, trends, and discussions on the latest in technology,
innovation, and digital transformation from the experts at OMS. innovation, and digital transformation from the experts at OMS.
</p> </p>
{/* Conditionally render Create Post button */}
{session?.user && (
<div className="mt-8">
<Button href="/tech-talk/create" variant="primary">
Create New Post
</Button>
</div>
)}
</div> </div>
{/* Blog Post Grid */} {/* Blog Post Grid */}
{posts.length > 0 ? ( {posts.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-10"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-10">
{posts.map((post: Post) => ( {posts.map((post: Post) => (
<BlogPostCard <BlogPostCard
key={post.id} // Use post ID as key key={post.slug}
slug={post.slug} slug={post.slug}
title={post.title} title={post.title}
excerpt={post.excerpt ?? post.content.substring(0, 150) + "..."} excerpt={post.excerpt ?? "No excerpt available"}
// Use imageUrl from DB or a default placeholder imageUrl={
imageUrl={post.imageUrl ?? "/posts/default-placeholder.jpg"} // Provide a default image post.featured_image ?? "/posts/default-placeholder.jpg"
author={"OMS Team"} // Replace with actual author logic if available (e.g., post.user.name) }
date={new Date(post.createdAt).toLocaleDateString("en-US", { author={"OMS Team"}
date={new Date(post.date_created).toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",

View File

@ -20,22 +20,25 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({
author, author,
date, date,
}) => { }) => {
console.log("BlogPostCard Props:", { imageUrl });
return ( return (
<Link href={`/tech-talk/${slug}`} passHref> <Link href={`/tech-talk/${slug}`} passHref>
<div className="group bg-card border border-border rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 ease-in-out flex flex-col h-full transform hover:-translate-y-1"> <div className="group bg-card border border-border rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 ease-in-out flex flex-col h-full transform hover:-translate-y-1">
{/* Image Container */} {/* Image Container */}
<div className="relative w-full h-48 overflow-hidden"> <div className="relative w-full aspect-video overflow-hidden">
<Image {imageUrl ? (
src={imageUrl} <Image
alt={title} src={imageUrl}
layout="fill" alt={title}
objectFit="cover" fill // Use fill instead of layout
className="transition-transform duration-500 group-hover:scale-105" className="object-cover transition-transform duration-500 group-hover:scale-105" // Use object-cover class
/> sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
{/* Optional: Subtle overlay on hover */} />
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div> ) : (
// Optional: Placeholder if no image
<div className="w-full h-full bg-muted flex items-center justify-center">
<span className="text-muted-foreground text-sm">No Image</span>
</div>
)}
</div> </div>
{/* Content Area */} {/* Content Area */}

5
lib/directus.ts Normal file
View File

@ -0,0 +1,5 @@
import { createDirectus, rest } from "@directus/sdk";
export const directus = createDirectus(
String(process.env.DIRECTUS_API_ENDPOINT!)
).with(rest());

8
lib/query/home.ts Normal file
View File

@ -0,0 +1,8 @@
import { readItems } from "@directus/sdk";
import { directus } from "../directus";
import { HeroSection } from "@/types";
export async function getHome() {
// Assuming '1' is the primary key for the singleton "home" item
return directus.request(readItems("home")) as unknown as HeroSection;
}

66
lib/query/post.ts Normal file
View File

@ -0,0 +1,66 @@
import { directus } from "@/lib/directus";
import { ItemsQuery, Post } from "@/types";
import { readItem, readItems } from "@directus/sdk";
// Construct base URL for assets from environment variable
const assetsUrl = `${process.env.DIRECTUS_API_ENDPOINT}/assets/`;
function getFullImageUrl(imageId: string | null | undefined): string | null {
if (!imageId || !assetsUrl) {
return null;
}
return `${assetsUrl}${imageId}`;
}
// Function to fetch all published posts
export async function getPosts(): Promise<Post[]> {
try {
const postsData = await directus.request(
readItems("posts", {
fields: [
"slug",
"title",
"excerpt",
"featured_image",
"date_created",
"content",
],
filter: {
status: { _eq: "published" },
},
sort: ["-date_created"],
})
);
const posts = postsData.map((post) => ({
...post,
featured_image: getFullImageUrl(post.featured_image),
})) as Post[];
return posts;
} catch (error) {
console.error("Error fetching posts:", error);
return [];
}
}
// Function to fetch a single post by slug
export async function getPostBySlug(
slug: string,
options?: ItemsQuery
): Promise<Post | null> {
try {
const postData = await directus.request(readItem("posts", slug, options));
// Map data to include full image URL
const post = {
...postData,
featured_image: getFullImageUrl(postData.featured_image),
} as Post; // Adjust cast if needed
return post;
} catch (error) {
console.error(`Error fetching post with slug ${slug}:`, error);
return null;
}
}

View File

@ -11,6 +11,10 @@ const nextConfig: NextConfig = {
protocol: "https", protocol: "https",
hostname: "storage.cvevolve.com", hostname: "storage.cvevolve.com",
}, },
{
protocol: "https",
hostname: process.env.DIRECTUS_API_ENDPOINT!.replace("https://", ""),
},
], ],
}, },
experimental: { experimental: {

24
package-lock.json generated
View File

@ -9,8 +9,10 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.9.0", "@auth/prisma-adapter": "^2.9.0",
"@directus/sdk": "^18.0.3",
"@google/generative-ai": "^0.24.0", "@google/generative-ai": "^0.24.0",
"@prisma/client": "^6.6.0", "@prisma/client": "^6.6.0",
"date-fns": "^4.1.0",
"minio": "^8.0.5", "minio": "^8.0.5",
"next": "15.3.1", "next": "15.3.1",
"next-auth": "^5.0.0-beta.26", "next-auth": "^5.0.0-beta.26",
@ -646,6 +648,18 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@directus/sdk": {
"version": "18.0.3",
"resolved": "https://registry.npmjs.org/@directus/sdk/-/sdk-18.0.3.tgz",
"integrity": "sha512-PnEDRDqr2x/DG3HZ3qxU7nFp2nW6zqJqswjii57NhriXgTz4TBUI8NmSdzQvnyHuTL9J0nedYfQGfW4v8odS1A==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/directus/directus?sponsor=1"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.4.3", "version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@ -4604,6 +4618,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",

View File

@ -10,8 +10,10 @@
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.9.0", "@auth/prisma-adapter": "^2.9.0",
"@directus/sdk": "^18.0.3",
"@google/generative-ai": "^0.24.0", "@google/generative-ai": "^0.24.0",
"@prisma/client": "^6.6.0", "@prisma/client": "^6.6.0",
"date-fns": "^4.1.0",
"minio": "^8.0.5", "minio": "^8.0.5",
"next": "15.3.1", "next": "15.3.1",
"next-auth": "^5.0.0-beta.26", "next-auth": "^5.0.0-beta.26",

79
types/index.d.ts vendored Normal file
View File

@ -0,0 +1,79 @@
export interface OutputData {
time: number;
blocks: ContentBlock[];
version: string;
}
export interface ContentBlock {
id: string;
type: string;
data:
| ParagraphData
| HeaderData
| ListData
| ImageData
| QuoteData
| CodeData;
}
export interface ParagraphData {
text: string;
}
export interface HeaderData {
text: string;
level: number;
}
export interface ListData {
style: "ordered" | "unordered";
items: string[];
}
export interface ImageData {
file: {
url: string;
width?: number;
height?: number;
};
caption?: string;
}
export interface QuoteData {
text: string;
caption?: string;
}
export interface CodeData {
code: string;
}
export interface Post {
slug: string;
status: string;
user_created: string;
date_created: string;
user_updated: string | null;
date_updated: string | null;
title: string;
content: OutputData | null;
excerpt: string | null;
featured_image: string | null;
imageUrl?: string | null;
}
export interface HeroSection {
id: string;
hero_title?: string;
hero_subtitle?: string;
hero_cover?: string;
hero_buttons?: HeroButton[];
}
interface HeroButton {
label?: string;
link?: string;
}
interface ItemsQuery {
fields?: string[];
}