diff --git a/apps/web/app/api/public/[...route]/public-api.constants.ts b/apps/web/app/api/public/[...route]/public-api.constants.ts
new file mode 100644
index 0000000..7444970
--- /dev/null
+++ b/apps/web/app/api/public/[...route]/public-api.constants.ts
@@ -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];
diff --git a/apps/web/app/api/public/[...route]/route.types.ts b/apps/web/app/api/public/[...route]/public-api.types.ts
similarity index 100%
rename from apps/web/app/api/public/[...route]/route.types.ts
rename to apps/web/app/api/public/[...route]/public-api.types.ts
diff --git a/apps/web/app/api/public/[...route]/route.ts b/apps/web/app/api/public/[...route]/route.ts
index c62f0fc..11b7cfa 100644
--- a/apps/web/app/api/public/[...route]/route.ts
+++ b/apps/web/app/api/public/[...route]/route.ts
@@ -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];
diff --git a/apps/web/app/docs/api/[endpointId]/page.tsx b/apps/web/app/docs/api/[endpointId]/page.tsx
new file mode 100644
index 0000000..7d07847
--- /dev/null
+++ b/apps/web/app/docs/api/[endpointId]/page.tsx
@@ -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
Endpoint not found
;
+ }
+
+ return (
+ <>
+
+
+ {endpoint.title}
+
+
{endpoint.description}
+
+
+
+
+
+
Headers
+
+ {endpoint.headers.map((header) => (
+ -
+
+
{header.key}
+
{header.required ? "Required" : "Optional"}
+
+ {header.description}
+
+ ))}
+
+
+
+
+
Response
+
+ {Object.entries(endpoint.response).map(([status, response]) => (
+
+
+
{status}
+
{response.description}
+
+
+ {response.example}
+
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/apps/web/app/docs/getting-started/page.tsx b/apps/web/app/docs/getting-started/page.tsx
new file mode 100644
index 0000000..580ac21
--- /dev/null
+++ b/apps/web/app/docs/getting-started/page.tsx
@@ -0,0 +1,3 @@
+export default function GettingStarted() {
+ return GettingStarted
;
+}
diff --git a/apps/web/app/docs/layout.tsx b/apps/web/app/docs/layout.tsx
new file mode 100644
index 0000000..810ed29
--- /dev/null
+++ b/apps/web/app/docs/layout.tsx
@@ -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 (
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+export function ObjectRenderer({ object }: { object: any }) {
+ const keys = Object.keys(object);
+
+ return (
+
+ {keys.map((key) => (
+
+
{key}
+
{object[key]}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx
index b9daf14..1edbd90 100644
--- a/apps/web/app/docs/page.tsx
+++ b/apps/web/app/docs/page.tsx
@@ -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 (
-
-
-
-
-
- {endpoints.map((endpoint) => (
-
-
- {endpoint.title}
-
-
{endpoint.description}
-
-
-
{endpoint.method}
-
- {endpoint.path}
-
-
-
-
-
Headers
-
- {endpoint.headers.map((header) => (
- -
-
-
{header.key}
-
{header.required ? "Required" : "Optional"}
-
- {header.description}
-
- ))}
-
-
-
-
-
Response
-
- {Object.entries(endpoint.response).map(
- ([status, response]) => (
-
-
-
{status}
-
{response.description}
-
-
- {response.example}
-
-
- )
- )}
-
-
-
- ))}
-
-
-
- );
+ router.push("/docs/getting-started");
+ return ;
}
diff --git a/apps/web/app/docs/ui/sidebar.tsx b/apps/web/app/docs/ui/sidebar.tsx
new file mode 100644
index 0000000..abcfea9
--- /dev/null
+++ b/apps/web/app/docs/ui/sidebar.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export function SidebarLink({
+ children,
+ href,
+}: {
+ children: React.ReactNode;
+ href: string;
+}) {
+ const pathname = usePathname();
+
+ const isActive = pathname?.includes(href);
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx
index 99a9873..db495fe 100644
--- a/apps/web/src/layouts/AppLayout.tsx
+++ b/apps/web/src/layouts/AppLayout.tsx
@@ -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`,
diff --git a/apps/web/src/pages/blogs/[blogId]/authors.tsx b/apps/web/src/pages/blogs/[blogId]/authors.tsx
new file mode 100644
index 0000000..d01f673
--- /dev/null
+++ b/apps/web/src/pages/blogs/[blogId]/authors.tsx
@@ -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 (
+
+ );
+}
+
+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 (
+ }
+ >
+
+
+
+
+ Author
+ Email
+ Posts by author
+
+ Action
+
+
+
+
+ {/* {authors?.data?.length === 0 && No authors found
}
+ {authors?.data?.map((author) => (
+
+ {author.author_name}
+ {author.author_email}
+
+ {author.post_count}
+
+
+
+
+
+
+
+ {
+ setSelectedAuthor(author);
+ setUpdateAuthorOpen(true);
+ }}
+ >
+ Edit
+
+ {
+ setSelectedAuthor(author);
+ setDeleteAuthorOpen(true);
+ }}
+ >
+ Delete
+
+
+
+
+
+ ))} */}
+
+
+
+
+
+
+ );
+}
+
+export default AuthorsPage;
diff --git a/apps/web/src/pages/blogs/[blogId]/posts.tsx b/apps/web/src/pages/blogs/[blogId]/posts.tsx
index 7176c88..0402dab 100644
--- a/apps/web/src/pages/blogs/[blogId]/posts.tsx
+++ b/apps/web/src/pages/blogs/[blogId]/posts.tsx
@@ -199,7 +199,7 @@ function PostItem({
{post.cover_image ? (
@@ -216,9 +216,7 @@ function PostItem({
-
- {post.title}
-
+
{post.title}
{post.tags && post.tags.length > 0 && (
{post.tags?.map((tag: any) => (
diff --git a/apps/web/src/queries/authors.ts b/apps/web/src/queries/authors.ts
new file mode 100644
index 0000000..5299266
--- /dev/null
+++ b/apps/web/src/queries/authors.ts
@@ -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
) =>
+ 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"],
+ });
+ },
+ });
+}
diff --git a/apps/web/src/types/supabase.ts b/apps/web/src/types/supabase.ts
index a758481..300f7a0 100644
--- a/apps/web/src/types/supabase.ts
+++ b/apps/web/src/types/supabase.ts
@@ -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