From 305fc32eed7e6eaeae60fcf310f816f03db30964 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Tue, 22 Oct 2024 22:08:13 +0200 Subject: [PATCH] wip --- .../public/[...route]/public-api.constants.ts | 121 +++++++++ .../{route.types.ts => public-api.types.ts} | 0 apps/web/app/api/public/[...route]/route.ts | 123 +--------- apps/web/app/docs/api/[endpointId]/page.tsx | 71 ++++++ apps/web/app/docs/getting-started/page.tsx | 3 + apps/web/app/docs/layout.tsx | 82 +++++++ apps/web/app/docs/page.tsx | 83 +------ apps/web/app/docs/ui/sidebar.tsx | 46 ++++ apps/web/src/layouts/AppLayout.tsx | 4 + apps/web/src/pages/blogs/[blogId]/authors.tsx | 232 ++++++++++++++++++ apps/web/src/pages/blogs/[blogId]/posts.tsx | 6 +- apps/web/src/queries/authors.ts | 95 +++++++ apps/web/src/types/supabase.ts | 113 +++++++++ 13 files changed, 775 insertions(+), 204 deletions(-) create mode 100644 apps/web/app/api/public/[...route]/public-api.constants.ts rename apps/web/app/api/public/[...route]/{route.types.ts => public-api.types.ts} (100%) create mode 100644 apps/web/app/docs/api/[endpointId]/page.tsx create mode 100644 apps/web/app/docs/getting-started/page.tsx create mode 100644 apps/web/app/docs/layout.tsx create mode 100644 apps/web/app/docs/ui/sidebar.tsx create mode 100644 apps/web/src/pages/blogs/[blogId]/authors.tsx create mode 100644 apps/web/src/queries/authors.ts 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 ( + + + + + + + Create author + +
{ + 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."); + } + }} + > + + + + +
+ +
+
+
+
+ ); +} + +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 + + + + + + ))} */} +
+
+ + + + + Edit author + +
{ + 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); + }} + > + + + + +
+ +
+
+
+ + { + if (!selectedAuthor?.author_id) return; + deleteAuthor.mutate(selectedAuthor.author_id.toString()); + toast.success("Author deleted"); + setDeleteAuthorOpen(false); + }} + /> +
+
+
+ ); +} + +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