├── .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 |
22 | {children}
23 |
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 |
14 | New Post
15 |
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 | {
23 | setTheme(theme === 'dark' ? 'light' : 'dark');
24 | }}
25 | >
26 | {theme === 'dark' ? : }
27 |
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 |
19 | ))}
20 | {
22 | if (!inView) {
23 | return;
24 | }
25 |
26 | loadMore();
27 | }}
28 | >
29 | {({ ref }) =>
30 | noMorePosts ? null : (
31 |
32 | )
33 | }
34 |
35 |
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 There are no comments yet.
;
10 | }
11 |
12 | return comments.map((comment) => (
13 |
14 |
15 |
16 |
17 | @{comment.user?.login}
18 |
19 | {new Date(comment.created_at).toDateString()}
20 |
21 |
22 |
23 | {comment.body}
24 |
25 |
26 |
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 |
19 |
24 | {String(c).replace(/\n$/, '')}
25 |
26 |
27 | ) : (
28 | {c}
29 | );
30 | },
31 | }}
32 | >
33 | {children}
34 |
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 |
24 |
27 |
28 |
29 |
30 |
31 |
32 | {children}
33 |
34 |
35 |
36 |
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 |
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 {
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 |
28 |
29 | {(await isAuthor()) ?
: null}
30 |
31 | {post.body}
32 |
33 |
34 |
35 |
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 |
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 |
12 |
13 |
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;
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 |
29 |
42 |
59 | {actionName}
60 |
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 | {
30 | startTransition(async () => {
31 | await login(pathname);
32 | });
33 | }}
34 | >
35 | {isLoading ? null : }
36 | Login
37 |
38 | );
39 | }
40 |
41 | return (
42 |
43 |
44 |
52 |
53 |
54 |
59 | Signed in as
60 | {user.name}
61 |
62 | {
66 | await logOut();
67 | }}
68 | >
69 | Log Out
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
GitHub Issue Blog
3 |
4 | Use GitHub issue as your blog.
5 |
6 | 
7 |
8 |
9 | [🌐 Example Site](https://github-issue-blog.vercel.app)
10 |
11 | [](https://github.com/m4xshen/github-issue-blog/actions/workflows/playwright.yml)
12 | 
13 |
14 |
15 |
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 | 
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 | [](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 |
23 |
30 | Edit
31 |
32 |
33 | Delete
34 |
35 |
40 |
41 | {(onClose) => (
42 | <>
43 | Delete post
44 |
45 | Are you sure you want to delete this post? This action cannot be
46 | undone.
47 |
48 |
49 |
55 | Cancel
56 |
57 | {
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 |
73 |
74 | >
75 | )}
76 |
77 |
78 |
79 | );
80 | }
81 |
--------------------------------------------------------------------------------