This commit is contained in:
Jordi Enric
2024-10-21 22:46:12 +02:00
parent cb38cf57f9
commit b4a022dfd9
13 changed files with 259 additions and 62 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ const BasePostSchema = z.object({
description: "The cover image of the post",
example: "https://example.com/cover.jpg",
}),
abstract: z.string().optional().openapi({
excerpt: z.string().optional().openapi({
description: "The excerpt of the post",
example: "This is my first post!",
}),
+126 -8
View File
@@ -4,6 +4,7 @@ import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json";
import { Hono } from "hono";
import bcrypt from "bcrypt";
import { Endpoint } from "./route.types";
async function verifyAPIKey(header: string, blogId: string) {
const supabase = createClient();
@@ -32,13 +33,64 @@ async function verifyAPIKey(header: string, blogId: string) {
return isValid;
}
const app = new Hono()
export const app = new Hono()
.basePath("/api/public")
.use("*", logger())
.use("*", prettyJSON());
// GET POSTS
app.get("/blogs/:blogId/posts", async (c) => {
const BASE_HEADERS = [
{
key: "Authorization",
required: true,
description: "The API key for the blog",
},
];
// Get posts
const posts: Endpoint = {
id: "posts",
path: "/blogs/:blogId/posts",
method: "GET",
title: "Post list",
description: "Get posts for a blog",
headers: [
...BASE_HEADERS,
{
key: "offset",
required: false,
description: "The offset for the posts",
},
{
key: "limit",
required: false,
description: "The limit for the posts",
},
],
response: {
200: {
description: "The posts",
type: "object",
example: `
{
posts: {
title: "string",
html_content: "string",
slug: "string",
category_name: "string", // nullable
category_slug: "string", // nullable
tags: "object",
excerpt: "string", // nullable
published_at: "string",
},
total: "number", // The total number of posts
offset: "number", // The offset
limit: "number", // The limit
}
`,
},
},
};
app.get(posts.path, async (c) => {
const blogId = c.req.param("blogId");
const offset = parseInt(c.req.query("offset") || "0");
const limit = parseInt(c.req.query("limit") || "30");
@@ -87,7 +139,33 @@ app.get("/blogs/:blogId/posts", async (c) => {
});
// Get post by slug
app.get("/blogs/:blogId/posts/:slug", async (c) => {
const postBySlug: Endpoint = {
id: "postBySlug",
path: "/blogs/:blogId/posts/:slug",
method: "GET",
title: "Post detail",
description: "Get a post by its slug",
headers: [...BASE_HEADERS],
response: {
200: {
description: "The post",
type: "object",
example: `
{
title: "string",
html_content: "string",
slug: "string",
category_name: "string",
category_slug: "string",
tags: "object",
excerpt: "string",
published_at: "string",
}
`,
},
},
};
app.get(postBySlug.path, async (c) => {
const blogId = c.req.param("blogId");
const slug = c.req.param("slug");
const supabase = createClient();
@@ -120,8 +198,27 @@ app.get("/blogs/:blogId/posts/:slug", async (c) => {
return c.json(post);
});
// Get blog categories
app.get("/blogs/:blogId/categories", async (c) => {
const categories: Endpoint = {
id: "categories",
path: "/blogs/:blogId/categories",
method: "GET",
title: "Categories list",
description: "Get the categories for a blog",
headers: [...BASE_HEADERS],
response: {
200: {
description: "The categories",
type: "object",
example: `
[{
name: "string",
slug: "string",
}]
`,
},
},
};
app.get(categories.path, async (c) => {
const blogId = c.req.param("blogId");
const supabase = createClient();
const authHeader = c.req.header("Authorization");
@@ -153,8 +250,27 @@ app.get("/blogs/:blogId/categories", async (c) => {
return c.json(categories);
});
// Get blog tags
app.get("/blogs/:blogId/tags", async (c) => {
const tags: Endpoint = {
id: "tags",
path: "/blogs/:blogId/tags",
method: "GET",
title: "Tags list",
description: "Get the tags for a blog",
headers: [...BASE_HEADERS],
response: {
200: {
description: "The tags",
type: "object",
example: `
[{
name: "string",
slug: "string",
}]
`,
},
},
};
app.get(tags.path, async (c) => {
const blogId = c.req.param("blogId");
const supabase = createClient();
const authHeader = c.req.header("Authorization");
@@ -191,3 +307,5 @@ export const POST = handle(app);
export const PUT = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
export const endpoints = [posts, postBySlug, categories, tags];
@@ -0,0 +1,23 @@
export type Endpoint = {
id: string;
path: string;
method: string;
title: string;
description: string;
headers: Header[];
response: Response;
};
export type Header = {
key: string;
required: boolean;
description: string;
};
export type Response = {
[200]: {
description: string;
type: string;
example: string;
};
};
+82
View File
@@ -0,0 +1,82 @@
import { endpoints } from "app/api/public/[...route]/route";
import Link from "next/link";
export default function Docs() {
return (
<div className="h-screen">
<div className="flex">
<aside className="min-w-[240px] flex-col gap-2 border-r p-4">
<h2 className="text-lg font-medium tracking-tight">
<Link href="/">Zenblog</Link>
</h2>
{endpoints.map((endpoint) => (
<a
key={endpoint.id}
href={`#${endpoint.id}`}
className="font-mono hover:underline"
>
{endpoint.title}
</a>
))}
</aside>
<main className="max-h-screen flex-1 space-y-4 overflow-y-auto bg-zinc-100 pb-32">
{endpoints.map((endpoint) => (
<div
id={endpoint.id}
key={endpoint.path}
className="m-4 rounded-md border bg-white p-4 font-mono shadow-sm [&_h3]:font-sans"
>
<h2 id={endpoint.id} className="text-lg font-medium">
{endpoint.title}
</h2>
<p>{endpoint.description}</p>
<hr className="-mx-4 my-3" />
<div className="flex items-center gap-4">
<p className="text-sm text-gray-500">{endpoint.method}</p>
<p className="rounded-md bg-zinc-100 px-3 py-1">
{endpoint.path}
</p>
</div>
<hr className="-mx-4 my-3" />
<div className="mt-4">
<h3 className="text-md font-medium">Headers</h3>
<ul className="mt-2 list-disc space-y-2 pl-4">
{endpoint.headers.map((header) => (
<li key={header.key}>
<div className="flex gap-2">
<p className="font-medium">{header.key}</p>
<p>{header.required ? "Required" : "Optional"}</p>
</div>
<p>{header.description}</p>
</li>
))}
</ul>
</div>
<hr className="-mx-4 my-3" />
<div className="">
<h3 className="text-md font-medium">Response</h3>
<div className="mt-2">
{Object.entries(endpoint.response).map(
([status, response]) => (
<div key={status}>
<div className="flex items-center gap-2">
<p className="font-medium">{status}</p>
<p>{response.description}</p>
</div>
<pre className="mt-2 overflow-x-auto rounded-md bg-gray-100 p-4 text-xs">
{response.example}
</pre>
</div>
)
)}
</div>
</div>
</div>
))}
</main>
</div>
</div>
);
}
+3 -3
View File
@@ -17,8 +17,8 @@ export async function getBlog(subdomain: string) {
export async function getPosts(subdomain: string, sort: string = "desc") {
const supa = createClient();
const res = await supa
.from("posts_v4")
.select("title, slug, published_at, cover_image, abstract")
.from("posts_v5")
.select("title, slug, published_at, cover_image, excerpt")
.eq("blog_slug", subdomain)
.eq("published", true)
.order("published_at", { ascending: sort === "asc" });
@@ -37,7 +37,7 @@ export async function getPosts(subdomain: string, sort: string = "desc") {
export async function getPost(subdomain: string, slug: string) {
const supa = createClient();
const { data: post } = await supa
.from("posts_v4")
.from("posts_v5")
.select(
"title, content, cover_image, published_at, created_at, html_content"
)
@@ -58,8 +58,8 @@ export function BlogPostItem({
{rightText}
</motion.div>
</div>
{post.abstract && (
<p className="font-mono text-xs text-zinc-500">{post.abstract}</p>
{post.excerpt && (
<p className="font-mono text-xs text-zinc-500">{post.excerpt}</p>
)}
</Link>
</FadeIn>
+1 -1
View File
@@ -95,7 +95,7 @@ export function GardenHome({ blog, posts, disableLinks }: BlogHomeProps) {
</div>
<div className="flex h-full w-full flex-col leading-5">
<h3 className="font-medium">{post.title}</h3>
<p className="text-xs text-gray-500">{post.abstract}</p>
<p className="text-xs text-gray-500">{post.excerpt}</p>
<p className="mt-4 justify-self-end text-right align-bottom font-mono text-xs text-gray-300 group-hover:text-gray-400">
{formatPostDate(post.published_at)}
</p>
+2 -2
View File
@@ -53,8 +53,8 @@ export function InstrumentHome({ posts, blog, disableLinks }: BlogHomeProps) {
</p>
</div>
<div>
{post.abstract && (
<p className="text-zinc-400">{post.abstract}</p>
{post.excerpt && (
<p className="text-zinc-400">{post.excerpt}</p>
)}
</div>
</Link>
+1 -1
View File
@@ -13,7 +13,7 @@ export type Post = {
title: string;
published_at: string;
slug: string;
abstract?: string;
excerpt?: string;
};
export type BlogHomeProps = {
@@ -51,7 +51,7 @@ const formSchema = z.object({
slug: z.string(),
cover_image: z.string().optional(),
content: z.any(),
abstract: z.string().optional(),
excerpt: z.string().optional(),
category_id: z.number().nullable(),
});
@@ -65,7 +65,7 @@ type OnSaveData = {
cover_image?: string;
published: boolean;
metadata?: any;
abstract?: string;
excerpt?: string;
category_id: number | null;
tags?: {
id: string;
@@ -92,7 +92,7 @@ export const ZendoEditor = (props: Props) => {
title: props.post?.title || "",
slug: props.post?.slug || "",
cover_image: props.post?.cover_image || "",
abstract: props.post?.abstract || "",
excerpt: props.post?.excerpt || "",
category_id: props.post?.category_id || null,
},
});
@@ -222,7 +222,7 @@ export const ZendoEditor = (props: Props) => {
published_at: publishedAt || new Date().toISOString(),
metadata,
tags,
abstract: data.abstract,
excerpt: data.excerpt,
category_id,
});
});
@@ -559,7 +559,7 @@ export const ZendoEditor = (props: Props) => {
<div className="mt-4 text-sm text-zinc-800">
<textarea
{...register("abstract")}
{...register("excerpt")}
className="w-full resize-none rounded-lg p-1.5 outline-none transition-all focus:bg-zinc-100"
placeholder="Excerpt"
/>
+8
View File
@@ -170,6 +170,14 @@ export default function AppLayout({
</a>
<div className="flex items-center gap-1 pr-2">
<Link
title="API docs"
target="_blank"
className="rounded-full px-3 py-4 text-sm font-medium text-slate-600 hover:text-orange-600"
href="/docs"
>
Docs
</Link>
<Feedback />
{/* <Link
className="rounded-full px-3 py-4 text-sm font-medium text-slate-600 hover:text-orange-600"
+4 -38
View File
@@ -229,13 +229,6 @@ export type Database = {
referencedRelation: "posts_v5"
referencedColumns: ["post_id"]
},
{
foreignKeyName: "post_tags_post_id_fkey"
columns: ["post_id"]
isOneToOne: false
referencedRelation: "posts_with_blog_and_subscription_status_v2"
referencedColumns: ["post_id"]
},
{
foreignKeyName: "post_tags_tag_id_fkey"
columns: ["tag_id"]
@@ -254,13 +247,13 @@ export type Database = {
}
posts: {
Row: {
abstract: string
blog_id: string
category_id: number | null
content: Json
cover_image: string | null
created_at: string
deleted: boolean
excerpt: string
html_content: string
id: string
metadata: Json[] | null
@@ -272,13 +265,13 @@ export type Database = {
user_id: string
}
Insert: {
abstract?: string
blog_id: string
category_id?: number | null
content?: Json
cover_image?: string | null
created_at?: string
deleted?: boolean
excerpt?: string
html_content?: string
id?: string
metadata?: Json[] | null
@@ -290,13 +283,13 @@ export type Database = {
user_id?: string
}
Update: {
abstract?: string
blog_id?: string
category_id?: number | null
content?: Json
cover_image?: string | null
created_at?: string
deleted?: boolean
excerpt?: string
html_content?: string
id?: string
metadata?: Json[] | null
@@ -465,7 +458,6 @@ export type Database = {
}
posts_v5: {
Row: {
abstract: string | null
blog_id: string | null
blog_slug: string | null
category_name: string | null
@@ -474,6 +466,7 @@ export type Database = {
cover_image: string | null
created_at: string | null
deleted: boolean | null
excerpt: string | null
html_content: string | null
metadata: Json[] | null
post_id: string | null
@@ -495,33 +488,6 @@ export type Database = {
},
]
}
posts_with_blog_and_subscription_status_v2: {
Row: {
blog_id: string | null
content: Json | null
cover_image: string | null
created_at: string | null
deleted: boolean | null
metadata: Json[] | null
post_id: string | null
published: boolean | null
published_at: string | null
slug: string | null
subscription_status: string | null
tags: string[] | null
title: string | null
updated_at: string | null
}
Relationships: [
{
foreignKeyName: "posts_blog_id_fkey"
columns: ["blog_id"]
isOneToOne: false
referencedRelation: "blogs"
referencedColumns: ["id"]
},
]
}
tag_usage_count_v2: {
Row: {
blog_id: string | null
+1 -1
View File
@@ -25,7 +25,7 @@ const posts = await client.posts.list({ withContent: true, limit: 10 });
- [] Make layout work on mobile
- [] Make inputs not zoom in on mobile
- [] Grace period for expired subscriptions
- [] Auto generate abstract with AI
- [] Auto generate excerpt with AI
- [] Auto generate promotional tweet for a post with AI. With a short description of what the post is about.
- [] "New blogs" page in zenblog.com that links to new blogs. Good for SEO.
- [] "New posts" page in zenblog.com that links to new posts. Good for SEO.