├── .npmrc ├── src ├── app │ ├── globals.css │ ├── favicon.ico │ ├── providers.tsx │ ├── post │ │ ├── new │ │ │ └── page.tsx │ │ ├── edit │ │ │ └── [number] │ │ │ │ └── page.tsx │ │ └── [number] │ │ │ └── page.tsx │ ├── page.tsx │ ├── auth │ │ └── callback │ │ │ └── route.ts │ └── layout.tsx ├── utils │ ├── octokit.ts │ ├── post.ts │ └── auth.ts ├── types │ └── index.ts ├── components │ ├── Layout │ │ ├── Footer.tsx │ │ ├── ThemeSwitcher.tsx │ │ ├── NavbarWrapper.tsx │ │ ├── ToasterWrapper.tsx │ │ └── AvatarWrapper.tsx │ ├── Post │ │ ├── Title.tsx │ │ ├── CommentsSection │ │ │ ├── index.tsx │ │ │ ├── Comments.tsx │ │ │ └── CommentCreator.tsx │ │ ├── MarkdownWrapper.tsx │ │ └── Actions.tsx │ ├── PostEditor │ │ ├── Submit.tsx │ │ └── index.tsx │ ├── Posts.tsx │ └── Icons.tsx ├── actions │ ├── auth.ts │ ├── comment.ts │ └── post.ts └── hooks │ └── usePosts.ts ├── .prettierrc.json ├── next.config.mjs ├── postcss.config.js ├── environment.d.ts ├── .eslintrc.json ├── tests └── home.spec.ts ├── .gitignore ├── tsconfig.json ├── playwright.config.ts ├── .github └── workflows │ └── playwright.yml ├── LICENSE ├── tailwind.config.ts ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*@heroui/* 2 | node-linker=hoisted 3 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m4xshen/github-issue-blog/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "plugins": ["prettier-plugin-tailwindcss"] 4 | } 5 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/utils/octokit.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from 'octokit'; 2 | 3 | const octokit = new Octokit({ 4 | auth: process.env.GITHUB_TOKEN, 5 | }); 6 | 7 | export default octokit; 8 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { GetResponseDataTypeFromEndpointMethod } from '@octokit/types'; 2 | import octokit from '@/utils/octokit'; 3 | 4 | export type Issues = GetResponseDataTypeFromEndpointMethod< 5 | typeof octokit.rest.issues.listForRepo 6 | >; 7 | 8 | export type User = GetResponseDataTypeFromEndpointMethod< 9 | typeof octokit.rest.users.getAuthenticated 10 | >; 11 | -------------------------------------------------------------------------------- /src/components/Layout/Footer.tsx: -------------------------------------------------------------------------------- 1 | export default function Footer() { 2 | return ( 3 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /environment.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | GITHUB_CLIENT_ID: string; 5 | GITHUB_CLIENT_SECRET: string; 6 | GITHUB_TOKEN: string; 7 | AUTHOR_NAME: string; 8 | BLOG_TITLE: string; 9 | BLOG_DESCRIPTION: string; 10 | NEXT_PUBLIC_OWNER: string; 11 | NEXT_PUBLIC_REPO: string; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb", 4 | "airbnb-typescript", 5 | "airbnb/hooks", 6 | "next/core-web-vitals", 7 | "prettier" 8 | ], 9 | "parserOptions": { 10 | "project": "./tsconfig.json" 11 | }, 12 | "rules": { 13 | "import/no-extraneous-dependencies": "off", 14 | "no-console": "off", 15 | "consistent-return": "off", 16 | "react/jsx-no-bind": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Post/Title.tsx: -------------------------------------------------------------------------------- 1 | export default function Title({ 2 | title, 3 | createdAt, 4 | }: { 5 | title: string; 6 | createdAt: string; 7 | }) { 8 | return ( 9 |
10 |

{title}

11 |
12 | {new Date(createdAt).toDateString()} 13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ReactNode } from 'react'; 4 | import { ThemeProvider as NextThemesProvider } from 'next-themes'; 5 | import { HeroUIProvider } from "@heroui/react"; 6 | 7 | export default function Providers({ children }: { children: ReactNode }) { 8 | return ( 9 | 10 | 11 | {children} 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/post/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import PostEditor from '@/components/PostEditor/index'; 3 | import { createPost } from '@/actions/post'; 4 | import { isAuthor } from '@/utils/auth'; 5 | 6 | export default async function NewPost() { 7 | if (!(await isAuthor())) { 8 | redirect('/'); 9 | } 10 | 11 | return ( 12 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Post/CommentsSection/index.tsx: -------------------------------------------------------------------------------- 1 | import { getUser } from '@/utils/auth'; 2 | import CommentCreator from './CommentCreator'; 3 | import Comments from './Comments'; 4 | 5 | export default async function CommentsSection({ number }: { number: number }) { 6 | const user = await getUser(); 7 | 8 | return ( 9 | <> 10 |

Comments

11 |
12 | 13 | 14 |
15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/actions/auth.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { cookies } from 'next/headers'; 4 | import { redirect } from 'next/navigation'; 5 | 6 | export async function login(pathname: string) { 7 | const url = new URL('https://github.com/login/oauth/authorize'); 8 | url.searchParams.append('client_id', process.env.GITHUB_CLIENT_ID); 9 | url.searchParams.append('scope', 'repo'); 10 | url.searchParams.append('state', pathname); 11 | 12 | redirect(url.toString()); 13 | } 14 | 15 | export async function logOut() { 16 | cookies().delete('access_token'); 17 | } 18 | -------------------------------------------------------------------------------- /tests/home.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('have site title', async ({ page }) => { 4 | await page.goto('/'); 5 | await expect(page).toHaveTitle(/Daniel's Blog/); 6 | }); 7 | 8 | test('link to detail page with same title', async ({ page }) => { 9 | await page.goto('/'); 10 | const firstPost = page.locator('a >> h1').first(); 11 | const postTitleHome = await firstPost.innerText(); 12 | 13 | await firstPost.click(); 14 | const postTitleDetail = await page.locator('h1').first().innerText(); 15 | 16 | expect(postTitleHome).toEqual(postTitleDetail); 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/PostEditor/Submit.tsx: -------------------------------------------------------------------------------- 1 | import { useFormStatus } from 'react-dom'; 2 | import { Button } from "@heroui/button"; 3 | import { ReactNode } from 'react'; 4 | 5 | export default function Submit({ 6 | children, 7 | isInvalid, 8 | }: { 9 | children: ReactNode; 10 | isInvalid: boolean; 11 | }) { 12 | const { pending } = useFormStatus(); 13 | return ( 14 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | /test-results/ 38 | /playwright-report/ 39 | /blob-report/ 40 | /playwright/.cache/ 41 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { Button } from "@heroui/react"; 3 | import Posts from '@/components/Posts'; 4 | import { getPosts } from '@/utils/post'; 5 | import { isAuthor } from '@/utils/auth'; 6 | 7 | export default async function Home() { 8 | const data = await getPosts(1); 9 | 10 | return ( 11 |
12 | {(await isAuthor()) ? ( 13 | 16 | ) : null} 17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/app/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import { NextResponse } from 'next/server'; 3 | import { exchangeCodeForAccessToken } from '@/utils/auth'; 4 | 5 | export async function GET(request: Request) { 6 | const { searchParams, origin } = new URL(request.url); 7 | const code = searchParams.get('code'); 8 | const pathname = searchParams.get('state'); 9 | 10 | if (code) { 11 | const { error } = await exchangeCodeForAccessToken(code); 12 | if (!error) { 13 | return NextResponse.redirect(`${origin}${pathname}`); 14 | } 15 | } 16 | 17 | return NextResponse.redirect( 18 | `${origin}${pathname}?error=Access denied. Please try again.`, 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/post/edit/[number]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import PostEditor from '@/components/PostEditor/index'; 3 | import { updatePost } from '@/actions/post'; 4 | import { getPost } from '@/utils/post'; 5 | import { isAuthor } from '@/utils/auth'; 6 | 7 | export default async function EditPost({ 8 | params, 9 | }: { 10 | params: { number: string }; 11 | }) { 12 | const number = parseInt(params.number, 10); 13 | const post = await getPost(number); 14 | 15 | if (!(await isAuthor()) || !number) { 16 | redirect('/'); 17 | } 18 | 19 | return ( 20 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/hooks/usePosts.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { getPosts } from '@/utils/post'; 3 | import { Issues } from '@/types'; 4 | 5 | const perPage = 10; 6 | 7 | export default function usePosts(initPosts: Issues = []) { 8 | const [posts, setPosts] = useState(initPosts); 9 | const [page, setPage] = useState(initPosts.length < perPage ? 1 : 2); 10 | const [noMorePosts, setNoMorePosts] = useState(initPosts.length < perPage); 11 | 12 | async function loadMore() { 13 | const morePosts = await getPosts(page); 14 | setPosts([...posts, ...morePosts]); 15 | setPage(page + 1); 16 | 17 | if (morePosts.length < perPage) { 18 | setNoMorePosts(true); 19 | } 20 | } 21 | 22 | return { 23 | posts, 24 | noMorePosts, 25 | loadMore, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Layout/ThemeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | import { useTheme } from 'next-themes'; 5 | import { Skeleton } from "@heroui/react"; 6 | import { Moon, Sun } from '../Icons'; 7 | 8 | export default function ThemeSwitcher() { 9 | const [mounted, setMounted] = useState(false); 10 | const { theme, setTheme } = useTheme(); 11 | 12 | useEffect(() => { 13 | setMounted(true); 14 | }, []); 15 | 16 | if (!mounted) return ; 17 | 18 | return ( 19 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | testDir: './tests', 5 | fullyParallel: true, 6 | forbidOnly: !!process.env.CI, 7 | retries: process.env.CI ? 2 : 0, 8 | workers: process.env.CI ? 1 : undefined, 9 | reporter: 'html', 10 | use: { 11 | baseURL: process.env.CI 12 | ? 'https://github-issue-blog.vercel.app/' 13 | : 'http://127.0.0.1:3000', 14 | trace: 'on-first-retry', 15 | }, 16 | projects: [ 17 | { 18 | name: 'chromium', 19 | use: { ...devices['Desktop Chrome'] }, 20 | }, 21 | { 22 | name: 'firefox', 23 | use: { ...devices['Desktop Firefox'] }, 24 | }, 25 | ], 26 | webServer: { 27 | command: 'yarn run dev', 28 | url: 'http://127.0.0.1:3000', 29 | reuseExistingServer: !process.env.CI, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [ main, master ] 5 | pull_request: 6 | branches: [ main, master ] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: lts/* 17 | 18 | - name: Install pnpm 19 | run: npm install -g pnpm 20 | 21 | - name: Install dependencies 22 | run: pnpm i 23 | 24 | - name: Install Playwright Browsers 25 | run: pnpm exec playwright install 26 | 27 | - name: Run Playwright tests 28 | run: pnpm test 29 | 30 | - uses: actions/upload-artifact@v4 31 | if: always() 32 | with: 33 | name: playwright-report 34 | path: playwright-report/ 35 | retention-days: 30 36 | -------------------------------------------------------------------------------- /src/actions/comment.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { revalidatePath } from 'next/cache'; 4 | import { cookies } from 'next/headers'; 5 | import { Octokit } from 'octokit'; 6 | 7 | const owner = process.env.NEXT_PUBLIC_OWNER; 8 | const repo = process.env.NEXT_PUBLIC_REPO; 9 | 10 | // eslint-disable-next-line import/prefer-default-export 11 | export async function createComment(issue_number: number, formData: FormData) { 12 | const accessToken = cookies().get('access_token')?.value; 13 | if (!accessToken) { 14 | return null; 15 | } 16 | 17 | const body = formData.get('body') as string; 18 | 19 | const userOctokit = new Octokit({ auth: accessToken }); 20 | try { 21 | await userOctokit.rest.issues.createComment({ 22 | owner, 23 | repo, 24 | issue_number, 25 | body, 26 | }); 27 | 28 | revalidatePath(`/post/${issue_number}`); 29 | } catch (error) { 30 | console.error(error); 31 | return error; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Layout/NavbarWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@heroui/link"; 2 | import { 3 | Navbar, 4 | NavbarBrand, 5 | NavbarContent, 6 | NavbarItem, 7 | } from "@heroui/navbar"; 8 | import { getUser } from '@/utils/auth'; 9 | import AvatarWrapper from './AvatarWrapper'; 10 | import ThemeSwitcher from './ThemeSwitcher'; 11 | 12 | export default async function NavbarWrapper() { 13 | const user = await getUser(); 14 | 15 | return ( 16 | 21 | 22 | 23 | {process.env.BLOG_TITLE} 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Posts.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { InView } from 'react-intersection-observer'; 4 | import { Link } from "@heroui/link"; 5 | import { Spinner } from "@heroui/spinner"; 6 | import Title from '@/components/Post/Title'; 7 | import usePosts from '@/hooks/usePosts'; 8 | import { Issues } from '@/types'; 9 | 10 | export default function Posts({ data }: { data: Issues }) { 11 | const { posts, noMorePosts, loadMore } = usePosts(data); 12 | 13 | return ( 14 |
15 | {posts.map((post) => ( 16 | 17 | 18 | </Link> 19 | ))} 20 | <InView 21 | onChange={(inView: boolean) => { 22 | if (!inView) { 23 | return; 24 | } 25 | 26 | loadMore(); 27 | }} 28 | > 29 | {({ ref }) => 30 | noMorePosts ? null : ( 31 | <Spinner ref={ref} color="primary" role="status" /> 32 | ) 33 | } 34 | </InView> 35 | </div> 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Max Shen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/Post/CommentsSection/Comments.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Link } from "@heroui/react"; 2 | import { getComments } from '@/utils/post'; 3 | import MarkdownWrapper from '../MarkdownWrapper'; 4 | 5 | export default async function Comments({ number }: { number: number }) { 6 | const comments = await getComments(number); 7 | 8 | if (!comments.length) { 9 | return <div>There are no comments yet.</div>; 10 | } 11 | 12 | return comments.map((comment) => ( 13 | <div key={comment.id} className="flex gap-3"> 14 | <Avatar src={comment.user?.avatar_url} className="flex-shrink-0" /> 15 | <div className="grid w-full gap-3"> 16 | <div className="flex items-center gap-3"> 17 | <Link href={comment.user?.html_url}>@{comment.user?.login}</Link> 18 | <span className="text-sm"> 19 | {new Date(comment.created_at).toDateString()} 20 | </span> 21 | </div> 22 | <div className="prose rounded-xl border border-secondary p-5 dark:prose-invert"> 23 | <MarkdownWrapper>{comment.body}</MarkdownWrapper> 24 | </div> 25 | </div> 26 | </div> 27 | )); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Post/MarkdownWrapper.tsx: -------------------------------------------------------------------------------- 1 | import Markdown from 'react-markdown'; 2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 3 | import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; 4 | 5 | export default async function MarkdownWrapper({ 6 | children, 7 | }: { 8 | children: string | null | undefined; 9 | }) { 10 | return ( 11 | <Markdown 12 | components={{ 13 | // eslint-disable-next-line react/no-unstable-nested-components 14 | code(props) { 15 | const { children: c, className } = props; 16 | const match = /language-(\w+)/.exec(className || ''); 17 | return match ? ( 18 | <div className="max-w-[80vw]"> 19 | <SyntaxHighlighter 20 | PreTag="div" 21 | language={match[1]} 22 | style={oneDark} 23 | > 24 | {String(c).replace(/\n$/, '')} 25 | </SyntaxHighlighter> 26 | </div> 27 | ) : ( 28 | <code className={className}>{c}</code> 29 | ); 30 | }, 31 | }} 32 | > 33 | {children} 34 | </Markdown> 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import type { Metadata } from 'next'; 3 | import { Inter } from 'next/font/google'; 4 | import ToasterWrapper from '@/components/Layout/ToasterWrapper'; 5 | import NavbarWrapper from '@/components/Layout/NavbarWrapper'; 6 | import Footer from '@/components/Layout/Footer'; 7 | import Providers from './providers'; 8 | import './globals.css'; 9 | 10 | const inter = Inter({ subsets: ['latin'] }); 11 | 12 | export const metadata: Metadata = { 13 | title: process.env.BLOG_TITLE, 14 | description: process.env.BLOG_DESCRIPTION, 15 | }; 16 | 17 | export default function RootLayout({ 18 | children, 19 | }: Readonly<{ 20 | children: React.ReactNode; 21 | }>) { 22 | return ( 23 | <html lang="en" suppressHydrationWarning> 24 | <body 25 | className={`${inter.className} flex min-h-screen flex-col bg-background scrollbar-hide`} 26 | > 27 | <Providers> 28 | <Suspense> 29 | <ToasterWrapper /> 30 | </Suspense> 31 | <NavbarWrapper /> 32 | <div className="px-5 py-10">{children}</div> 33 | </Providers> 34 | <Footer /> 35 | </body> 36 | </html> 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/Layout/ToasterWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | import { usePathname, useRouter, useSearchParams } from 'next/navigation'; 5 | import { useTheme } from 'next-themes'; 6 | import { Toaster, toast } from 'sonner'; 7 | 8 | export default function ToasterWrapper() { 9 | const { theme } = useTheme(); 10 | const searchParams = useSearchParams(); 11 | const router = useRouter(); 12 | const pathname = usePathname(); 13 | 14 | useEffect(() => { 15 | const error = searchParams.get('error'); 16 | const success = searchParams.get('success'); 17 | 18 | if (error) { 19 | toast.error(error); 20 | router.replace(pathname); 21 | } else if (success) { 22 | toast.success(success); 23 | router.replace(pathname); 24 | } 25 | }, [searchParams]); 26 | 27 | return ( 28 | <Toaster 29 | position="bottom-left" 30 | toastOptions={{ 31 | style: { 32 | background: theme === 'dark' ? '#171717' : '#ffffff', 33 | borderColor: theme === 'dark' ? '#ffffff30' : '#17171730', 34 | color: theme === 'dark' ? '#ffffff' : '#171717', 35 | }, 36 | }} 37 | /> 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/post.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import octokit from './octokit'; 3 | 4 | const owner = process.env.NEXT_PUBLIC_OWNER; 5 | const repo = process.env.NEXT_PUBLIC_REPO; 6 | 7 | export async function getPosts(page: number) { 8 | try { 9 | const { data } = await octokit.rest.issues.listForRepo({ 10 | owner, 11 | repo, 12 | per_page: 10, 13 | page, 14 | labels: 'blog', 15 | }); 16 | 17 | return data; 18 | } catch (error) { 19 | console.error(error); 20 | return []; 21 | } 22 | } 23 | 24 | export async function getPost(issue_number: number) { 25 | try { 26 | const { data } = await octokit.rest.issues.get({ 27 | owner, 28 | repo, 29 | issue_number, 30 | }); 31 | 32 | if (data.state === 'closed') { 33 | throw new Error('Post is deleted'); 34 | } 35 | 36 | return data; 37 | } catch (error) { 38 | console.error(error); 39 | redirect('/'); 40 | } 41 | } 42 | 43 | export async function getComments(issue_number: number) { 44 | try { 45 | const { data } = await octokit.rest.issues.listComments({ 46 | owner, 47 | repo, 48 | issue_number, 49 | }); 50 | 51 | return data; 52 | } catch (error) { 53 | console.error(error); 54 | return []; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/app/post/[number]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | import CommentsSection from '@/components/Post/CommentsSection'; 3 | import Actions from '@/components/Post/Actions'; 4 | import Title from '@/components/Post/Title'; 5 | import MarkdownWrapper from '@/components/Post/MarkdownWrapper'; 6 | import { getPost } from '@/utils/post'; 7 | import { isAuthor } from '@/utils/auth'; 8 | 9 | export async function generateMetadata({ 10 | params, 11 | }: { 12 | params: { number: string }; 13 | }): Promise<Metadata> { 14 | const number = parseInt(params.number, 10); 15 | const post = await getPost(number); 16 | 17 | return { 18 | title: `${post.title} | ${process.env.BLOG_TITLE}`, 19 | }; 20 | } 21 | 22 | export default async function Post({ params }: { params: { number: string } }) { 23 | const number = parseInt(params.number, 10); 24 | const post = await getPost(number); 25 | 26 | return ( 27 | <div className="mx-auto grid max-w-[65ch] gap-6"> 28 | <Title title={post.title} createdAt={post.created_at} /> 29 | {(await isAuthor()) ? <Actions number={number} /> : null} 30 | <div className="prose dark:prose-invert prose-pre:bg-[#282c34]"> 31 | <MarkdownWrapper>{post.body}</MarkdownWrapper> 32 | <hr /> 33 | <CommentsSection number={number} /> 34 | </div> 35 | </div> 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Post/CommentsSection/CommentCreator.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createComment } from '@/actions/comment'; 4 | import Submit from '@/components/PostEditor/Submit'; 5 | import { User } from '@/types'; 6 | import { Textarea } from '@heroui/react'; 7 | import { useState } from 'react'; 8 | 9 | export default function CommentCreator({ 10 | number, 11 | user, 12 | }: { 13 | number: number; 14 | user: User | null; 15 | }) { 16 | const [body, setBody] = useState(''); 17 | const bodyIsInvalid = body === ''; 18 | 19 | return ( 20 | <form 21 | action={async (formData) => { 22 | await createComment(number, formData); 23 | setBody(''); 24 | }} 25 | className="flex flex-col gap-3" 26 | > 27 | <Textarea 28 | value={body} 29 | onValueChange={setBody} 30 | isDisabled={!user} 31 | name="body" 32 | radius="sm" 33 | size="lg" 34 | placeholder={user ? 'Write your comment here.' : 'Log in to comment.'} 35 | classNames={{ 36 | inputWrapper: 37 | 'border border-secondary data-[hover=true]:bg-background group-data-[focus=true]:bg-background bg-background', 38 | input: '!text-primary', 39 | }} 40 | /> 41 | <Submit isInvalid={bodyIsInvalid}>Comment</Submit> 42 | </form> 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | import { heroui } from "@heroui/react"; 3 | 4 | const config: Config = { 5 | content: [ 6 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 8 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 9 | "./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}", 10 | ], 11 | darkMode: 'class', 12 | plugins: [ 13 | require('@tailwindcss/typography'), 14 | heroui({ 15 | themes: { 16 | light: { 17 | colors: { 18 | background: '#ffffff', 19 | primary: { 20 | DEFAULT: '#171717', 21 | 500: '#171717a0', 22 | foreground: '#ffffff', 23 | }, 24 | secondary: { 25 | DEFAULT: '#17171730', 26 | }, 27 | focus: '#171717', 28 | }, 29 | }, 30 | dark: { 31 | colors: { 32 | background: '#171717', 33 | primary: { 34 | DEFAULT: '#ffffff', 35 | 500: '#ffffffa0', 36 | foreground: '#171717', 37 | }, 38 | secondary: { 39 | DEFAULT: '#ffffff30', 40 | }, 41 | focus: '#ffffff', 42 | }, 43 | }, 44 | }, 45 | }), 46 | ], 47 | }; 48 | export default config; 49 | -------------------------------------------------------------------------------- /src/components/Icons.tsx: -------------------------------------------------------------------------------- 1 | export function Sun() { 2 | return '☀️'; 3 | } 4 | 5 | export function Moon() { 6 | return '🌙'; 7 | } 8 | 9 | export function GitHub() { 10 | return ( 11 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" width="20"> 12 | <path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" /> 13 | </svg> 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-issue-blog", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "playwright test" 11 | }, 12 | "dependencies": { 13 | "@heroui/react": "2.6.14", 14 | "framer-motion": "^11.0.3", 15 | "next": "14.1.0", 16 | "next-themes": "^0.3.0", 17 | "octokit": "^3.1.2", 18 | "react": "^18", 19 | "react-dom": "^18", 20 | "react-intersection-observer": "^9.8.1", 21 | "react-markdown": "^9.0.1", 22 | "react-syntax-highlighter": "^15.5.0", 23 | "sonner": "^1.4.3" 24 | }, 25 | "devDependencies": { 26 | "@playwright/test": "^1.42.1", 27 | "@tailwindcss/typography": "^0.5.10", 28 | "@types/node": "^20", 29 | "@types/react": "^18", 30 | "@types/react-dom": "^18", 31 | "@types/react-syntax-highlighter": "^15.5.11", 32 | "@typescript-eslint/eslint-plugin": "^6.0.0", 33 | "@typescript-eslint/parser": "^6.0.0", 34 | "autoprefixer": "^10.0.1", 35 | "eslint": "^8.2.0", 36 | "eslint-config-airbnb": "19.0.4", 37 | "eslint-config-airbnb-typescript": "^17.1.0", 38 | "eslint-config-next": "14.1.0", 39 | "eslint-config-prettier": "^9.1.0", 40 | "eslint-plugin-import": "^2.25.3", 41 | "eslint-plugin-jsx-a11y": "^6.5.1", 42 | "eslint-plugin-react": "^7.28.0", 43 | "eslint-plugin-react-hooks": "^4.3.0", 44 | "postcss": "^8", 45 | "prettier": "^3.2.5", 46 | "prettier-plugin-tailwindcss": "^0.5.11", 47 | "tailwindcss": "^3.3.0", 48 | "typescript": "^5" 49 | } 50 | } -------------------------------------------------------------------------------- /src/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { Octokit } from 'octokit'; 3 | 4 | export async function exchangeCodeForAccessToken(code: string) { 5 | const cookieStore = cookies(); 6 | const url = new URL('https://github.com/login/oauth/access_token'); 7 | url.searchParams.append('client_id', process.env.GITHUB_CLIENT_ID); 8 | url.searchParams.append('client_secret', process.env.GITHUB_CLIENT_SECRET); 9 | url.searchParams.append('code', code); 10 | 11 | try { 12 | const response = await fetch(url.href, { 13 | method: 'POST', 14 | headers: { 15 | Accept: 'application/json', 16 | }, 17 | }); 18 | 19 | if (!response.ok) { 20 | throw new Error(response.statusText); 21 | } 22 | 23 | // eslint-disable-next-line @typescript-eslint/naming-convention 24 | const { access_token } = await response.json(); 25 | cookieStore.set('access_token', access_token); 26 | } catch (error) { 27 | console.error('error', error); 28 | return { error }; 29 | } 30 | return { error: null }; 31 | } 32 | 33 | export async function getUser() { 34 | const accessToken = cookies().get('access_token')?.value; 35 | if (!accessToken) { 36 | return null; 37 | } 38 | 39 | const userOctokit = new Octokit({ auth: accessToken }); 40 | try { 41 | const { data } = await userOctokit.rest.users.getAuthenticated(); 42 | return data; 43 | } catch (error) { 44 | console.error(error); 45 | return null; 46 | } 47 | } 48 | 49 | export async function isAuthor() { 50 | const user = await getUser(); 51 | return user?.login === process.env.NEXT_PUBLIC_OWNER; 52 | } 53 | -------------------------------------------------------------------------------- /src/components/PostEditor/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import { Textarea, Input } from "@heroui/input"; 5 | import Submit from './Submit'; 6 | 7 | export default function PostEditor({ 8 | initTitle, 9 | initBody, 10 | actionName, 11 | action, 12 | }: { 13 | initTitle: string; 14 | initBody: string; 15 | actionName: string; 16 | action: (formData: FormData) => Promise<any>; 17 | }) { 18 | const [title, setTitle] = useState(initTitle); 19 | const [body, setBody] = useState(initBody); 20 | 21 | const titleIsInvalid = title === ''; 22 | const bodyIsInvalid = body.length < 30; 23 | 24 | return ( 25 | <form 26 | className="prose prose-invert mx-auto flex flex-col gap-6" 27 | action={action} 28 | > 29 | <Input 30 | name="title" 31 | value={title} 32 | onValueChange={setTitle} 33 | isInvalid={titleIsInvalid} 34 | errorMessage={titleIsInvalid && 'Title is required.'} 35 | placeholder="New post title here..." 36 | classNames={{ 37 | inputWrapper: 38 | 'data-[hover=true]:bg-transparent group-data-[focus=true]:bg-transparent bg-transparent outline-none p-0', 39 | input: '!text-primary font-bold text-3xl', 40 | }} 41 | /> 42 | <Textarea 43 | value={body} 44 | onValueChange={setBody} 45 | isInvalid={bodyIsInvalid} 46 | errorMessage={ 47 | bodyIsInvalid && 'Content must be at least 30 characters.' 48 | } 49 | name="body" 50 | radius="sm" 51 | size="lg" 52 | placeholder="Write your content here." 53 | classNames={{ 54 | inputWrapper: 55 | 'border border-secondary data-[hover=true]:bg-background group-data-[focus=true]:bg-background bg-background', 56 | input: '!text-primary', 57 | }} 58 | /> 59 | <Submit isInvalid={titleIsInvalid || bodyIsInvalid}>{actionName}</Submit> 60 | </form> 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/Layout/AvatarWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTransition } from 'react'; 4 | import { usePathname } from 'next/navigation'; 5 | import { Avatar } from "@heroui/avatar"; 6 | import { Button } from "@heroui/button"; 7 | import { 8 | Dropdown, 9 | DropdownTrigger, 10 | DropdownMenu, 11 | DropdownItem, 12 | } from "@heroui/dropdown"; 13 | import { logOut, login } from '@/actions/auth'; 14 | import { User } from '@/types'; 15 | import { GitHub } from '../Icons'; 16 | 17 | export default function AvatarWrapper({ user }: { user: User | null }) { 18 | const [isLoading, startTransition] = useTransition(); 19 | const pathname = usePathname(); 20 | 21 | if (!user) { 22 | return ( 23 | <Button 24 | size="sm" 25 | radius="sm" 26 | color="primary" 27 | isLoading={isLoading} 28 | className="flex items-center fill-background" 29 | onPress={() => { 30 | startTransition(async () => { 31 | await login(pathname); 32 | }); 33 | }} 34 | > 35 | {isLoading ? null : <GitHub />} 36 | Login 37 | </Button> 38 | ); 39 | } 40 | 41 | return ( 42 | <Dropdown radius="sm" placement="bottom-end"> 43 | <DropdownTrigger> 44 | <Avatar 45 | isBordered 46 | as="button" 47 | className="transition-transform" 48 | name={user.name ?? undefined} 49 | size="sm" 50 | src={user.avatar_url} 51 | /> 52 | </DropdownTrigger> 53 | <DropdownMenu aria-label="Profile Actions" variant="flat"> 54 | <DropdownItem 55 | key="profile" 56 | className="h-14 gap-2" 57 | textValue={`Signed in as ${user.name}`} 58 | > 59 | <p className="font-semibold">Signed in as</p> 60 | <p className="font-semibold">{user.name}</p> 61 | </DropdownItem> 62 | <DropdownItem 63 | key="logout" 64 | color="danger" 65 | onPress={async () => { 66 | await logOut(); 67 | }} 68 | > 69 | Log Out 70 | </DropdownItem> 71 | </DropdownMenu> 72 | </Dropdown> 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <div align="center"> 2 | <h1>GitHub Issue Blog</h1> 3 | 4 | Use GitHub issue as your blog. 5 | 6 | ![screenshot](https://github.com/user-attachments/assets/4ec02823-dfd4-41d6-aa24-bc37f303cfd1) 7 | 8 | 9 | [🌐 Example Site](https://github-issue-blog.vercel.app) 10 | 11 | [![Playwright Tests](https://github.com/m4xshen/github-issue-blog/actions/workflows/playwright.yml/badge.svg)](https://github.com/m4xshen/github-issue-blog/actions/workflows/playwright.yml) 12 | ![Vercel](https://therealsujitk-vercel-badge.vercel.app/?app=github-issue-blog) 13 | 14 | 15 | </div> 16 | 17 | 18 | ## ✨ Features 19 | 20 | - 🐱 Use GitHub issues as your blog storage 21 | - 💬 Comment Section 22 | - 📝 Create / Edit / Delete posts 23 | - 🌓 Light / Dark theme 24 | - 📱 RWD 25 | - 🧑‍💻 Syntax Highlighting 26 | - ♾️ Infinite scroll at home page 27 | - 🔍 SEO Friendly 28 | 29 | ![lighthouse](https://github.com/m4xshen/github-issues-blog/assets/74842863/84c19d65-90f4-45e3-8100-ef81b60ad089) 30 | 31 | ## 🚀 Get started 32 | 33 | 1. Fork this repository 34 | 2. [Create a GitHub OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) with the callback URL: `your-site-domain/auth/callback` 35 | 3. Create a personal access token. 36 | 4. You can customize the blog with environment variables. Here's an example: 37 | 38 | ``` 39 | GITHUB_CLIENT_ID="your oauth app client id" 40 | GITHUB_CLIENT_SECRET="your oauth app client secret" 41 | GITHUB_TOKEN="your personal access token" 42 | AUTHOR_NAME="Daniel" 43 | BLOG_TITLE="Daniel's Blog" 44 | BLOG_DESCRIPTION="Hi, I'm Daniel, a software engineer from Taiwan. Welcome to my blog!" 45 | NEXT_PUBLIC_OWNER="m4xshen" (your GitHub username) 46 | NEXT_PUBLIC_REPO="github-issue-blog" (the GitHub repository name that you want to store posts in) 47 | ``` 48 | 49 | If you plan to deploy your site... 50 | 51 | - with Vercel: [add environment variables in settings](https://vercel.com/docs/projects/environment-variables) 52 | - by yourself: copy above content to `.env.local` 53 | 54 | 5. Deploy the site and login to start blogging! 55 | 56 | - with Vercel: [follow the docs](https://vercel.com/docs/deployments/overview) 57 | - by yourself: `pnpm build && pnpm start` and check out http://localhost:3000 58 | 59 | ## ⭐ Star history 60 | 61 | [![Star History Chart](https://app.repohistory.com/api/svg?repo=m4xshen/github-issue-blog&type=Date&background=0D1117&color=FCE2C6)](https://app.repohistory.com/star-history) 62 | -------------------------------------------------------------------------------- /src/actions/post.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { redirect } from 'next/navigation'; 4 | import { cookies } from 'next/headers'; 5 | import { revalidatePath } from 'next/cache'; 6 | import { Octokit } from 'octokit'; 7 | 8 | const owner = process.env.NEXT_PUBLIC_OWNER; 9 | const repo = process.env.NEXT_PUBLIC_REPO; 10 | 11 | export async function createPost(formData: FormData) { 12 | const accessToken = cookies().get('access_token')?.value; 13 | if (!accessToken) { 14 | return null; 15 | } 16 | 17 | const title = formData.get('title') as string; 18 | const body = formData.get('body') as string; 19 | 20 | const userOctokit = new Octokit({ auth: accessToken }); 21 | let issueNumber = null; 22 | 23 | try { 24 | const { data } = await userOctokit.rest.issues.create({ 25 | owner, 26 | repo, 27 | title, 28 | body, 29 | labels: ['blog'], 30 | }); 31 | issueNumber = data.number; 32 | } catch (error) { 33 | console.error(error); 34 | return error; 35 | } 36 | 37 | redirect(`/post/${issueNumber}?success=Post created successfully`); 38 | } 39 | 40 | export async function updatePost(issue_number: number, formData: FormData) { 41 | const accessToken = cookies().get('access_token')?.value; 42 | if (!accessToken) { 43 | return null; 44 | } 45 | 46 | const title = formData.get('title') as string; 47 | const body = formData.get('body') as string; 48 | 49 | const userOctokit = new Octokit({ auth: accessToken }); 50 | try { 51 | await userOctokit.rest.issues.update({ 52 | owner, 53 | repo, 54 | issue_number, 55 | title, 56 | body, 57 | }); 58 | } catch (error) { 59 | console.error(error); 60 | return error; 61 | } 62 | 63 | revalidatePath('/post/edit'); 64 | revalidatePath(`/post/${issue_number}`); 65 | redirect(`/post/${issue_number}?success=Post updated successfully`); 66 | } 67 | 68 | export async function deletePost(issue_number: number) { 69 | const accessToken = cookies().get('access_token')?.value; 70 | if (!accessToken) { 71 | return null; 72 | } 73 | 74 | const userOctokit = new Octokit({ auth: accessToken }); 75 | try { 76 | await userOctokit.rest.issues.update({ 77 | owner, 78 | repo, 79 | issue_number, 80 | state: 'closed', 81 | }); 82 | } catch (error) { 83 | console.error(error); 84 | return error; 85 | } 86 | 87 | redirect('/?success=Post deleted successfully'); 88 | } 89 | -------------------------------------------------------------------------------- /src/components/Post/Actions.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTransition } from 'react'; 4 | import Link from 'next/link'; 5 | import { Button } from "@heroui/button"; 6 | import { 7 | Modal, 8 | ModalBody, 9 | ModalContent, 10 | ModalFooter, 11 | ModalHeader, 12 | useDisclosure, 13 | } from "@heroui/modal"; 14 | import { toast } from 'sonner'; 15 | import { deletePost } from '@/actions/post'; 16 | 17 | export default function Actions({ number }: { number: number }) { 18 | const [isLoading, startTransition] = useTransition(); 19 | const { isOpen, onOpen, onOpenChange } = useDisclosure(); 20 | 21 | return ( 22 | <div className="flex items-center gap-5"> 23 | <Button 24 | as={Link} 25 | radius="sm" 26 | color="primary" 27 | href={`/post/edit/${number}`} 28 | className="no-underline" 29 | > 30 | Edit 31 | </Button> 32 | <Button radius="sm" color="primary" onPress={onOpen}> 33 | Delete 34 | </Button> 35 | <Modal 36 | isOpen={isOpen} 37 | onOpenChange={onOpenChange} 38 | className="bg-background" 39 | > 40 | <ModalContent> 41 | {(onClose) => ( 42 | <> 43 | <ModalHeader>Delete post</ModalHeader> 44 | <ModalBody> 45 | Are you sure you want to delete this post? This action cannot be 46 | undone. 47 | </ModalBody> 48 | <ModalFooter> 49 | <Button 50 | radius="sm" 51 | color="primary" 52 | variant="light" 53 | onPress={onClose} 54 | > 55 | Cancel 56 | </Button> 57 | <Button 58 | radius="sm" 59 | color="danger" 60 | isLoading={isLoading} 61 | onPress={() => { 62 | startTransition(async () => { 63 | const error = await deletePost(number); 64 | if (error) { 65 | toast('Error deleting post.'); 66 | } 67 | onClose(); 68 | }); 69 | }} 70 | > 71 | Delete 72 | </Button> 73 | </ModalFooter> 74 | </> 75 | )} 76 | </ModalContent> 77 | </Modal> 78 | </div> 79 | ); 80 | } 81 | --------------------------------------------------------------------------------