mirror of
https://github.com/jordienr/zenblog.git
synced 2026-08-24 10:14:46 -05:00
docs
This commit is contained in:
@@ -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!",
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user