Skip to content

Commit 9020c3c

Browse files
committed
feat(blogs): implement blog retrieval and detail components with empty state handling
1 parent b4e6080 commit 9020c3c

File tree

8 files changed

+175
-8
lines changed

8 files changed

+175
-8
lines changed

src/actions/blogs.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"use server";
2+
3+
import type { Blog } from "@/types";
4+
5+
import { createClient } from "@/lib/supabase/server";
6+
7+
export const getBlogs = async (): Promise<Blog[]> => {
8+
const supabase = await createClient();
9+
const { data, error } = await supabase.from("blogs").select("*");
10+
if (error) throw new Error(error.message);
11+
return data;
12+
};
13+
14+
export const getBlogBySlug = async (slug: string): Promise<Blog | null> => {
15+
const supabase = await createClient();
16+
const { data, error } = await supabase
17+
.from("blogs")
18+
.select("*")
19+
.eq("slug", slug)
20+
.single();
21+
22+
if (error) throw new Error(error.message);
23+
return data;
24+
};

src/app/(public)/blog/[slug]/page.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { notFound } from "next/navigation";
2+
import { getBlogBySlug } from "@/actions/blogs";
3+
import { BlogDetail } from "@/components/blogs";
4+
5+
type Props = {
6+
params: { slug: string };
7+
};
8+
9+
export default async function BlogDetailPage({ params }: Props) {
10+
const { slug } = await params;
11+
const blog = await getBlogBySlug(slug);
12+
if (!blog) return notFound();
13+
return <BlogDetail blog={blog} />;
14+
}

src/app/(public)/blog/page.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,27 @@ import type { Metadata } from "next";
22

33
import { Heading } from "@/components/ui";
44
import { PROJECT_NAME } from "@/config/constants";
5+
import { getBlogs } from "@/actions/blogs";
6+
import { EmptyList } from "@/components/layout";
7+
import { BlogList } from "@/components/blogs";
58

69
export const metadata: Metadata = {
710
title: `Blog - ${PROJECT_NAME}`,
811
description: "Articles and posts",
912
};
1013

11-
export default function BlogPage() {
14+
export default async function BlogPage() {
15+
const blogs = await getBlogs();
16+
17+
const renderResources = () => {
18+
if (blogs.length === 0) return <EmptyList type="blogs" />;
19+
return <BlogList blogs={blogs} />;
20+
};
21+
1222
return (
1323
<main className="max-w-screen-xl mx-auto p-4 flex flex-col gap-4">
1424
<Heading title="Blog" subtitle="Articles and posts" />
15-
<p>
16-
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quae tenetur
17-
itaque tempora. Nostrum ut architecto libero! Quasi, iure placeat
18-
doloribus tenetur ullam veniam esse vero nemo! Molestias inventore velit
19-
praesentium!
20-
</p>
25+
{renderResources()}
2126
</main>
2227
);
2328
}

src/components/blogs/blog-card.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import type { Blog } from "@/types";
4+
5+
import Link from "next/link";
6+
import { Card, CardFooter, Chip, Image } from "@heroui/react";
7+
8+
type Props = {
9+
blog: Blog;
10+
};
11+
12+
export default function BlogCard({ blog }: Props) {
13+
return (
14+
<article className="group">
15+
<Link href={`/blog/${blog.slug}`}>
16+
<Card
17+
isBlurred
18+
radius="none"
19+
className="bg-white dark:bg-neutral-950 active:bg-neutral-800 transition-colors duration-1000 !outline-none shadow-none rounded-xl border-2 border-neutral-200 dark:border-neutral-800"
20+
>
21+
<figure className="relative border-b-2 border-neutral-200 dark:border-neutral-800 overflow-hidden">
22+
<span className="hidden group-hover:block absolute top-2 right-2 bg-neutral-950 dark:bg-white text-neutral-950 px-2 py-1 rounded-lg text-xs z-50">
23+
Uncategorized{" "}
24+
{/* TODO: Add category, tag or something called category */}
25+
</span>
26+
<Image
27+
isBlurred
28+
src={blog.cover_url}
29+
alt={`Image by ${blog.name}`}
30+
title={blog.name}
31+
width={400}
32+
height={200}
33+
loading="lazy"
34+
radius="none"
35+
className="transform transition-transform duration-300 group-hover:scale-105"
36+
/>
37+
</figure>
38+
<CardFooter>
39+
<div className="flex flex-col gap-2">
40+
<h2 className="text-sm font-medium line-clamp-2">{blog.name}</h2>
41+
<div className="flex flex-wrap gap-1">
42+
{blog.tags?.map((tag, idx) => (
43+
<Chip
44+
key={idx}
45+
color="default"
46+
variant="flat"
47+
size="sm"
48+
radius="sm"
49+
>
50+
<span className="text-xs text-neutral-500">{tag}</span>
51+
</Chip>
52+
))}
53+
</div>
54+
</div>
55+
</CardFooter>
56+
</Card>
57+
</Link>
58+
</article>
59+
);
60+
}

src/components/blogs/blog-detail.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"use client";
2+
3+
import dynamic from "next/dynamic";
4+
import { Blog } from "@/types";
5+
6+
const MarkdownPreview = dynamic(
7+
() => import("@uiw/react-markdown-preview").then((mod) => mod.default),
8+
{ ssr: false },
9+
);
10+
11+
type Props = {
12+
blog: Blog;
13+
};
14+
15+
export default function BlogDetail({ blog }: Props) {
16+
return (
17+
<div className="max-w-4xl mx-auto py-10 px-4 space-y-8">
18+
<h1 className="text-3xl font-bold">{blog.name}</h1>
19+
20+
{blog.cover_url && (
21+
<img
22+
src={blog.cover_url}
23+
alt={`Image by ${blog.name}`}
24+
className="w-full max-h-96 object-cover rounded-xl shadow"
25+
/>
26+
)}
27+
28+
<div className="text-sm text-gray-500">
29+
Publicated on {new Date(blog.created_at).toLocaleDateString()}
30+
</div>
31+
32+
<MarkdownPreview source={blog.content} className="prose max-w-none" />
33+
</div>
34+
);
35+
}

src/components/blogs/blog-list.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Blog } from "@/types";
2+
3+
import { BlogCard } from "@/components/blogs";
4+
5+
type Props = {
6+
blogs: Blog[];
7+
};
8+
9+
export default function BlogList({ blogs }: Props) {
10+
return (
11+
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
12+
{blogs.map((blog) => (
13+
<BlogCard key={blog.id} blog={blog} />
14+
))}
15+
</section>
16+
);
17+
}

src/lib/supabase/middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const publicRoutes = [
1010
"/blog",
1111
"/faqs",
1212
];
13-
const publicRoutePrefixes = ["/auth/", "/resources/"];
13+
const publicRoutePrefixes = ["/auth/", "/resources/", "/blog"];
1414

1515
export async function updateSession(request: NextRequest) {
1616
let supabaseResponse = NextResponse.next({

src/types/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ export type ResourceList = Pick<
3939
"id" | "name" | "slug" | "image" | "tags" | "category"
4040
>;
4141

42+
export type Blog = {
43+
id: string;
44+
name: string;
45+
slug: string;
46+
content: string;
47+
cover_url: string;
48+
excerpt: string;
49+
tags: string[];
50+
author: string;
51+
created_at: string;
52+
};
53+
4254
export type Faq = {
4355
id: string;
4456
question: string;

0 commit comments

Comments
 (0)