This commit is contained in:
Jordi Enric
2024-10-22 22:08:13 +02:00
parent b4a022dfd9
commit 305fc32eed
13 changed files with 775 additions and 204 deletions
@@ -0,0 +1,121 @@
import { Endpoint } from "./public-api.types";
const BASE_HEADERS = [
{
key: "Authorization",
required: true,
description: "The API key for the blog",
},
];
export 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
}
`,
},
},
};
export 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",
}
`,
},
},
};
export 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",
}]
`,
},
},
};
export 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",
}]
`,
},
},
};
export const endpoints = [posts, postBySlug, categories, tags];
+1 -122
View File
@@ -4,7 +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";
import { categories, postBySlug, posts, tags } from "./public-api.constants";
async function verifyAPIKey(header: string, blogId: string) {
const supabase = createClient();
@@ -38,58 +38,6 @@ export const app = new Hono()
.use("*", logger())
.use("*", prettyJSON());
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");
@@ -138,33 +86,6 @@ app.get(posts.path, async (c) => {
return c.json(res, 200);
});
// Get post by slug
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");
@@ -198,26 +119,6 @@ app.get(postBySlug.path, async (c) => {
return c.json(post);
});
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();
@@ -250,26 +151,6 @@ app.get(categories.path, async (c) => {
return c.json(categories);
});
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();
@@ -307,5 +188,3 @@ 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,71 @@
"use client";
import { ObjectRenderer } from "app/docs/layout";
import { useParams } from "next/navigation";
import { endpoints } from "app/api/public/[...route]/public-api.constants";
export default function Endpoint() {
const params = useParams<{ endpointId: string }>();
const endpointId = params?.endpointId;
const endpoint = endpoints.find((e) => e.id === endpointId);
if (!endpoint) {
return <div>Endpoint not found</div>;
}
return (
<>
<div
id={endpoint.id}
key={endpoint.path}
className="m-2 rounded-md p-4 [&_h3]:font-sans"
>
<h2 id={endpoint.id} className="text-xl font-medium">
{endpoint.title}
</h2>
<p>{endpoint.description}</p>
<div className="mt-4 max-w-lg">
<ObjectRenderer
object={{
path: endpoint.path,
method: endpoint.method,
}}
/>
</div>
<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>
</>
);
}
@@ -0,0 +1,3 @@
export default function GettingStarted() {
return <div>GettingStarted</div>;
}
+82
View File
@@ -0,0 +1,82 @@
import { ZendoLogo } from "@/components/ZendoLogo";
import Link from "next/link";
import { BsGithub } from "react-icons/bs";
import { SidebarLink, SidebarTitle } from "./ui/sidebar";
import { endpoints } from "app/api/public/[...route]/public-api.constants";
export default function DocsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-full max-h-[90vh] flex-col">
<nav className="sticky top-0 z-20 border-b bg-white">
<div className="mx-auto flex w-full max-w-7xl items-center justify-between p-4">
<div className="mx-auto flex w-full max-w-7xl items-center gap-4">
<h2 className="text-lg font-semibold">
<Link href="/docs">
<ZendoLogo />
</Link>
</h2>
<Link
title="homepage"
href="/"
className="text-sm font-medium text-slate-800 hover:text-orange-500"
>
Home
</Link>
</div>
<div>
<Link
title="GitHub"
target="_blank"
href="https://github.com/jordienr/zenblog"
className="text-slate-400 hover:text-slate-600"
>
<BsGithub size="24" />
</Link>
</div>
</div>
</nav>
<div className="mx-auto flex h-screen w-full max-w-7xl">
<aside className="h-full min-w-[240px] flex-col overflow-y-auto px-4">
<SidebarTitle>Docs</SidebarTitle>
<SidebarLink href="/docs/getting-started">
Getting Started
</SidebarLink>
<SidebarTitle>API</SidebarTitle>
{endpoints.map((endpoint) => (
<SidebarLink key={endpoint.id} href={`/docs/api/${endpoint.id}`}>
{endpoint.title}
</SidebarLink>
))}
</aside>
<main className="h-full flex-1 space-y-4 overflow-y-auto pb-16">
{children}
</main>
</div>
</div>
);
}
export function ObjectRenderer({ object }: { object: any }) {
const keys = Object.keys(object);
return (
<div className="divide-y rounded-md border">
{keys.map((key) => (
<div
key={key}
className="grid grid-cols-2 gap-2 p-2 text-sm hover:bg-slate-50"
>
<p className="font-medium">{key}</p>
<p className="font-mono">{object[key]}</p>
</div>
))}
</div>
);
}
+5 -78
View File
@@ -1,82 +1,9 @@
import { endpoints } from "app/api/public/[...route]/route";
import Link from "next/link";
"use client";
import { useRouter } from "next/navigation";
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>
const router = useRouter();
{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>
);
router.push("/docs/getting-started");
return <div></div>;
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function SidebarTitle({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<h3
className={cn(
"mt-4 py-2 text-sm font-semibold text-slate-900",
className
)}
>
{children}
</h3>
);
}
export function SidebarLink({
children,
href,
}: {
children: React.ReactNode;
href: string;
}) {
const pathname = usePathname();
const isActive = pathname?.includes(href);
return (
<Link
href={href}
className={cn(
"border-l border-slate-200 p-1.5 px-3 text-sm font-medium text-slate-700 hover:border-orange-500 hover:text-slate-900",
isActive && "border-orange-500 text-slate-900"
)}
>
{children}
</Link>
);
}
+4
View File
@@ -60,6 +60,10 @@ export default function AppLayout({
label: "Media",
href: `/blogs/${selectedBlog?.id}/media`,
},
// {
// label: "Authors",
// href: `/blogs/${selectedBlog?.id}/authors`,
// },
{
label: "Tags",
href: `/blogs/${selectedBlog?.id}/tags`,
@@ -0,0 +1,232 @@
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useBlogId } from "@/hooks/use-blog-id";
import AppLayout, { Section } from "@/layouts/AppLayout";
import {
useAuthors,
useAuthorsWithPostCount,
useCreateAuthor,
useDeleteAuthorMutation,
useUpdateAuthorMutation,
} from "@/queries/authors";
import { MoreHorizontal, Plus } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
export function CreateAuthorDialog() {
const createAuthor = useCreateAuthor();
const blogId = useBlogId();
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant={"outline"}>
<Plus size={16} />
<div>Create author</div>
</Button>
</DialogTrigger>
<DialogContent className="!max-w-sm">
<DialogHeader>
<DialogTitle>Create author</DialogTitle>
</DialogHeader>
<form
className="[&_input]:mb-3 [&_input]:mt-1"
onSubmit={async (e) => {
e.preventDefault();
e.stopPropagation();
const formData = new FormData(e.target as HTMLFormElement);
const name = formData.get("name") as string;
try {
// await createAuthor.mutateAsync({
// name: name,
// blog_id: blogId,
// });
toast.success("Author created");
setOpen(false);
} catch (error) {
toast.error("Failed to create author. Email must be unique.");
}
}}
>
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" placeholder="John Doe" />
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
placeholder="john@example.com"
type="email"
/>
<div className="flex justify-end">
<Button type="submit">Create</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export function AuthorsPage() {
const blogId = useBlogId();
const { data: authors, isLoading } = useAuthorsWithPostCount();
const [selectedAuthor, setSelectedAuthor] = useState<{
author_id: number | null;
author_name: string | null;
author_email: string | null;
} | null>(null);
const [updateAuthorOpen, setUpdateAuthorOpen] = useState(false);
const updateAuthor = useUpdateAuthorMutation();
const [deleteAuthorOpen, setDeleteAuthorOpen] = useState(false);
const deleteAuthor = useDeleteAuthorMutation(blogId);
return (
<AppLayout
title="Authors"
loading={isLoading}
actions={<CreateAuthorDialog />}
>
<Section>
<Table>
<TableHeader>
<TableRow>
<TableHead>Author</TableHead>
<TableHead>Email</TableHead>
<TableHead className="text-right">Posts by author</TableHead>
<TableHead className="text-right">
<div className="sr-only">Action</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* {authors?.data?.length === 0 && <div>No authors found</div>}
{authors?.data?.map((author) => (
<TableRow key={author.author_id}>
<TableCell>{author.author_name}</TableCell>
<TableCell>{author.author_email}</TableCell>
<TableCell className="text-right">
{author.post_count}
</TableCell>
<TableCell className="text-right">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
setSelectedAuthor(author);
setUpdateAuthorOpen(true);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedAuthor(author);
setDeleteAuthorOpen(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))} */}
</TableBody>
</Table>
<Dialog open={updateAuthorOpen} onOpenChange={setUpdateAuthorOpen}>
<DialogContent className="!max-w-sm">
<DialogHeader>
<DialogTitle>Edit author</DialogTitle>
</DialogHeader>
<form
onSubmit={async (e) => {
e.preventDefault();
e.stopPropagation();
const formData = new FormData(e.target as HTMLFormElement);
const name = formData.get("name");
const email = formData.get("email");
if (!selectedAuthor?.author_id) return;
// await updateAuthor.mutateAsync({
// id: selectedAuthor.author_id,
// name: name as string,
// email: email as string,
// });
toast.success("Author updated");
setUpdateAuthorOpen(false);
}}
>
<Label htmlFor="name">Name</Label>
<Input
defaultValue={selectedAuthor?.author_name ?? ""}
id="name"
name="name"
placeholder="John Doe"
/>
<Label className="mt-4" htmlFor="email">
Email
</Label>
<Input
defaultValue={selectedAuthor?.author_email ?? ""}
id="email"
name="email"
placeholder="john@example.com"
type="email"
/>
<div className="mt-4 flex justify-end">
<Button type="submit">Update</Button>
</div>
</form>
</DialogContent>
<ConfirmDialog
open={deleteAuthorOpen}
onOpenChange={setDeleteAuthorOpen}
onConfirm={() => {
if (!selectedAuthor?.author_id) return;
deleteAuthor.mutate(selectedAuthor.author_id.toString());
toast.success("Author deleted");
setDeleteAuthorOpen(false);
}}
/>
</Dialog>
</Section>
</AppLayout>
);
}
export default AuthorsPage;
+2 -4
View File
@@ -199,7 +199,7 @@ function PostItem({
<Link
key={post.slug}
href={`/blogs/${blogId}/post/${post.slug}`}
className="borer-zinc-100 group flex flex-col gap-4 border-b px-3 py-2 transition-all hover:border-zinc-300 md:flex-row md:items-center"
className="group flex flex-col gap-4 border-b border-zinc-200 px-3 py-2 transition-all hover:bg-slate-50 md:flex-row md:items-center"
>
<div className="hidden h-16 w-24 rounded-md bg-zinc-100 md:block ">
{post.cover_image ? (
@@ -216,9 +216,7 @@ function PostItem({
</div>
<div className="flex flex-col gap-0.5">
<h2 className="ml-1 text-lg font-normal text-zinc-700 group-hover:text-zinc-950">
{post.title}
</h2>
<h2 className="ml-1 text-lg">{post.title}</h2>
{post.tags && post.tags.length > 0 && (
<div className="flex items-center gap-2">
{post.tags?.map((tag: any) => (
+95
View File
@@ -0,0 +1,95 @@
import { createSupabaseBrowserClient } from "@/lib/supabase";
import { Database } from "@/types/supabase";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
const sb = createSupabaseBrowserClient();
export type Author = Database["public"]["Tables"]["authors"]["Row"];
export function useAuthorsWithPostCount() {
return useQuery({
queryKey: ["authors-with-post-count"],
queryFn: async () => [],
// await sb
// .from("author_post_count")
// .select(
// "author_id, author_name, author_slug, post_count, created_at"
// )
// .throwOnError(),
});
}
export function useAuthors() {
return useQuery({
queryKey: ["authors"],
queryFn: async () => {
const { data, error } = await sb
.from("authors")
.select("id, slug, name, created_at, bio, twitter, website")
.throwOnError();
if (error) {
throw error;
}
return data;
},
});
}
export function useCreateAuthor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (author: Omit<Author, "id" | "created_at">) =>
await sb.from("authors").insert(author).throwOnError(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["authors"] });
},
});
}
export function useDeleteAuthorMutation(blogId: string) {
const queryClient = useQueryClient();
const supa = createSupabaseBrowserClient();
return useMutation({
mutationFn: async (authorId: string) => {
const res = await supa
.from("authors")
.delete()
.eq("id", authorId)
.eq("blog_id", blogId);
if (res.error) {
throw new Error(res.error.message);
}
return res;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["authors-with-post-count"],
});
},
});
}
export function useUpdateAuthorMutation() {
const queryClient = useQueryClient();
const supa = createSupabaseBrowserClient();
return useMutation({
mutationFn: async (author: { id: string; name: string; slug: string }) => {
const res = await supa.from("authors").update(author).eq("id", author.id);
if (res.error) {
throw new Error(res.error.message);
}
return res;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["authors-with-post-count"],
});
},
});
}
+113
View File
@@ -9,6 +9,72 @@ export type Json =
export type Database = {
public: {
Tables: {
authors: {
Row: {
bio: string | null
created_at: string
id: string
name: string
slug: string
twitter: string | null
updated_at: string
website: string | null
}
Insert: {
bio?: string | null
created_at?: string
id?: string
name: string
slug: string
twitter?: string | null
updated_at?: string
website?: string | null
}
Update: {
bio?: string | null
created_at?: string
id?: string
name?: string
slug?: string
twitter?: string | null
updated_at?: string
website?: string | null
}
Relationships: []
}
blog_authors: {
Row: {
author_id: string
blog_id: string
id: number
}
Insert: {
author_id: string
blog_id: string
id?: number
}
Update: {
author_id?: string
blog_id?: string
id?: number
}
Relationships: [
{
foreignKeyName: "blog_authors_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "authors"
referencedColumns: ["id"]
},
{
foreignKeyName: "blog_authors_blog_id_fkey"
columns: ["blog_id"]
isOneToOne: false
referencedRelation: "blogs"
referencedColumns: ["id"]
},
]
}
blog_tags: {
Row: {
blog_id: string
@@ -178,6 +244,53 @@ export type Database = {
}
Relationships: []
}
post_authors: {
Row: {
author_id: string
id: number
post_id: string
}
Insert: {
author_id: string
id?: number
post_id: string
}
Update: {
author_id?: string
id?: number
post_id?: string
}
Relationships: [
{
foreignKeyName: "post_authors_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "authors"
referencedColumns: ["id"]
},
{
foreignKeyName: "post_authors_post_id_fkey"
columns: ["post_id"]
isOneToOne: false
referencedRelation: "posts"
referencedColumns: ["id"]
},
{
foreignKeyName: "post_authors_post_id_fkey"
columns: ["post_id"]
isOneToOne: false
referencedRelation: "posts_v4"
referencedColumns: ["post_id"]
},
{
foreignKeyName: "post_authors_post_id_fkey"
columns: ["post_id"]
isOneToOne: false
referencedRelation: "posts_v5"
referencedColumns: ["post_id"]
},
]
}
post_tags: {
Row: {
blog_id: string