mirror of
https://github.com/jordienr/zenblog.git
synced 2026-09-27 09:35:47 -04:00
wip
This commit is contained in:
@@ -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];
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user