├── .env.example ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── actions ├── bookmark │ ├── add-bookmark.ts │ ├── delete-bookmark.ts │ └── get-bookmark.ts ├── comment │ ├── delete-comment.ts │ └── post-comment.ts ├── images │ ├── delete-cover-image.tsx │ └── delete-gallery-image.tsx ├── post │ ├── create-post.ts │ ├── delete-post.ts │ └── update-post.ts └── settings │ └── update-settings.ts ├── app ├── (detail) │ └── posts │ │ └── [...slug] │ │ ├── error.tsx │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ ├── not-found.tsx │ │ └── page.tsx ├── (main) │ ├── about │ │ └── page.tsx │ ├── category │ │ └── [...slug] │ │ │ ├── error.tsx │ │ │ ├── loading.tsx │ │ │ ├── not-found.tsx │ │ │ └── page.tsx │ ├── contact │ │ └── page.tsx │ ├── layout.tsx │ ├── not-found.tsx │ ├── page.tsx │ ├── policy │ │ └── page.tsx │ └── terms │ │ └── page.tsx ├── (protected) │ ├── bookmarks │ │ ├── loading.tsx │ │ └── page.tsx │ ├── editor │ │ └── posts │ │ │ ├── [postId] │ │ │ └── page.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ ├── layout.tsx │ ├── not-found.tsx │ └── settings │ │ └── page.tsx ├── api │ ├── contact │ │ └── route.tsx │ ├── og │ │ └── route.tsx │ └── subscribe │ │ └── route.ts ├── auth │ └── callback │ │ └── route.ts ├── layout.tsx ├── login │ └── page.tsx └── not-found.tsx ├── components.json ├── components ├── detail │ └── post │ │ ├── buttons │ │ ├── detail-post-bookmark-button.tsx │ │ ├── detail-post-comment-button.tsx │ │ ├── detail-post-scroll-up-button.tsx │ │ ├── detail-post-share-button.tsx │ │ └── index.ts │ │ ├── comment │ │ ├── detail-post-comment-delete-button.tsx │ │ ├── detail-post-comment-form.tsx │ │ ├── detail-post-comment-item.tsx │ │ ├── detail-post-comment-wrapper.tsx │ │ ├── detail-post-sign-in-to-comment.tsx │ │ └── index.ts │ │ ├── detail-post-comment.tsx │ │ ├── detail-post-floating-bar.tsx │ │ ├── detail-post-header.tsx │ │ ├── detail-post-heading.tsx │ │ ├── detail-post-loading.tsx │ │ └── index.ts ├── login │ ├── index.ts │ ├── login-button.tsx │ ├── login-header.tsx │ ├── login-menu.tsx │ ├── login-profile-button.tsx │ └── login-section.tsx ├── main │ ├── banner │ │ └── main-banner.tsx │ ├── footer │ │ ├── main-footer.tsx │ │ └── main-newsletter.tsx │ ├── grid │ │ └── main-grid.tsx │ ├── header │ │ ├── main-header.tsx │ │ └── navigations │ │ │ ├── index.ts │ │ │ ├── main-desktop-navigation.tsx │ │ │ ├── main-mobile-navigation.tsx │ │ │ └── menu │ │ │ ├── index.ts │ │ │ ├── main-desktop-navigation-menu.tsx │ │ │ ├── main-mobile-menu-button.tsx │ │ │ └── main-mobile-navigation-menu.tsx │ ├── index.ts │ ├── pages │ │ ├── email.tsx │ │ ├── main-about-page.tsx │ │ ├── main-contact-page.tsx │ │ ├── main-policy-page.tsx │ │ └── main-terms-page.tsx │ ├── post │ │ ├── loading │ │ │ ├── index.ts │ │ │ ├── main-post-item-desktop-loading.tsx │ │ │ └── main-post-item-mobile-loading.tsx │ │ ├── main-post-item-loading.tsx │ │ └── main-post-item.tsx │ └── tailwind-indicator │ │ └── tailwind-indicator.tsx ├── protected │ ├── bookmark │ │ ├── index.ts │ │ ├── protected-bookmark-delete-button.tsx │ │ ├── protected-bookmark-table-columns.tsx │ │ ├── protected-bookmark-table-row-actions.tsx │ │ ├── protected-bookmark-table-title.tsx │ │ └── protected-bookmark-view-button.tsx │ ├── editor │ │ ├── editor.tsx │ │ ├── upload │ │ │ ├── cover-image │ │ │ │ ├── editor-upload-cover-image-item.tsx │ │ │ │ └── editor-upload-cover-image-placeholder.tsx │ │ │ ├── gallery-image │ │ │ │ ├── editor-upload-gallery-image-item.tsx │ │ │ │ ├── editor-upload-gallery-image-placeholder.tsx │ │ │ │ ├── editor-upload-gallery-image-table-empty.tsx │ │ │ │ └── editor-upload-gallery-image-table.tsx │ │ │ └── index.ts │ │ └── wysiwyg │ │ │ ├── bubble-menu │ │ │ ├── color-selector.tsx │ │ │ ├── image-selector.tsx │ │ │ ├── index.tsx │ │ │ ├── link-selector.tsx │ │ │ └── node-selector.tsx │ │ │ ├── default-content.tsx │ │ │ ├── extensions │ │ │ ├── custom-keymap.ts │ │ │ ├── drag-and-drop.tsx │ │ │ ├── image-resizer.tsx │ │ │ ├── index.tsx │ │ │ ├── slash-command.tsx │ │ │ └── updated-image.tsx │ │ │ ├── props.ts │ │ │ └── wysiwyg-editor.tsx │ ├── main │ │ ├── header │ │ │ ├── index.ts │ │ │ ├── protected-mobile-menu-button.tsx │ │ │ ├── protected-profile-dropdown.tsx │ │ │ └── protected-top-bar.tsx │ │ ├── index.ts │ │ ├── protected-main.tsx │ │ └── sidebars │ │ │ ├── index.ts │ │ │ ├── protected-desktop-sidebar.tsx │ │ │ └── protected-mobile-sidebar.tsx │ ├── post │ │ ├── buttons │ │ │ ├── post-create-button.tsx │ │ │ └── post-edit-button.tsx │ │ ├── post-emtpy-table.tsx │ │ ├── post-refresh-once.tsx │ │ ├── post-table-title.tsx │ │ └── table │ │ │ ├── columns.tsx │ │ │ ├── data-table-column-header.tsx │ │ │ ├── data-table-faceted-filter.tsx │ │ │ ├── data-table-pagination.tsx │ │ │ ├── data-table-row-actions.tsx │ │ │ ├── data-table-toolbar.tsx │ │ │ ├── data-table-view-options.tsx │ │ │ ├── data-table.tsx │ │ │ └── data │ │ │ └── data.ts │ └── settings │ │ └── protected-settings-profile.tsx ├── shared │ ├── index.ts │ ├── shared-back-button.tsx │ ├── shared-empty.tsx │ ├── shared-error.tsx │ ├── shared-not-found.tsx │ ├── shared-og-image-wrapper.tsx │ ├── shared-og-image.tsx │ ├── shared-pager.tsx │ ├── shared-pagination.tsx │ ├── shared-table-empty.tsx │ └── shared-table-loading.tsx └── ui │ ├── alert-dialog.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── radio-group.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── switch.tsx │ ├── table.tsx │ └── textarea.tsx ├── config ├── detail │ ├── detail-bookmark-config.ts │ ├── detail-comment-config.ts │ ├── detail-share-config.ts │ └── index.ts ├── main │ ├── index.ts │ ├── main-banner-config.ts │ ├── main-category-config.ts │ ├── main-footer-config.ts │ ├── main-newsletter-config.ts │ ├── main-post-config.ts │ └── pages │ │ ├── index.ts │ │ ├── main-page-about-config.ts │ │ ├── main-page-contact-config.ts │ │ ├── main-page-policy-config.ts │ │ └── main-page-terms-config.ts ├── protected │ ├── index.ts │ ├── protected-editor-config.ts │ ├── protected-post-config.ts │ └── protected-profile-config.ts ├── root │ └── seo.tsx └── shared │ ├── dashboard │ ├── dashboard-bookmark.ts │ ├── dashboard-logout.ts │ ├── dashboard-menu.ts │ ├── dashboard-post.ts │ ├── dashboard-profile.ts │ ├── dashboard-settings.ts │ └── index.ts │ ├── index.ts │ ├── shared-empty-config.ts │ ├── shared-login-config.ts │ ├── shared-not-found-config.ts │ └── shared-paging-config.ts ├── database_schema ├── dummy_data.csv └── supabase_db_schema.sql ├── hooks └── use-reading-progress.ts ├── icons ├── bookmark-outline-icon.tsx ├── bookmark-solid-icon.tsx ├── categories │ ├── category-health-icon.tsx │ ├── category-home-icon.tsx │ ├── category-marketing-icon.tsx │ ├── category-science-icon.tsx │ ├── category-technology-icon.tsx │ └── index.ts ├── github-icon.tsx ├── google-icon.tsx ├── icon-wrapper-rounded.tsx ├── index.ts ├── loading-dots.module.css ├── loading-dots.tsx ├── login-icon.tsx ├── logo-icon.tsx ├── message-outline-icon.tsx ├── message-solid-icon.tsx ├── share-outline-icon.tsx ├── share-solid-icon.tsx └── socials │ ├── facebook-icon.tsx │ ├── github-icon.tsx │ ├── index.ts │ ├── instagram-icon.tsx │ ├── twitter-icon.tsx │ └── youtube-icon.tsx ├── lib ├── nodemailer.ts ├── utils.ts └── validation │ ├── bookmark.ts │ ├── comment.ts │ ├── contact.ts │ ├── image.ts │ ├── og.ts │ ├── post.ts │ └── profile.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicons │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-180x180.png │ ├── apple-icon-precomposed.png │ ├── apple-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── favicon.ico │ └── manifest.json ├── fonts │ ├── Inter-Bold.ttf │ ├── Inter-Medium.ttf │ └── Inter-Regular.ttf └── images │ ├── logo.png │ ├── not-found.jpg │ ├── opengraph-image.png │ ├── twitter-image.png │ └── user-placeholder.png ├── styles ├── editor.css ├── prosemirror.css └── tailwind.css ├── tailwind.config.js ├── tsconfig.json ├── types ├── collection.ts ├── index.d.ts └── supabase.ts ├── utils └── supabase │ ├── client.ts │ ├── middleware.ts │ └── server.ts └── vercel.json /.env.example: -------------------------------------------------------------------------------- 1 | # Next Public 2 | # ----------------------------------------------------------------------------- 3 | NEXT_PUBLIC_APP_URL="http://localhost:3000" 4 | NEXT_PUBLIC_WEB_URL= 5 | 6 | # ----------------------------------------------------------------------------- 7 | # Nodemailer - Sending a email via contact page 8 | # ----------------------------------------------------------------------------- 9 | GOOGLE_EMAIL= 10 | GOOGLE_PASSWORD= 11 | 12 | # ----------------------------------------------------------------------------- 13 | # Convertkit - Form 14 | # ----------------------------------------------------------------------------- 15 | CONVERTKIT_API_KEY= 16 | CONVERTKIT_FORM_ID= 17 | CONVERTKIT_API_URL= 18 | 19 | 20 | # ----------------------------------------------------------------------------- 21 | # SUPERBASE 22 | # ----------------------------------------------------------------------------- 23 | NEXT_PUBLIC_SUPABASE_PROJECT_ID= 24 | NEXT_PUBLIC_SUPABASE_URL= 25 | NEXT_PUBLIC_SUPABASE_ANON_KEY= 26 | NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET_POSTS=posts 27 | NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET_COVER_IMAGE=cover-image 28 | NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET_GALLERY_IMAGE=gallery-image 29 | NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET_PROFILE=profile 30 | SUPABASE_ACCESS_TOKEN= 31 | 32 | 33 | # ----------------------------------------------------------------------------- 34 | # FOR DEMO PURPOSES ONLY 35 | # ----------------------------------------------------------------------------- 36 | 37 | NEXT_PUBLIC_AUTHOR_ID=a84dba4d-947f-4802-b885-7b4177439901 38 | 39 | NEXT_PUBLIC_PAGE_COUNT=30 40 | 41 | NEXT_PUBLIC_GA_TRACKING_ID= 42 | NEXT_PUBLIC_GA_MEASUREMENT_ID= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | #supabase 38 | ubdotcafe.code-workspace 39 | /supabase 40 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .next 4 | build 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@ianvs/prettier-plugin-sort-imports", 4 | "prettier-plugin-tailwindcss" 5 | ], 6 | "pluginSearchDirs": false 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode" 3 | } -------------------------------------------------------------------------------- /actions/bookmark/add-bookmark.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { bookmarkSchema } from "@/lib/validation/bookmark"; 4 | import { Database } from "@/types/supabase"; 5 | import { createClient } from "@/utils/supabase/server"; 6 | import { cookies } from "next/headers"; 7 | import * as z from "zod"; 8 | 9 | export async function AddBookmark(context: z.infer) { 10 | const cookieStore = cookies(); 11 | const supabase = createClient(cookieStore); 12 | try { 13 | const bookmark = bookmarkSchema.parse(context); 14 | const { data, error } = await supabase 15 | .from("bookmarks") 16 | .insert({ 17 | id: bookmark.id, 18 | user_id: bookmark.user_id, 19 | }) 20 | .single(); 21 | 22 | if (error) { 23 | console.log(error); 24 | return false; 25 | } 26 | return true; 27 | } catch (error) { 28 | if (error instanceof z.ZodError) { 29 | console.log(error); 30 | return false; 31 | } 32 | return false; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /actions/bookmark/delete-bookmark.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { bookmarkSchema } from "@/lib/validation/bookmark"; 4 | import { Database } from "@/types/supabase"; 5 | import { createClient } from "@/utils/supabase/server"; 6 | import { cookies } from "next/headers"; 7 | import * as z from "zod"; 8 | 9 | export async function DeleteBookmark(context: z.infer) { 10 | const cookieStore = cookies(); 11 | const supabase = createClient(cookieStore); 12 | 13 | try { 14 | const bookmark = bookmarkSchema.parse(context); 15 | 16 | const { data, error } = await supabase 17 | .from("bookmarks") 18 | .delete() 19 | .match({ id: bookmark.id, user_id: bookmark.user_id }) 20 | .select(); 21 | 22 | if (error) { 23 | console.log(error); 24 | return false; 25 | } 26 | if (data && data.length > 0) { 27 | return true; 28 | } 29 | return false; 30 | } catch (error) { 31 | if (error instanceof z.ZodError) { 32 | console.log(error); 33 | return false; 34 | } 35 | return false; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /actions/bookmark/get-bookmark.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { bookmarkSchema } from "@/lib/validation/bookmark"; 4 | import { Database } from "@/types/supabase"; 5 | import { createClient } from "@/utils/supabase/server"; 6 | import { cookies } from "next/headers"; 7 | import * as z from "zod"; 8 | 9 | export async function GetBookmark(context: z.infer) { 10 | const cookieStore = cookies(); 11 | const supabase = createClient(cookieStore); 12 | try { 13 | const bookmark = bookmarkSchema.parse(context); 14 | 15 | const { data, error } = await supabase 16 | .from("bookmarks") 17 | .select("*") 18 | .match({ id: bookmark.id, user_id: bookmark.user_id }); 19 | 20 | if (error) { 21 | console.log(error); 22 | return false; 23 | } 24 | if (data && data.length > 0) { 25 | return true; 26 | } 27 | return false; 28 | } catch (error) { 29 | if (error instanceof z.ZodError) { 30 | console.log(error); 31 | return false; 32 | } 33 | return false; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /actions/comment/delete-comment.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { commentDeleteSchema } from "@/lib/validation/comment"; 4 | import { Database } from "@/types/supabase"; 5 | import { createClient } from "@/utils/supabase/server"; 6 | import { cookies } from "next/headers"; 7 | import * as z from "zod"; 8 | 9 | export async function DeleteComment( 10 | context: z.infer, 11 | ) { 12 | const cookieStore = cookies(); 13 | const supabase = createClient(cookieStore); 14 | try { 15 | const comment = commentDeleteSchema.parse(context); 16 | 17 | const { data, error } = await supabase 18 | .from("comments") 19 | .delete() 20 | .match({ id: comment.id, user_id: comment.userId }) 21 | .select(); 22 | 23 | if (error) { 24 | console.log(error); 25 | return false; 26 | } 27 | if (data && data.length > 0) { 28 | return true; 29 | } 30 | return false; 31 | } catch (error) { 32 | if (error instanceof z.ZodError) { 33 | console.log(error); 34 | return false; 35 | } 36 | return false; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /actions/comment/post-comment.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { commentSchema } from "@/lib/validation/comment"; 4 | import { Database } from "@/types/supabase"; 5 | import { createClient } from "@/utils/supabase/server"; 6 | import { cookies } from "next/headers"; 7 | import * as z from "zod"; 8 | 9 | export async function PostComment(context: z.infer) { 10 | const cookieStore = cookies(); 11 | const supabase = createClient(cookieStore); 12 | try { 13 | const comment = commentSchema.parse(context); 14 | const { data, error } = await supabase 15 | .from("comments") 16 | .insert({ 17 | post_id: comment.postId, 18 | user_id: comment.userId, 19 | comment: comment.comment, 20 | }) 21 | .single(); 22 | 23 | if (error) { 24 | console.log(error); 25 | return false; 26 | } 27 | return true; 28 | } catch (error) { 29 | if (error instanceof z.ZodError) { 30 | console.log(error); 31 | return false; 32 | } 33 | return false; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /actions/images/delete-cover-image.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { imageDeleteSchema } from "@/lib/validation/image"; 4 | import { Database } from "@/types/supabase"; 5 | import { createClient } from "@/utils/supabase/server"; 6 | import { cookies } from "next/headers"; 7 | import * as z from "zod"; 8 | 9 | export async function DeleteCoverImage( 10 | context: z.infer, 11 | ) { 12 | const cookieStore = cookies(); 13 | const supabase = createClient(cookieStore); 14 | try { 15 | const { userId, postId, fileName } = imageDeleteSchema.parse(context); 16 | const bucketName = 17 | process.env.NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET_COVER_IMAGE || 18 | "cover-image"; 19 | 20 | const { data, error } = await supabase.storage 21 | .from(bucketName) 22 | .remove([`${userId}/${postId}/${fileName}`]); 23 | 24 | if (error) { 25 | console.log(error); 26 | } 27 | if (data?.length && data?.length > 0) { 28 | return true; 29 | } else { 30 | return false; 31 | } 32 | } catch (error) { 33 | if (error instanceof z.ZodError) { 34 | console.log(error); 35 | return false; 36 | } 37 | return false; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /actions/images/delete-gallery-image.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { imageDeleteSchema } from "@/lib/validation/image"; 4 | import { Database } from "@/types/supabase"; 5 | import { createClient } from "@/utils/supabase/server"; 6 | import { cookies } from "next/headers"; 7 | import * as z from "zod"; 8 | 9 | export async function DeleteGalleryImage( 10 | context: z.infer, 11 | ) { 12 | const cookieStore = cookies(); 13 | const supabase = createClient(cookieStore); 14 | try { 15 | const { userId, postId, fileName } = imageDeleteSchema.parse(context); 16 | const bucketName = 17 | process.env.NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET_GALLERY_IMAGE || 18 | "gallery-image"; 19 | 20 | const { data, error } = await supabase.storage 21 | .from(bucketName) 22 | .remove([`${userId}/${postId}/${fileName}`]); 23 | 24 | if (error) { 25 | console.log(error); 26 | } 27 | if (data?.length && data?.length > 0) { 28 | return true; 29 | } else { 30 | return false; 31 | } 32 | } catch (error) { 33 | if (error instanceof z.ZodError) { 34 | console.log(error); 35 | return false; 36 | } 37 | return false; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /actions/post/create-post.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { postCreateSchema } from "@/lib/validation/post"; 4 | import type { Database } from "@/types/supabase"; 5 | import { createClient } from "@/utils/supabase/server"; 6 | import { cookies } from "next/headers"; 7 | import * as z from "zod"; 8 | 9 | export async function CreatePost(context: z.infer) { 10 | const cookieStore = cookies(); 11 | const supabase = createClient(cookieStore); 12 | try { 13 | const post = postCreateSchema.parse(context); 14 | const { data, error } = await supabase 15 | .from("drafts") 16 | .insert({ 17 | title: post.title, 18 | author_id: post.user_id, 19 | }) 20 | .select() 21 | .single(); 22 | 23 | if (error) { 24 | console.log(error); 25 | return null; 26 | } 27 | return data; 28 | } catch (error) { 29 | console.log(error); 30 | return null; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /actions/post/delete-post.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { postDeleteSchema } from "@/lib/validation/post"; 4 | import type { Database } from "@/types/supabase"; 5 | import { createClient } from "@/utils/supabase/server"; 6 | import { cookies } from "next/headers"; 7 | import * as z from "zod"; 8 | 9 | export async function DeletePost(context: z.infer) { 10 | const cookieStore = cookies(); 11 | const supabase = createClient(cookieStore); 12 | try { 13 | const post = postDeleteSchema.parse(context); 14 | 15 | const { data, error } = await supabase 16 | .from("drafts") 17 | .delete() 18 | .match({ id: post.id, author_id: post.user_id }) 19 | .select(); 20 | 21 | if (error) { 22 | console.log(error); 23 | return false; 24 | } 25 | if (data && data.length > 0) { 26 | return true; 27 | } 28 | return false; 29 | } catch (error) { 30 | if (error instanceof z.ZodError) { 31 | console.log(error); 32 | return false; 33 | } 34 | return false; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /actions/post/update-post.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { postUpdateSchema } from "@/lib/validation/post"; 4 | import type { Database } from "@/types/supabase"; 5 | import { createClient } from "@/utils/supabase/server"; 6 | import { cookies } from "next/headers"; 7 | import * as z from "zod"; 8 | 9 | export async function UpdatePost(context: z.infer) { 10 | const cookieStore = cookies(); 11 | const supabase = createClient(cookieStore); 12 | try { 13 | const post = postUpdateSchema.parse(context); 14 | 15 | const { data, error } = await supabase 16 | .from("drafts") 17 | .update({ 18 | id: post.id, 19 | title: post.title, 20 | slug: post.slug, 21 | category_id: post.categoryId, 22 | description: post.description, 23 | image: post.image, 24 | content: post.content, 25 | }) 26 | .match({ id: post.id }) 27 | .select() 28 | .single(); 29 | 30 | if (error) { 31 | console.log(error); 32 | return null; 33 | } 34 | return data; 35 | } catch (error) { 36 | console.log(error); 37 | return null; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /actions/settings/update-settings.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { profileSchema } from "@/lib/validation/profile"; 4 | import { Database } from "@/types/supabase"; 5 | import { createClient } from "@/utils/supabase/server"; 6 | import { cookies } from "next/headers"; 7 | import * as z from "zod"; 8 | 9 | export async function UpdateSettings(context: z.infer) { 10 | const cookieStore = cookies(); 11 | const supabase = createClient(cookieStore); 12 | try { 13 | const profile = profileSchema.parse(context); 14 | const { data, error } = await supabase 15 | .from("profiles") 16 | .update({ 17 | full_name: `${profile.fistName} ${profile.lastName}`, 18 | username: profile.userName, 19 | avatar_url: profile.avatarUrl, 20 | website: profile.website, 21 | }) 22 | .eq("id", profile.id); 23 | 24 | if (error) { 25 | console.log(error); 26 | return false; 27 | } 28 | return true; 29 | } catch (error) { 30 | if (error instanceof z.ZodError) { 31 | console.log(error); 32 | return false; 33 | } 34 | return false; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/(detail)/posts/[...slug]/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SharedError } from "@/components/shared"; 4 | import React from "react"; 5 | 6 | const Error = () => { 7 | return ; 8 | }; 9 | 10 | export default Error; 11 | -------------------------------------------------------------------------------- /app/(detail)/posts/[...slug]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { DetailPostHeader } from "@/components/detail/post"; 2 | import { PostWithCategoryWithProfile } from "@/types/collection"; 3 | import type { Database } from "@/types/supabase"; 4 | import { createClient } from "@/utils/supabase/server"; 5 | import { cookies } from "next/headers"; 6 | import { notFound } from "next/navigation"; 7 | 8 | async function getPost(params: { slug: string[] }) { 9 | const cookieStore = cookies(); 10 | const supabase = createClient(cookieStore); 11 | const slug = params?.slug?.join("/"); 12 | 13 | if (!slug) { 14 | notFound; 15 | } 16 | 17 | const response = await supabase 18 | .from("posts") 19 | .select(`*, categories(*), profiles(*)`) 20 | .match({ slug: slug, published: true }) 21 | .single(); 22 | 23 | if (!response.data) { 24 | notFound; 25 | } 26 | 27 | return response.data; 28 | } 29 | 30 | export default async function MainLayout({ 31 | children, 32 | params, 33 | }: { 34 | children: React.ReactNode; 35 | params: { 36 | slug: string[]; 37 | }; 38 | }) { 39 | const post = await getPost(params); 40 | 41 | if (!post) { 42 | notFound(); 43 | } 44 | return ( 45 | <> 46 | 47 |
48 |
49 |
{children}
50 |
51 |
52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/(detail)/posts/[...slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { DetailPostLoading } from "@/components/detail/post"; 2 | import React from "react"; 3 | 4 | const Loading = () => { 5 | return ( 6 | <> 7 | 8 | 9 | ); 10 | }; 11 | 12 | export default Loading; 13 | -------------------------------------------------------------------------------- /app/(detail)/posts/[...slug]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { SharedNotFound } from "@/components/shared"; 2 | import React from "react"; 3 | 4 | const NotFound = () => { 5 | return ; 6 | }; 7 | 8 | export default NotFound; 9 | -------------------------------------------------------------------------------- /app/(main)/about/page.tsx: -------------------------------------------------------------------------------- 1 | import MainAboutPage from "@/components/main/pages/main-about-page"; 2 | import React from "react"; 3 | 4 | export default function About() { 5 | return ( 6 | <> 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/(main)/category/[...slug]/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SharedError } from "@/components/shared"; 4 | import React from "react"; 5 | 6 | const Error = () => { 7 | return ; 8 | }; 9 | 10 | export default Error; 11 | -------------------------------------------------------------------------------- /app/(main)/category/[...slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | import MainPostItemDesktopLoading from "@/components/main/post/loading/main-post-item-desktop-loading"; 2 | import MainPostItemMobileLoading from "@/components/main/post/loading/main-post-item-mobile-loading"; 3 | import React from "react"; 4 | 5 | const Loading = () => { 6 | return ( 7 | <> 8 | {/* LoadingItems */} 9 | 10 | 11 | 12 | ); 13 | }; 14 | 15 | export default Loading; 16 | -------------------------------------------------------------------------------- /app/(main)/category/[...slug]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { SharedNotFound } from "@/components/shared"; 2 | import React from "react"; 3 | 4 | const NotFound = () => { 5 | return ; 6 | }; 7 | 8 | export default NotFound; 9 | -------------------------------------------------------------------------------- /app/(main)/contact/page.tsx: -------------------------------------------------------------------------------- 1 | import MainContactPage from "@/components/main/pages/main-contact-page"; 2 | import React from "react"; 3 | 4 | export default function ContactPage() { 5 | return ( 6 | <> 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MainBanner, 3 | MainFooter, 4 | MainGrid, 5 | MainHeader, 6 | } from "@/components/main"; 7 | import { ReactNode } from "react"; 8 | 9 | export default function MainLayout({ children }: { children: ReactNode }) { 10 | return ( 11 | <> 12 | 13 | 14 | 15 |
16 |
17 |
{children}
18 |
19 |
20 |
21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/(main)/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { SharedNotFound } from "@/components/shared"; 2 | 3 | const NotFound = () => { 4 | return ; 5 | }; 6 | 7 | export default NotFound; 8 | -------------------------------------------------------------------------------- /app/(main)/page.tsx: -------------------------------------------------------------------------------- 1 | import { MainPostItem, MainPostItemLoading } from "@/components/main"; 2 | import { SharedPagination } from "@/components/shared"; 3 | import { PostWithCategoryWithProfile } from "@/types/collection"; 4 | import { createClient } from "@/utils/supabase/server"; 5 | import { cookies } from "next/headers"; 6 | import { notFound } from "next/navigation"; 7 | import { Suspense } from "react"; 8 | import { v4 } from "uuid"; 9 | 10 | export const revalidate = 0; 11 | 12 | interface HomePageProps { 13 | searchParams: { [key: string]: string | string[] | undefined }; 14 | } 15 | 16 | export default async function HomePage({ searchParams }: HomePageProps) { 17 | const cookieStore = cookies(); 18 | const supabase = createClient(cookieStore); 19 | 20 | // Fetch total pages 21 | const { count } = await supabase 22 | .from("posts") 23 | .select("*", { count: "exact", head: true }); 24 | 25 | // Pagination 26 | const limit = 10; 27 | const totalPages = count ? Math.ceil(count / limit) : 0; 28 | const page = 29 | typeof searchParams.page === "string" && 30 | +searchParams.page > 1 && 31 | +searchParams.page <= totalPages 32 | ? +searchParams.page 33 | : 1; 34 | const from = (page - 1) * limit; 35 | const to = page ? from + limit : limit; 36 | 37 | // Fetch posts 38 | const { data, error } = await supabase 39 | .from("posts") 40 | .select(`*, categories(*), profiles(*)`) 41 | .eq("published", true) 42 | .order("created_at", { ascending: false }) 43 | .range(from, to) 44 | .returns(); 45 | 46 | if (!data || error || !data.length) { 47 | notFound; 48 | } 49 | 50 | return ( 51 | <> 52 |
53 | {data?.map((post) => ( 54 | }> 55 | 56 | 57 | ))} 58 |
59 | {/* Pagination */} 60 | {totalPages > 1 && ( 61 | 67 | )} 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /app/(main)/policy/page.tsx: -------------------------------------------------------------------------------- 1 | import MainPolicyPage from "@/components/main/pages/main-policy-page"; 2 | import React from "react"; 3 | 4 | export default function Policy() { 5 | return ( 6 | <> 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/(main)/terms/page.tsx: -------------------------------------------------------------------------------- 1 | import MainTermsPage from "@/components/main/pages/main-terms-page"; 2 | import React from "react"; 3 | 4 | export default function Terms() { 5 | return ( 6 | <> 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/(protected)/bookmarks/loading.tsx: -------------------------------------------------------------------------------- 1 | import { SharedTableLoading } from "@/components/shared"; 2 | import React from "react"; 3 | 4 | export default function Loading() { 5 | return ( 6 | <> 7 |
8 | 9 |
10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/(protected)/editor/posts/loading.tsx: -------------------------------------------------------------------------------- 1 | import { SharedTableLoading } from "@/components/shared"; 2 | import React from "react"; 3 | 4 | export default function Loading() { 5 | return ( 6 | <> 7 |
8 | 9 |
10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/(protected)/editor/posts/page.tsx: -------------------------------------------------------------------------------- 1 | import PostTableEmpty from "@/components/protected/post/post-emtpy-table"; 2 | import PostRefreshOnce from "@/components/protected/post/post-refresh-once"; 3 | import PostTableTitle from "@/components/protected/post/post-table-title"; 4 | import { columns } from "@/components/protected/post/table/columns"; 5 | import { DataTable } from "@/components/protected/post/table/data-table"; 6 | import { protectedPostConfig } from "@/config/protected"; 7 | import { Draft } from "@/types/collection"; 8 | import type { Database } from "@/types/supabase"; 9 | import { createClient } from "@/utils/supabase/server"; 10 | import { Metadata } from "next"; 11 | import { cookies } from "next/headers"; 12 | import { notFound } from "next/navigation"; 13 | import { FC } from "react"; 14 | 15 | export const revalidate = 0; 16 | 17 | export const metadata: Metadata = { 18 | title: protectedPostConfig.title, 19 | description: protectedPostConfig.description, 20 | }; 21 | 22 | interface PostsPageProps { 23 | searchParams: { [key: string]: string | string[] | undefined }; 24 | } 25 | 26 | const PostsPage: FC = async ({ searchParams }) => { 27 | const cookieStore = cookies(); 28 | const supabase = createClient(cookieStore); 29 | // Fetch user data 30 | const { 31 | data: { user }, 32 | } = await supabase.auth.getUser(); 33 | 34 | // Fetch posts 35 | const { data, error } = await supabase 36 | .from("drafts") 37 | .select(`*, categories(*)`) 38 | .order("created_at", { ascending: false }) 39 | .match({ author_id: user?.id }) 40 | .returns(); 41 | 42 | if (!data || error || !data.length) { 43 | notFound; 44 | } 45 | return ( 46 | <> 47 |
48 | {data?.length && data?.length > 0 ? ( 49 | <> 50 | 51 | 52 | 53 | ) : ( 54 | 55 | )} 56 | 57 |
58 | 59 | ); 60 | }; 61 | 62 | export default PostsPage; 63 | -------------------------------------------------------------------------------- /app/(protected)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ProtectedMain } from "@/components/protected/main"; 2 | import { createClient } from "@/utils/supabase/server"; 3 | import { cookies } from "next/headers"; 4 | import { redirect } from "next/navigation"; 5 | import React from "react"; 6 | 7 | interface ProtectedLayoutProps { 8 | children: React.ReactNode; 9 | } 10 | 11 | const ProtectedLayout: React.FC = async ({ 12 | children, 13 | }) => { 14 | const cookieStore = cookies(); 15 | const supabase = createClient(cookieStore); 16 | 17 | const { 18 | data: { session }, 19 | } = await supabase.auth.getSession(); 20 | if (!session?.user.id) { 21 | // This route can only be accessed by authenticated users. 22 | // Unauthenticated users will be redirected to the `/login` route. 23 | redirect("/login"); 24 | } 25 | 26 | return ( 27 | <> 28 | {children} 29 | 30 | ); 31 | }; 32 | 33 | export default ProtectedLayout; 34 | -------------------------------------------------------------------------------- /app/(protected)/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { SharedNotFound } from "@/components/shared"; 2 | import React from "react"; 3 | 4 | const NotFound = () => { 5 | return ; 6 | }; 7 | 8 | export default NotFound; 9 | -------------------------------------------------------------------------------- /app/(protected)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import ProtectedSettingsProfile from "@/components/protected/settings/protected-settings-profile"; 2 | import { Profile } from "@/types/collection"; 3 | import { createClient } from "@/utils/supabase/server"; 4 | import { cookies } from "next/headers"; 5 | import { notFound } from "next/navigation"; 6 | 7 | export const revalidate = 0; 8 | 9 | async function getUserId() { 10 | const cookieStore = cookies(); 11 | const supabase = createClient(cookieStore); 12 | const { 13 | data: { session }, 14 | error, 15 | } = await supabase.auth.getSession(); 16 | 17 | if (error) { 18 | console.log("Error has occured while getting UserId!"); 19 | console.log("Error message : ", error.message); 20 | return null; 21 | } 22 | 23 | return session ? session.user.id : null; 24 | } 25 | 26 | const SettingsPage = async () => { 27 | const cookieStore = cookies(); 28 | const supabase = createClient(cookieStore); 29 | 30 | const userId = await getUserId(); 31 | 32 | const { data, error } = await supabase 33 | .from("profiles") 34 | .select("*") 35 | .match({ id: userId }) 36 | .single(); 37 | 38 | if (error) { 39 | console.log(error); 40 | throw Error; 41 | } 42 | 43 | if (!data) { 44 | notFound; 45 | console.log("Cound't find User profile."); 46 | } 47 | 48 | return ( 49 |
50 | 51 |
52 | ); 53 | }; 54 | 55 | export default SettingsPage; 56 | -------------------------------------------------------------------------------- /app/api/contact/route.tsx: -------------------------------------------------------------------------------- 1 | import Email from "@/components/main/pages/email"; 2 | import { smtpEmail, transporter } from "@/lib/nodemailer"; 3 | import { render } from "@react-email/components"; 4 | import { NextRequest, NextResponse } from "next/server"; 5 | import * as z from "zod"; 6 | 7 | export async function POST(req: NextRequest, res: NextResponse) { 8 | const body = await req.json(); 9 | const { name, email, message } = body; 10 | 11 | const emailHtml = render( 12 | , 13 | ); 14 | 15 | const options = { 16 | from: smtpEmail, 17 | to: smtpEmail, 18 | subject: "New Form Submission", 19 | html: emailHtml, 20 | }; 21 | 22 | try { 23 | // Send email using the transporter 24 | const response = await transporter.sendMail(options); 25 | return new Response(null, { status: 200 }); 26 | } catch (error) { 27 | console.error("Failed to send email:", error); 28 | if (error instanceof z.ZodError) { 29 | return new Response(JSON.stringify(error.issues), { status: 422 }); 30 | } 31 | 32 | return new Response(null, { status: 500 }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/api/og/route.tsx: -------------------------------------------------------------------------------- 1 | import { SharedOgImage } from "@/components/shared"; 2 | import { ogImageSchema } from "@/lib/validation/og"; 3 | import { ImageResponse } from "next/og"; 4 | 5 | /* eslint-disable jsx-a11y/alt-text */ 6 | /* eslint-disable @next/next/no-img-element */ 7 | 8 | export const runtime = "edge"; 9 | 10 | const interBold = fetch( 11 | new URL("../../../public/fonts/Inter-Bold.ttf", import.meta.url), 12 | ).then((res) => res.arrayBuffer()); 13 | 14 | export async function GET(req: Request) { 15 | try { 16 | const { searchParams } = new URL(`${req.url}`); 17 | const fontBold = await interBold; 18 | 19 | const { title, subTitle, tags, slug } = ogImageSchema.parse({ 20 | title: searchParams.get("title"), 21 | subTitle: searchParams.get("subTitle"), 22 | tags: searchParams.getAll("tags"), 23 | slug: searchParams.get("slug"), 24 | }); 25 | 26 | return new ImageResponse( 27 | ( 28 | 34 | ), 35 | { 36 | width: 1200, 37 | height: 630, 38 | fonts: [ 39 | { 40 | name: "Inter", 41 | data: fontBold, 42 | weight: 700, 43 | style: "normal", 44 | }, 45 | ], 46 | }, 47 | ); 48 | } catch (error) { 49 | return new Response(`Failed to generate image`, { 50 | status: 500, 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/api/subscribe/route.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | import { emailSchema } from '@/lib/validation/contact'; 3 | 4 | const FORM_ID = process.env.CONVERTKIT_FORM_ID; 5 | const API_KEY = process.env.CONVERTKIT_API_KEY; 6 | const API_URL = process.env.CONVERTKIT_API_URL; 7 | 8 | export async function POST(req: Request, res: Response) { 9 | try { 10 | // Get the request body and validate it. 11 | const json = await req.json(); 12 | const body = emailSchema.parse(json); 13 | const email = body.email; 14 | 15 | //what do we want to send to CK? 16 | const data = { email, api_key: API_KEY }; 17 | 18 | // Update the post. 19 | // TODO: Implement sanitization for content. 20 | // ship it :) 21 | const response = await fetch(`${API_URL}forms/${FORM_ID}/subscribe`, { 22 | body: JSON.stringify(data), 23 | headers: { 'Content-Type': 'application/json' }, 24 | method: 'POST', 25 | }); 26 | 27 | return new Response(null, { status: 200 }); 28 | } catch (error) { 29 | if (error instanceof z.ZodError) { 30 | return new Response(JSON.stringify(error.issues), { status: 422 }); 31 | } 32 | 33 | return new Response(null, { status: 500 }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { getUrl } from "@/lib/utils"; 2 | import { createClient } from "@/utils/supabase/server"; 3 | import { cookies } from "next/headers"; 4 | import { NextResponse } from "next/server"; 5 | 6 | export async function GET(request: Request) { 7 | // The `/auth/callback` route is required for the server-side auth flow implemented 8 | // by the Auth Helpers package. It exchanges an auth code for the user's session. 9 | // https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-sign-in-with-code-exchange 10 | const requestUrl = new URL(request.url); 11 | const code = requestUrl.searchParams.get("code"); 12 | const redirectPath = requestUrl.searchParams.get("redirect"); 13 | const redirect = getUrl() + redirectPath; 14 | 15 | if (code) { 16 | const cookieStore = cookies(); 17 | const supabase = createClient(cookieStore); 18 | 19 | const { error } = await supabase.auth.exchangeCodeForSession(code); 20 | if (!error) { 21 | // URL to redirect to after sign in process completes 22 | return NextResponse.redirect(redirect ? redirect : requestUrl.origin); 23 | } 24 | } 25 | 26 | // return the user to an error page with instructions 27 | return NextResponse.redirect(new URL("/auth/auth-code-error", request.url)); 28 | } 29 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginHeader, LoginSection } from "@/components/login"; 2 | import { createClient } from "@/utils/supabase/server"; 3 | import { cookies } from "next/headers"; 4 | import { redirect } from "next/navigation"; 5 | 6 | const LoginPage = async () => { 7 | const cookieStore = cookies(); 8 | const supabase = createClient(cookieStore); 9 | 10 | const { 11 | data: { user }, 12 | } = await supabase.auth.getUser(); 13 | 14 | user && redirect("/editor/posts"); 15 | 16 | return ( 17 | <> 18 | {" "} 19 |
20 | 21 |
22 | 23 | ); 24 | }; 25 | 26 | export default LoginPage; 27 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { SharedNotFound } from "@/components/shared"; 2 | import React from "react"; 3 | 4 | const NotFound = () => { 5 | return ; 6 | }; 7 | 8 | export default NotFound; 9 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/styles/tailwind.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /components/detail/post/buttons/detail-post-comment-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { detailCommentConfig } from "@/config/detail"; 4 | import { MessageOutlineIcon, MessageSolidIcon } from "@/icons"; 5 | import React from "react"; 6 | import ScrollIntoView from "react-scroll-into-view"; 7 | 8 | interface DetailPostCommentButtonProps { 9 | totalComments?: number; 10 | } 11 | 12 | const DetailPostCommentButton: React.FC = ({ 13 | totalComments = 0, 14 | }) => { 15 | const [isHovering, setIsHovered] = React.useState(false); 16 | const onMouseEnter = () => setIsHovered(true); 17 | const onMouseLeave = () => setIsHovered(false); 18 | 19 | return ( 20 | 21 | 39 | 40 | ); 41 | }; 42 | 43 | export default DetailPostCommentButton; 44 | -------------------------------------------------------------------------------- /components/detail/post/buttons/detail-post-scroll-up-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import ScrollToTop from "react-scroll-to-top"; 5 | 6 | const DetailPostScrollUpButton = () => { 7 | return ( 8 | <> 9 |