diff --git a/.changeset/famous-maps-sniff.md b/.changeset/famous-maps-sniff.md new file mode 100644 index 00000000..5b4af2e1 --- /dev/null +++ b/.changeset/famous-maps-sniff.md @@ -0,0 +1,5 @@ +--- +"@tszhong0411/tsconfig": patch +--- + +update config diff --git a/.changeset/four-lamps-shave.md b/.changeset/four-lamps-shave.md new file mode 100644 index 00000000..7ba39031 --- /dev/null +++ b/.changeset/four-lamps-shave.md @@ -0,0 +1,5 @@ +--- +"@tszhong0411/eslint-config": patch +--- + +update config diff --git a/.changeset/gold-crabs-greet.md b/.changeset/gold-crabs-greet.md new file mode 100644 index 00000000..9e081f27 --- /dev/null +++ b/.changeset/gold-crabs-greet.md @@ -0,0 +1,5 @@ +--- +"@tszhong0411/ui": patch +--- + +import react style diff --git a/.changeset/sixty-nails-yawn.md b/.changeset/sixty-nails-yawn.md new file mode 100644 index 00000000..c6f9151f --- /dev/null +++ b/.changeset/sixty-nails-yawn.md @@ -0,0 +1,5 @@ +--- +"@tszhong0411/utils": patch +--- + +update utils diff --git a/.changeset/slow-stingrays-train.md b/.changeset/slow-stingrays-train.md new file mode 100644 index 00000000..3032f9c6 --- /dev/null +++ b/.changeset/slow-stingrays-train.md @@ -0,0 +1,5 @@ +--- +'@tszhong0411/ui': patch +--- + +refactor classname diff --git a/.changeset/wicked-ears-type.md b/.changeset/wicked-ears-type.md new file mode 100644 index 00000000..7d8bb394 --- /dev/null +++ b/.changeset/wicked-ears-type.md @@ -0,0 +1,5 @@ +--- +"@tszhong0411/prettier-config": patch +--- + +update config diff --git a/.cspell.json b/.cspell.json new file mode 100644 index 00000000..9ea596d1 --- /dev/null +++ b/.cspell.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "dictionaries": ["libraries", "project-words"], + "dictionaryDefinitions": [ + { + "name": "libraries", + "path": "./.cspell/libraries.txt", + "description": "A list of libraries" + }, + { + "name": "project-words", + "path": "./.cspell/project-words.txt", + "description": "A list of project specific words" + } + ], + "files": [ + "**/*.{js,cjs,mjs,ts,jsx,tsx,md,mdx,html,json,css,toml,yaml,yml,css}" + ], + "ignorePaths": [ + "*lock.{yaml,json}", + "CHANGELOG.md", + "*.mp4", + "volumes", + "*.txt" + ], + "import": [ + "@cspell/dict-typescript/cspell-ext.json", + "@cspell/dict-companies/cspell-ext.json", + "@cspell/dict-fullstack/cspell-ext.json", + "@cspell/dict-markdown/cspell-ext.json", + "@cspell/dict-npm/cspell-ext.json", + "@cspell/dict-node/cspell-ext.json" + ], + "language": "en-US", + "useGitignore": true, + "version": "0.2" +} diff --git a/.cspell/libraries.txt b/.cspell/libraries.txt new file mode 100644 index 00000000..613cd7d5 --- /dev/null +++ b/.cspell/libraries.txt @@ -0,0 +1,19 @@ +autosize +cmdk +cobe +jiti +knip +lightningcss +normy +paralleldrive +rehype +shiki +shikijs +sonarjs +sonner +tinycolor2 +tiptap +tsup +tszhong0411 +vfile +zustand diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt new file mode 100644 index 00000000..9da27b21 --- /dev/null +++ b/.cspell/project-words.txt @@ -0,0 +1,57 @@ +aceternity +airpods +anishde +bentogrids +calcom +callees +cleanmymac +cleanshot +corepack +customizer +delba +frontmatter +fuma +fumadocs +goodnotes +googleusercontent +gstatic +honghong +honghongme +jahir +joshwcomeau +karabiner +keka +leerob +lightroom +linktree +macbook +maximeheckel +motrix +mounty +nextdotjs +nextra +nikolovlazar +nodedotjs +nosniff +orbstack +photoshop +pixelsnap +planetscale +preinstall +prosemirror +raycast +rmiz +sameorigin +samuelkraft +scrollspy +shadcn +simulately +tableplus +techstack +theodorusclarence +umami +unzoom +visualstudiocode +vocs +wakatime +zenorocha diff --git a/.env.example b/.env.example index 3cffc49d..e71303c3 100644 --- a/.env.example +++ b/.env.example @@ -26,9 +26,7 @@ WAKATIME_API_KEY= # Authentication # @see https://next-auth.js.org/getting-started/example # --------------------------------------------------------------------------------------------------------- - NEXTAUTH_SECRET= -NEXTAUTH_URL="http://localhost:3000/api/auth" # Google OAuth GOOGLE_CLIENT_ID= @@ -88,4 +86,4 @@ AUTHOR_EMAIL= # NEXT_PUBLIC_FLAG_SPOTIFY=true # NEXT_PUBLIC_FLAG_ANALYTICS=true # NEXT_PUBLIC_FLAG_GUESTBOOK_NOTIFICATION=true -# NEXT_PUPLIC_FLAG_LIKE_BUTTON=true +# NEXT_PUBLIC_FLAG_LIKE_BUTTON=true diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..2de4adc1 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,8 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + ignorePatterns: ['apps/**', 'packages/**'], + extends: ['@tszhong0411/eslint-config', 'plugin:turbo/recommended'], + parserOptions: { + project: true + } +} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 52b98a1d..480dab6f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,7 +29,10 @@ jobs: run: pnpm install - name: Build packages - run: pnpm build --filter=./packages/* + run: pnpm build:packages + + - name: Create dotenv file + run: echo "DATABASE_URL=postgres://postgres:@localhost:5432" >> .env.local - name: Check run: pnpm check diff --git a/.knip.json b/.knip.json new file mode 100644 index 00000000..780364fb --- /dev/null +++ b/.knip.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "ignoreBinaries": ["only-allow"], + "workspaces": { + ".": { + "project": ["**/*.{js,ts,tsx}"] + }, + "apps/web": { + "entry": ["./src/**/*.{js,ts,tsx}"], + "ignoreDependencies": ["sharp"] + }, + "packages/eslint-config": { + "eslint": { + "config": "index.js" + } + }, + "packages/prettier-config": { + "prettier": { + "config": "index.js" + } + } + } +} diff --git a/.prettierignore b/.prettierignore index ad1c5391..311e5cb9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ # Misc .changeset/*.md +**/db/migrations/**/* pnpm-lock.yaml pnpm-workspace.yaml diff --git a/.vscode/extensions.json b/.vscode/extensions.json index eb1982e3..f7184e2f 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,6 +4,7 @@ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "bradlc.vscode-tailwindcss", - "unifiedjs.vscode-mdx" + "unifiedjs.vscode-mdx", + "streetsidesoftware.code-spell-checker" ] } diff --git a/.vscode/project.code-snippets b/.vscode/project.code-snippets index 616c3ca4..886074e5 100644 --- a/.vscode/project.code-snippets +++ b/.vscode/project.code-snippets @@ -1,27 +1,4 @@ { - "Custom Icon Component": { - "scope": "typescriptreact", - "prefix": "icon", - "body": [ - "import * as React from 'react'", - "", - "export const Icon$1 = (props: React.SVGAttributes) => {", - " return (", - " ", - " $2", - " ", - " )", - "}", - "" - ] - }, "Stateless Functional Component": { "scope": "typescriptreact", "prefix": "sfc", diff --git a/.vscode/settings.json b/.vscode/settings.json index cfd2ae67..6022a7bc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,7 @@ { + "cSpell.enableFiletypes": ["mdx"], + + "cSpell.enabled": true, "editor.formatOnPaste": true, "editor.formatOnSave": true, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c93c3943..1b0323cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,7 +95,7 @@ The following flags can be set in the `.env.local` file to enable specific featu - `NEXT_PUBLIC_FLAG_SPOTIFY`: Spotify integration (Now Playing). - `NEXT_PUBLIC_FLAG_ANALYTICS`: Umami analytics. - `NEXT_PUBLIC_FLAG_GUESTBOOK_NOTIFICATION`: Discord notification for guestbook. -- `NEXT_PUPLIC_FLAG_LIKE_BUTTON`: Like button for blog posts. +- `NEXT_PUBLIC_FLAG_LIKE_BUTTON`: Like button for blog posts. ## Conventional Commits diff --git a/README.md b/README.md index bf3878e1..c9866662 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,11 @@ Welcome to the monorepo of my personal blog! This repository houses the code for - 💄 Prettier - code formatting - 〰️ Drizzle - ORM - 👷🏻‍♂️ t3-env - validate environment variables before building +- 🤖 Auto refresh - fast refresh when updating MDX ## 🔨 Requirements -- Node, recommended `20.x` +- Node, recommended `20.x` with [corepack](https://nodejs.org/api/corepack.html) enabled - pnpm, recommended `9.x` - PostgreSQL, recommended `14.x` (using [docker compose](./docker-compose.yml)) - [Visual Studio Code](https://code.visualstudio.com/) with [recommended extensions](.vscode/extensions.json) @@ -68,6 +69,37 @@ Please refer to the [contributing guidelines](./CONTRIBUTING.md) for detailed in This project has been possible thanks to the wonderful open-source community. Special thanks to [Timothy](https://www.timlrx.com/) for the [Tailwind nextjs starter blog template](https://github.com/timlrx/tailwind-nextjs-starter-blog). +This project also uses / adapts the following open-source projects +Without them, this project would not have been possible: + +- Comment System - from [fuma-comment](https://github.com/fuma-nama/fuma-comment) +- Rehype Plugins - from [fuma-docs](https://github.com/fuma-nama/fumadocs) +- MDX Rendering - from [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote) +- Auto Refresh - from [next-remote-refresh](https://github.com/souporserious/next-remote-refresh) +- UI - from [shadcn/ui](https://github.com/shadcn-ui/ui) + +Referenced the following projects for inspiration: + +- [fumadocs](https://fumadocs.vercel.app/) ❤️ +- [leerob.io](https://leerob.io/) +- [nextra](https://nextra.site/) +- [theodorusclarence.com](https://theodorusclarence.com/) +- [ped.ro](https://ped.ro/) +- [delba.dev](https://delba.dev/) +- [joshwcomeau.com](https://www.joshwcomeau.com/) +- [blog.maximeheckel.com](https://blog.maximeheckel.com/) +- [zenorocha.com](https://zenorocha.com/) +- [jahir.dev](https://jahir.dev/) +- [anishde.dev](https://anishde.dev/) +- [nikolovlazar.com](https://nikolovlazar.com/) +- [samuelkraft.com](https://samuelkraft.com/) +- [bentogrids](https://bentogrids.com/) +- [ui.aceternity.com](https://ui.aceternity.com/) +- [hover.dev](https://www.hover.dev/) +- [vocs.dev](https://vocs.dev/) + +and more but I can't remember them all 🥹 + ## ✍🏻 Author - [@tszhong0411](https://github.com/tszhong0411) diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs new file mode 100644 index 00000000..ad6d770f --- /dev/null +++ b/apps/web/.eslintrc.cjs @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ['next/core-web-vitals', '@tszhong0411/eslint-config'], + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: __dirname + } +} diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json deleted file mode 100644 index 10297305..00000000 --- a/apps/web/.eslintrc.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/eslintrc", - "extends": [ - "next/core-web-vitals", - "@tszhong0411/eslint-config", - "plugin:turbo/recommended" - ], - "root": true -} diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts index 133ea18f..b463d096 100644 --- a/apps/web/drizzle.config.ts +++ b/apps/web/drizzle.config.ts @@ -1,19 +1,12 @@ -import * as dotenv from 'dotenv' import { type Config } from 'drizzle-kit' -dotenv.config({ - path: '../../.env.local' -}) - -if (!process.env.DATABASE_URL) { - throw new Error('DATABASE_URL is required') -} +import { env } from './src/env' export default { dialect: 'postgresql', schema: './src/db/schema.ts', dbCredentials: { - url: process.env.DATABASE_URL + url: env.DATABASE_URL }, - out: './src/db/migration' + out: './src/db/migrations' } satisfies Config diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 8fe2bcb3..2c49e179 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,17 +1,10 @@ import bundleAnalyzer from '@next/bundle-analyzer' -import * as dotenv from 'dotenv' -import jiti from 'jiti' +import createJiti from 'jiti' import { fileURLToPath } from 'node:url' -dotenv.config({ - path: '../../.env.local' -}) - -const filename = fileURLToPath(import.meta.url) +const jiti = createJiti(fileURLToPath(import.meta.url)) -const envPath = './src/env' - -jiti(filename)(envPath) +jiti('./src/env') const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true' @@ -23,6 +16,13 @@ const nextConfig = { optimizePackageImports: ['shiki'] }, + transpilePackages: [ + '@tszhong0411/emails', + '@tszhong0411/mdx', + '@tszhong0411/ui', + '@tszhong0411/utils' + ], + images: { remotePatterns: [ { diff --git a/apps/web/package.json b/apps/web/package.json index 231d821c..0901d961 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,32 +5,53 @@ "license": "GPL-3.0", "type": "module", "scripts": { - "build": "next build && pnpm generate-posts", - "bundle-analyzer": "ANALYZE=true pnpm run build", + "build": "dotenv -e ../../.env.local -- next build && pnpm generate-posts", + "bundle-analyzer": "cross-env ANALYZE=true pnpm run build", "clean": "rm -rf .next .turbo", - "db:check": "drizzle-kit check", - "db:generate": "drizzle-kit generate", - "db:push": "drizzle-kit push", - "db:seed": "tsx ./src/db/seed.ts", - "db:studio": "drizzle-kit studio", - "dev": "concurrently \"tsx ./src/lib/auto-refresh.ts\" \"next dev\"", + "db:check": "dotenv -e ../../.env.local -- drizzle-kit check", + "db:generate": "dotenv -e ../../.env.local -- drizzle-kit generate", + "db:migrate": "dotenv -e ../../.env.local -- tsx ./src/db/migrate.ts", + "db:push": "dotenv -e ../../.env.local -- drizzle-kit push", + "db:seed": "dotenv -e ../../.env.local -- tsx ./src/db/seed.ts", + "db:studio": "dotenv -e ../../.env.local -- drizzle-kit studio", + "dev": "concurrently \"tsx ./src/lib/auto-refresh.ts\" \"dotenv -e ../../.env.local -- next dev\"", "generate-posts": "tsx ./src/scripts/generate-posts.ts", "lint": "eslint . --max-warnings 0", "lint:fix": "eslint --fix .", - "start": "next start", + "start": "dotenv -e ../../.env.local -- next start", "type-check": "tsc --noEmit" }, "dependencies": { + "@auth/drizzle-adapter": "^1.1.0", "@icons-pack/react-simple-icons": "^9.3.0", + "@normy/react-query": "^0.14.2", "@octokit/rest": "^20.0.2", "@paralleldrive/cuid2": "^2.2.2", - "@planetscale/database": "^1.16.0", "@t3-oss/env-nextjs": "^0.9.2", + "@tanstack/react-query": "^5.36.2", + "@tanstack/react-query-devtools": "^5.36.2", + "@tanstack/react-query-next-experimental": "^5.36.2", + "@tiptap/core": "^2.4.0", + "@tiptap/extension-bold": "^2.3.2", + "@tiptap/extension-code-block": "^2.4.0", + "@tiptap/extension-document": "^2.3.2", + "@tiptap/extension-italic": "^2.3.2", + "@tiptap/extension-paragraph": "^2.3.2", + "@tiptap/extension-placeholder": "^2.3.2", + "@tiptap/extension-strike": "^2.3.2", + "@tiptap/extension-text": "^2.3.2", + "@tiptap/html": "^2.4.0", + "@tiptap/pm": "^2.3.2", + "@tiptap/react": "^2.3.2", + "@trpc/client": "11.0.0-rc.373", + "@trpc/react-query": "11.0.0-rc.373", + "@trpc/server": "11.0.0-rc.373", "@tszhong0411/emails": "workspace:*", "@tszhong0411/mdx": "workspace:*", "@tszhong0411/ui": "workspace:*", "@tszhong0411/utils": "workspace:*", "canvas-confetti": "^1.9.2", + "class-variance-authority": "^0.7.0", "cobe": "^0.6.3", "dayjs": "^1.11.10", "drizzle-orm": "^0.29.4", @@ -41,7 +62,7 @@ "js-sha512": "^0.9.0", "lucide-react": "^0.341.0", "next": "14.2.3", - "next-auth": "^5.0.0-beta.13", + "next-auth": "5.0.0-beta.18", "next-themes": "^0.2.1", "pg": "^8.11.3", "react": "18.2.0", @@ -50,8 +71,11 @@ "react-spring": "^9.7.3", "resend": "^3.2.0", "rss": "^1.2.2", + "server-only": "^0.0.1", "sharp": "^0.33.2", - "swr": "^2.2.5", + "shiki": "^1.2.4", + "superjson": "^2.2.1", + "tinycolor2": "^1.6.0", "use-debounce": "^10.0.0", "zod": "^3.22.4", "zustand": "^4.5.1" @@ -62,19 +86,20 @@ "@tszhong0411/tailwind-config": "workspace:*", "@tszhong0411/tsconfig": "workspace:*", "@types/canvas-confetti": "^1.6.4", + "@types/hast": "^3.0.4", "@types/node": "^20.11.20", "@types/nodemailer": "^6.4.14", "@types/pg": "^8.11.2", "@types/react": "^18.2.60", "@types/react-dom": "^18.2.19", "@types/rss": "^0.0.32", + "@types/tinycolor2": "^1.4.6", "@types/ws": "^8.5.10", "chokidar": "^3.6.0", "concurrently": "^8.2.2", + "cross-env": "^7.0.3", "drizzle-kit": "^0.21.1", - "eslint": "^8.57.0", - "eslint-config-next": "14.2.3", - "eslint-plugin-turbo": "^1.12.4", + "eslint-config-next": "^14.2.3", "postcss": "^8.4.35", "postcss-lightningcss": "^1.0.0", "schema-dts": "^1.1.2", diff --git a/apps/web/public/images/email/logo.png b/apps/web/public/images/email/logo.png index 7a55cf87..7ac4a3f1 100644 Binary files a/apps/web/public/images/email/logo.png and b/apps/web/public/images/email/logo.png differ diff --git a/apps/web/public/images/uses/arc.png b/apps/web/public/images/uses/arc.png new file mode 100644 index 00000000..7dccaacd Binary files /dev/null and b/apps/web/public/images/uses/arc.png differ diff --git a/apps/web/public/images/uses/cleanmymac-x.png b/apps/web/public/images/uses/cleanmymac-x.png new file mode 100644 index 00000000..30f097ed Binary files /dev/null and b/apps/web/public/images/uses/cleanmymac-x.png differ diff --git a/apps/web/public/images/uses/cleanshot-x.png b/apps/web/public/images/uses/cleanshot-x.png new file mode 100644 index 00000000..34e48907 Binary files /dev/null and b/apps/web/public/images/uses/cleanshot-x.png differ diff --git a/apps/web/public/images/uses/fig.png b/apps/web/public/images/uses/fig.png deleted file mode 100644 index a4a44222..00000000 Binary files a/apps/web/public/images/uses/fig.png and /dev/null differ diff --git a/apps/web/public/images/uses/github-theme.png b/apps/web/public/images/uses/github-theme.png index 4aa632e1..d0391175 100644 Binary files a/apps/web/public/images/uses/github-theme.png and b/apps/web/public/images/uses/github-theme.png differ diff --git a/apps/web/public/images/uses/goodnotes.png b/apps/web/public/images/uses/goodnotes.png new file mode 100644 index 00000000..8ee93f8e Binary files /dev/null and b/apps/web/public/images/uses/goodnotes.png differ diff --git a/apps/web/public/images/uses/karabiner.png b/apps/web/public/images/uses/karabiner.png new file mode 100644 index 00000000..419bb4cc Binary files /dev/null and b/apps/web/public/images/uses/karabiner.png differ diff --git a/apps/web/public/images/uses/macbook-air-15-inch.png b/apps/web/public/images/uses/macbook-air-15-inch.png index 4fc0853b..a06582be 100644 Binary files a/apps/web/public/images/uses/macbook-air-15-inch.png and b/apps/web/public/images/uses/macbook-air-15-inch.png differ diff --git a/apps/web/public/images/uses/motrix.png b/apps/web/public/images/uses/motrix.png new file mode 100644 index 00000000..233f9537 Binary files /dev/null and b/apps/web/public/images/uses/motrix.png differ diff --git a/apps/web/public/images/uses/mounty.png b/apps/web/public/images/uses/mounty.png new file mode 100644 index 00000000..929ab4e7 Binary files /dev/null and b/apps/web/public/images/uses/mounty.png differ diff --git a/apps/web/public/images/uses/obs.png b/apps/web/public/images/uses/obs.png deleted file mode 100644 index 70214140..00000000 Binary files a/apps/web/public/images/uses/obs.png and /dev/null differ diff --git a/apps/web/public/images/uses/orbstack.png b/apps/web/public/images/uses/orbstack.png new file mode 100644 index 00000000..84692873 Binary files /dev/null and b/apps/web/public/images/uses/orbstack.png differ diff --git a/apps/web/public/images/uses/pixelsnap-2.png b/apps/web/public/images/uses/pixelsnap-2.png new file mode 100644 index 00000000..480b3f46 Binary files /dev/null and b/apps/web/public/images/uses/pixelsnap-2.png differ diff --git a/apps/web/public/images/uses/powershell.png b/apps/web/public/images/uses/powershell.png deleted file mode 100644 index 46bb09d1..00000000 Binary files a/apps/web/public/images/uses/powershell.png and /dev/null differ diff --git a/apps/web/public/images/uses/rectangle.png b/apps/web/public/images/uses/rectangle.png new file mode 100644 index 00000000..f9bb67a5 Binary files /dev/null and b/apps/web/public/images/uses/rectangle.png differ diff --git a/apps/web/public/images/uses/screen-studio.png b/apps/web/public/images/uses/screen-studio.png new file mode 100644 index 00000000..e22aa70c Binary files /dev/null and b/apps/web/public/images/uses/screen-studio.png differ diff --git a/apps/web/public/images/uses/sip.png b/apps/web/public/images/uses/sip.png new file mode 100644 index 00000000..311e0c23 Binary files /dev/null and b/apps/web/public/images/uses/sip.png differ diff --git a/apps/web/public/images/uses/stats.png b/apps/web/public/images/uses/stats.png new file mode 100644 index 00000000..40bf8271 Binary files /dev/null and b/apps/web/public/images/uses/stats.png differ diff --git a/apps/web/public/images/uses/tableplus.png b/apps/web/public/images/uses/tableplus.png new file mode 100644 index 00000000..a5d8a9a7 Binary files /dev/null and b/apps/web/public/images/uses/tableplus.png differ diff --git a/apps/web/public/images/uses/visual-studio.png b/apps/web/public/images/uses/visual-studio.png deleted file mode 100644 index 6344d5a9..00000000 Binary files a/apps/web/public/images/uses/visual-studio.png and /dev/null differ diff --git a/apps/web/public/images/uses/warp.png b/apps/web/public/images/uses/warp.png index bd651af5..329d8fed 100644 Binary files a/apps/web/public/images/uses/warp.png and b/apps/web/public/images/uses/warp.png differ diff --git a/apps/web/src/actions/comment.tsx b/apps/web/src/actions/comment.tsx deleted file mode 100644 index 9e5cdf4b..00000000 --- a/apps/web/src/actions/comment.tsx +++ /dev/null @@ -1,305 +0,0 @@ -'use server' - -import { createId } from '@paralleldrive/cuid2' -import { CommentNotification } from '@tszhong0411/emails' -import { and, eq } from 'drizzle-orm' -import { revalidatePath } from 'next/cache' -import { Resend } from 'resend' -import { z } from 'zod' - -import { db } from '@/db' -import { comments, commentUpvotes, users } from '@/db/schema' -import { env } from '@/env' -import { isProduction } from '@/lib/constants' -import { type BlogMetadata, getPage } from '@/lib/mdx' -import { type getComments } from '@/queries/comments' -import { getErrorMessage } from '@/utils/get-error-message' - -import { privateAction } from './private-action' - -const resend = new Resend(env.RESEND_API_KEY) - -export const postComment = ( - slug: string, - comment: string, - markdown: string, - parentId?: string -) => - privateAction(async (user) => { - const schema = z.object({ - comment: z.string().min(1, { - message: 'Comment is required.' - }), - markdown: z.string().min(1, { - message: 'Comment is required.' - }), - slug: z.string().min(1, { - message: 'Slug is required.' - }), - parentId: z.string().optional() - }) - - const parsed = schema.safeParse({ - comment, - markdown, - slug, - parentId - }) - - if (!parsed.success) { - return { - message: parsed.error.issues[0]!.message, - error: true - } - } - - const { - comment: parsedComment, - markdown: parsedMarkdown, - slug: parsedSlug, - parentId: parsedParentId - } = parsed.data - - try { - const commentId = createId() - - await db.insert(comments).values({ - id: commentId, - body: parsedComment, - userId: user.id as string, - postId: parsedSlug, - ...(parentId && { - parentId: parsedParentId - }) - }) - - const { - metadata: { title } - } = getPage(`/blog/${slug}`)! - - if (!parentId) { - await db.insert(commentUpvotes).values({ - id: createId(), - userId: user.id as string, - commentId - }) - - if (user.role === 'user' && isProduction) { - await resend.emails.send({ - from: 'Hong from honghong.me ', - to: env.AUTHOR_EMAIL, - subject: 'New comment posted', - react: CommentNotification({ - title, - name: user.name as string, - commenterName: user.name as string, - comment: parsedMarkdown, - commentUrl: `https://honghong.me/blog/${slug}#comment-${commentId}`, - postUrl: `https://honghong.me/blog/${slug}`, - type: 'comment' - }) - }) - } - } - - if (parentId) { - const parentComment = await db - .select() - .from(comments) - .where(eq(comments.id, parentId)) - .innerJoin(users, eq(users.id, comments.userId)) - - if ( - parentComment[0] && - parentComment[0].user.email !== user.email && - isProduction - ) { - await resend.emails.send({ - from: 'Hong from honghong.me ', - to: parentComment[0].user.email, - subject: 'New reply posted', - react: CommentNotification({ - title, - name: user.name as string, - commenterName: user.name as string, - comment: parsedMarkdown, - commentUrl: `https://honghong.me/blog/${slug}#comment-${commentId}`, - postUrl: `https://honghong.me/blog/${slug}`, - type: 'reply' - }) - }) - } - } - } catch (error) { - return { - message: getErrorMessage(error), - error: true - } - } - - revalidatePath('/blog/[slug]', 'page') - return { - message: 'Posted a comment.' - } - }) - -export const deleteComment = (id: string) => - privateAction(async (user) => { - const schema = z.object({ - id: z.string().min(1, { - message: 'ID is required.' - }) - }) - - const parsed = schema.safeParse({ - id - }) - - if (!parsed.success) { - return { - message: parsed.error.issues[0]!.message, - error: true - } - } - - const { id: parsedId } = parsed.data - - const email = user.email - - const comment = await db.query.comments.findFirst({ - where: eq(comments.id, parsedId), - with: { - user: true, - replies: true, - parent: true - } - }) - - if (!comment) { - return { - message: 'Comment not found', - error: true - } - } - - if (comment.user.email !== email) { - return { - message: 'Unauthorized', - error: true - } - } - - try { - // If the comment has replies, just mark it as deleted. - if (comment.replies.length > 0) { - await db - .update(comments) - .set({ - body: '[This comment has been deleted]', - isDeleted: true - }) - .where(eq(comments.id, parsedId)) - } else { - await db.delete(comments).where(eq(comments.id, parsedId)) - - if (comment.parentId) { - const parentComment = await db.query.comments.findFirst({ - where: and( - eq(comments.id, comment.parentId), - eq(comments.isDeleted, true) - ), - with: { - replies: true - } - }) - - // If the parent comment (which is marked as deleted) has no replies, delete it also. - if (parentComment?.replies.length === 0) { - await db.delete(comments).where(eq(comments.id, comment.parentId)) - } - } - } - } catch (error) { - return { - message: getErrorMessage(error), - error: true - } - } - - revalidatePath('/blog/[slug]', 'page') - return { - message: 'Deleted a comment.' - } - }) - -export const upvoteComment = (id: string) => - privateAction(async (user) => { - const schema = z.object({ - id: z.string().min(1, { - message: 'ID is required.' - }) - }) - - const parsed = schema.safeParse({ - id - }) - - if (!parsed.success) { - return { - message: parsed.error.issues[0]!.message, - error: true - } - } - - const { id: parsedId } = parsed.data - - const comment = await db.query.comments.findFirst({ - where: eq(comments.id, parsedId), - with: { - upvotes: { - where: eq(commentUpvotes.userId, user.id as string) - } - } - }) - - if (!comment) { - return { - message: 'Comment not found', - error: true - } - } - - if (comment.upvotes.length > 0) { - await db - .delete(commentUpvotes) - .where(eq(commentUpvotes.id, comment.upvotes[0]!.id)) - - revalidatePath('/blog/[slug]', 'page') - return { - message: 'Removed an upvote.' - } - } - - try { - await db.insert(commentUpvotes).values({ - id: createId(), - userId: user.id as string, - commentId: parsedId - }) - } catch (error) { - return { - message: getErrorMessage(error), - error: true - } - } - - revalidatePath('/blog/[slug]', 'page') - return { - message: 'Upvoted a comment.' - } - }) - -export type Comment = Awaited>[0] -export type Reply = typeof comments.$inferSelect & { - user: typeof users.$inferSelect -} diff --git a/apps/web/src/actions/guestbook.ts b/apps/web/src/actions/guestbook.ts deleted file mode 100644 index 4108aa71..00000000 --- a/apps/web/src/actions/guestbook.ts +++ /dev/null @@ -1,119 +0,0 @@ -'use server' - -import { createId } from '@paralleldrive/cuid2' -import { and, eq } from 'drizzle-orm' -import { revalidatePath } from 'next/cache' -import { z } from 'zod' - -import { db } from '@/db' -import { guestbook } from '@/db/schema' -import { env } from '@/env' -import { flags } from '@/lib/constants' -import { getErrorMessage } from '@/utils/get-error-message' - -import { privateAction } from './private-action' - -export const deleteMessage = (id: string) => - privateAction(async (user) => { - const email = user.email as string - - const message = await db - .select() - .from(guestbook) - .where(and(eq(guestbook.id, id), eq(guestbook.email, email))) - - if (!message[0]) { - return { - message: 'Message not found', - error: true - } - } - - try { - await db.delete(guestbook).where(eq(guestbook.id, id)) - } catch (error) { - return { - message: getErrorMessage(error), - error: true - } - } - - revalidatePath('/guestbook') - return { - message: 'Deleted a message.' - } - }) - -export const createMessage = (formData: FormData) => - privateAction(async (user) => { - const schema = z.object({ - message: z.string().min(1, { - message: 'Message is required.' - }) - }) - - const parsed = schema.safeParse({ - message: formData.get('message') ?? '' - }) - - if (!parsed.success) { - return { - message: parsed.error.issues[0]!.message, - error: true - } - } - - const { message } = parsed.data - const email = user.email as string - const name = user.name as string - const image = user.image as string - - try { - await db.insert(guestbook).values({ - id: createId(), - email, - body: message, - image, - createdBy: name - }) - } catch (error) { - return { - message: getErrorMessage(error), - error: true - } - } - - if (flags.guestbookNotification) { - await fetch(env.DISCORD_WEBHOOK_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - content: null, - embeds: [ - { - title: 'New comment!', - description: message, - url: 'https://honghong.me/guestbook', - color: '6609519', - author: { - name, - icon_url: image - }, - timestamp: new Date().toISOString() - } - ], - username: 'Blog', - avatar_url: - 'https://cdn.discordapp.com/avatars/1123845082672537751/8af603a10f1d2f86ebc922ede339cd3a.webp', - attachments: [] - }) - }) - } - - revalidatePath('/guestbook') - return { - message: 'Created a message.' - } - }) diff --git a/apps/web/src/actions/private-action.ts b/apps/web/src/actions/private-action.ts deleted file mode 100644 index c43b4f95..00000000 --- a/apps/web/src/actions/private-action.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { type User } from 'next-auth' - -import { getCurrentUser } from '@/lib/auth' - -export const privateAction = async ( - fn: (user: User) => Promise<{ message: string; error?: boolean }> -): Promise<{ message: string; error?: boolean }> => { - const user = await getCurrentUser() - - if (!user) { - return { - message: 'Unauthorized', - error: true - } - } - - return fn(user) -} diff --git a/apps/web/src/app/api/avatar/[id]/route.tsx b/apps/web/src/app/api/avatar/[id]/route.tsx new file mode 100644 index 00000000..978ea81f --- /dev/null +++ b/apps/web/src/app/api/avatar/[id]/route.tsx @@ -0,0 +1,87 @@ +/** + * Adapted from: https://github.com/vercel/avatar/blob/410bc1e438ef26a7456b037bbdd44d5aec49031a/pages/api/avatar/%5Bname%5D.tsx + */ +import { getErrorMessage } from '@tszhong0411/utils' +import { ImageResponse } from 'next/og' +import { NextResponse } from 'next/server' +import color from 'tinycolor2' + +export const runtime = 'edge' + +type AvatarRouteProps = { + params: { + id: string + } +} + +const djb2 = (str: string) => { + let hash = 5381 + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) + hash + (str.codePointAt(i) as number) + } + return hash +} + +const generateGradient = (id: string) => { + const c1 = color({ h: djb2(id) % 360, s: 0.95, l: 0.5 }) + const second = c1.triad()[1].toHexString() + + return { + fromColor: c1.toHexString(), + toColor: second + } +} + +export const GET = (req: Request, props: AvatarRouteProps) => { + const params = new URL(req.url) + const size = Number(params.searchParams.get('size')) || 40 + + try { + const { + params: { id } + } = props + + const gradient = generateGradient(id) + + return new ImageResponse( + ( + + + + + + + + + + + + ), + { + width: size, + height: size + } + ) + } catch (error) { + return NextResponse.json( + { + error: 'Failed to generate avatar: ' + getErrorMessage(error) + }, + { + status: 500 + } + ) + } +} diff --git a/apps/web/src/app/api/comments/route.ts b/apps/web/src/app/api/comments/route.ts deleted file mode 100644 index 6833c30e..00000000 --- a/apps/web/src/app/api/comments/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { count, eq } from 'drizzle-orm' -import { unstable_noStore as noStore } from 'next/cache' -import { NextResponse } from 'next/server' - -import { db } from '@/db' -import { comments } from '@/db/schema' - -export const GET = async (req: Request) => { - noStore() - - const { searchParams } = new URL(req.url) - const slug = searchParams.get('slug') - - if (!slug) { - return NextResponse.json( - { - error: 'Slug is required' - }, - { status: 400 } - ) - } - - const res = await db - .select({ - value: count() - }) - .from(comments) - .where(eq(comments.postId, slug)) - - return NextResponse.json({ - value: res[0]?.value ?? 0 - }) -} diff --git a/apps/web/src/app/api/github/route.ts b/apps/web/src/app/api/github/route.ts deleted file mode 100644 index a1e519cc..00000000 --- a/apps/web/src/app/api/github/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Octokit } from '@octokit/rest' -import { unstable_noStore as noStore } from 'next/cache' -import { NextResponse } from 'next/server' - -import { env } from '@/env' -import { flags, GITHUB_USERNAME } from '@/lib/constants' - -export const runtime = 'edge' - -export const GET = async () => { - if (!flags.stats) { - throw new Error('Stats is disabled') - } - - noStore() - - const octokit = new Octokit({ - auth: env.GITHUB_TOKEN - }) - - const { data: repos } = await octokit.request('GET /users/{username}/repos', { - username: GITHUB_USERNAME - }) - - const { - data: { followers } - } = await octokit.request('GET /users/{username}', { - username: GITHUB_USERNAME - }) - - const stars = repos - .filter((repo) => { - return !repo.fork - }) - .reduce((acc, repo) => { - return acc + (repo.stargazers_count ?? 0) - }, 0) - - return NextResponse.json({ - stars, - followers - }) -} diff --git a/apps/web/src/app/api/likes/route.ts b/apps/web/src/app/api/likes/route.ts deleted file mode 100644 index 6ffb8244..00000000 --- a/apps/web/src/app/api/likes/route.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { eq, sql, sum } from 'drizzle-orm' -import { sha512 } from 'js-sha512' -import { unstable_noStore as noStore } from 'next/cache' -import { NextResponse } from 'next/server' -import { z } from 'zod' - -import { db } from '@/db' -import { likesSessions, posts } from '@/db/schema' -import { env } from '@/env' -import { getErrorMessage } from '@/utils/get-error-message' - -const schema = z.object({ - slug: z.string(), - value: z.number().int().positive().min(1).max(3) -}) - -const getSessionId = (slug: string, req: Request): string => { - const ipAddress = req.headers.get('x-forwarded-for') ?? '0.0.0.0' - const currentUserId = sha512(ipAddress + env.IP_ADDRESS_SALT) - - return `${slug}___${currentUserId}` -} - -export const GET = async (req: Request) => { - noStore() - - const { searchParams } = new URL(req.url) - const slug = searchParams.get('slug') - - if (!slug) { - const res = await db - .select({ - value: sum(likesSessions.likes) - }) - .from(posts) - - return NextResponse.json({ - likes: res[0]?.value ?? 0 - }) - } - - const [post, user] = await Promise.all([ - db - .select({ - likes: posts.likes - }) - .from(posts) - .where(eq(posts.slug, slug)), - db - .select({ - likes: likesSessions.likes - }) - .from(likesSessions) - .where(eq(likesSessions.id, getSessionId(slug, req))) - ]) - - if (!post) { - return NextResponse.json( - { - error: 'Post not found' - }, - { status: 404 } - ) - } - - return NextResponse.json({ - likes: post[0]?.likes ?? 0, - currentUserLikes: user[0]?.likes ?? 0 - }) -} - -export const PATCH = async (req: Request) => { - const request = schema.safeParse(await req.json()) - - if (!request.success) { - return NextResponse.json( - { - error: `Invalid request: ${request.error.issues[0]!.message}` - }, - { status: 400 } - ) - } - - const { - data: { slug, value } - } = request - - try { - const session = await db - .select({ - likes: likesSessions.likes - }) - .from(likesSessions) - .where(eq(likesSessions.id, getSessionId(slug, req))) - - if (session[0] && session[0].likes + value > 3) { - throw new Error('You can only like a post 3 times') - } - - await db - .insert(posts) - .values({ - slug, - likes: value - }) - .onConflictDoUpdate({ - target: posts.slug, - set: { - likes: sql`${posts.likes} + ${value}` - } - }) - - await db - .insert(likesSessions) - .values({ - id: getSessionId(slug, req), - likes: value - }) - .onConflictDoUpdate({ - target: likesSessions.id, - set: { - likes: sql`${likesSessions.likes} + ${value}` - } - }) - - const post = await db - .select({ - likes: posts.likes - }) - .from(posts) - .where(eq(posts.slug, slug)) - - const likesSession = await db - .select({ - likes: likesSessions.likes - }) - .from(likesSessions) - .where(eq(likesSessions.id, getSessionId(slug, req))) - - return NextResponse.json({ - likes: post[0]?.likes ?? 0, - currentUserLikes: likesSession[0]?.likes ?? 0 - }) - } catch (error) { - return NextResponse.json( - { - error: getErrorMessage(error) - }, - { status: 500 } - ) - } -} diff --git a/apps/web/src/app/api/spotify/route.ts b/apps/web/src/app/api/spotify/route.ts deleted file mode 100644 index 2c1e7776..00000000 --- a/apps/web/src/app/api/spotify/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { unstable_noStore as noStore } from 'next/cache' -import { NextResponse } from 'next/server' - -import { getNowPlaying } from '@/lib/spotify' - -export const runtime = 'edge' - -export const GET = async () => { - noStore() - - try { - const response = await getNowPlaying() - - if ( - response.status === 204 || - response.status > 400 || - response?.data?.item === null || - !response.data - ) { - return NextResponse.json({ isPlaying: false }) - } - - const song = response.data - - if (song.is_playing === false) { - return NextResponse.json({ isPlaying: false }) - } - - const isPlaying = song.is_playing - const name = song.item.name - const artist = song.item.artists - .map((_artist: { name: string }) => { - return _artist.name - }) - .join(', ') - const album = song.item.album.name - const albumImage = song.item.album.images[0].url - const songUrl = song.item.external_urls.spotify - - return NextResponse.json({ - isPlaying, - name, - artist, - album, - albumImage, - songUrl - }) - } catch { - return NextResponse.json( - { - isPlaying: false, - message: 'Error getting Now Playing from Spotify' - }, - { status: 500 } - ) - } -} diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 00000000..927652df --- /dev/null +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,29 @@ +import { fetchRequestHandler } from '@trpc/server/adapters/fetch' +import type { NextRequest } from 'next/server' + +import { appRouter } from '@/trpc/root' +import { createTRPCContext } from '@/trpc/trpc' + +const createContext = async (req: NextRequest) => { + return createTRPCContext({ + headers: req.headers + }) +} + +const handler = async (req: NextRequest) => + fetchRequestHandler({ + endpoint: '/api/trpc', + req, + router: appRouter, + createContext: () => createContext(req), + onError: + process.env.NODE_ENV === 'development' + ? ({ path, error }) => { + console.error( + `❌ tRPC failed on ${path ?? ''}: ${error.message}` + ) + } + : undefined + }) + +export { handler as GET, handler as POST } diff --git a/apps/web/src/app/api/views/route.ts b/apps/web/src/app/api/views/route.ts deleted file mode 100644 index 2342d1e6..00000000 --- a/apps/web/src/app/api/views/route.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { eq, sql, sum } from 'drizzle-orm' -import { unstable_noStore as noStore } from 'next/cache' -import { NextResponse } from 'next/server' - -import { db } from '@/db' -import { posts } from '@/db/schema' - -export const GET = async (req: Request) => { - noStore() - - const { searchParams } = new URL(req.url) - const slug = searchParams.get('slug') - - if (!slug) { - const views = await db - .select({ - value: sum(posts.views) - }) - .from(posts) - - return NextResponse.json({ - views: views[0]?.value ?? 0 - }) - } - - const post = await db - .select({ - views: posts.views - }) - .from(posts) - .where(eq(posts.slug, slug)) - - if (!post[0]) { - return NextResponse.json( - { - error: 'Post not found' - }, - { status: 404 } - ) - } - - return NextResponse.json({ - views: post[0].views - }) -} - -export const POST = async (req: Request) => { - const { slug } = (await req.json()) as { - slug: string | null - } - - if (!slug) { - return NextResponse.json( - { - error: 'Slug is required' - }, - { status: 400 } - ) - } - - await db - .insert(posts) - .values({ - slug: slug, - views: 1 - }) - .onConflictDoUpdate({ - target: posts.slug, - set: { - views: sql`${posts.views} + 1` - } - }) - - return NextResponse.json({ - error: null - }) -} diff --git a/apps/web/src/app/api/wakatime/route.ts b/apps/web/src/app/api/wakatime/route.ts deleted file mode 100644 index 70ffe3b4..00000000 --- a/apps/web/src/app/api/wakatime/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { unstable_noStore as noStore } from 'next/cache' -import { NextResponse } from 'next/server' - -import { env } from '@/env' -import { flags } from '@/lib/constants' - -export const runtime = 'edge' - -export const GET = async () => { - if (!flags.stats) { - throw new Error('Stats is disabled') - } - - noStore() - - const res = await fetch( - 'https://wakatime.com/api/v1/users/current/all_time_since_today', - { - headers: { - Authorization: `Basic ${Buffer.from(env.WAKATIME_API_KEY).toString( - 'base64' - )}` - } - } - ) - - const { - data: { total_seconds } - } = await res.json() - - return NextResponse.json({ - seconds: total_seconds - }) -} diff --git a/apps/web/src/app/api/youtube/route.ts b/apps/web/src/app/api/youtube/route.ts deleted file mode 100644 index 6f4eb368..00000000 --- a/apps/web/src/app/api/youtube/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { unstable_noStore as noStore } from 'next/cache' -import { NextResponse } from 'next/server' - -import { env } from '@/env' -import { flags } from '@/lib/constants' - -export const runtime = 'edge' - -export const GET = async () => { - if (!flags.stats) { - throw new Error('Stats is disabled') - } - - noStore() - - const res = await fetch( - `https://www.googleapis.com/youtube/v3/channels?id=UC2hMWOaOlk9vrkvFVaGmn0Q&part=statistics&key=${env.GOOGLE_API_KEY}` - ) - const data = await res.json() - - const channel = data.items[0] - const statistics = channel.statistics - - if (!statistics) { - throw new Error('Statistics not found') - } - - return NextResponse.json({ - subscribers: Number(statistics.subscriberCount), - views: Number(statistics.viewCount) - }) -} diff --git a/apps/web/src/app/blog/[slug]/content.tsx b/apps/web/src/app/blog/[slug]/content.tsx index 9d82c899..34636220 100644 --- a/apps/web/src/app/blog/[slug]/content.tsx +++ b/apps/web/src/app/blog/[slug]/content.tsx @@ -24,8 +24,8 @@ const Content = async (props: ContentProps) => { diff --git a/apps/web/src/app/blog/[slug]/footer.tsx b/apps/web/src/app/blog/[slug]/footer.tsx index 25d0eba3..a877f977 100644 --- a/apps/web/src/app/blog/[slug]/footer.tsx +++ b/apps/web/src/app/blog/[slug]/footer.tsx @@ -1,7 +1,6 @@ 'use client' import { Link } from '@tszhong0411/ui' -import * as React from 'react' import { useFormattedDate } from '@/hooks/use-formatted-date' diff --git a/apps/web/src/app/blog/[slug]/header.tsx b/apps/web/src/app/blog/[slug]/header.tsx index 10a2b486..58d7e9a0 100644 --- a/apps/web/src/app/blog/[slug]/header.tsx +++ b/apps/web/src/app/blog/[slug]/header.tsx @@ -1,13 +1,11 @@ 'use client' import { BlurImage, Link } from '@tszhong0411/ui' -import * as React from 'react' -import useSWR from 'swr' +import { useEffect, useRef } from 'react' import ImageZoom from '@/components/image-zoom' import { useFormattedDate } from '@/hooks/use-formatted-date' -import { fetcher } from '@/lib/fetcher' -import { type Comments, type Views } from '@/types' +import { api } from '@/trpc/react' type HeaderProps = { date: string @@ -21,30 +19,28 @@ const Header = (props: HeaderProps) => { format: 'LL', loading: '--' }) - const { data: viewsData, isLoading: viewsIsLoading } = useSWR( - `/api/views?slug=${slug}`, - fetcher - ) - const { data: commentsData, isLoading: commentsIsLoading } = useSWR( - `/api/comments?slug=${slug}`, - fetcher - ) + const utils = api.useUtils() - React.useEffect(() => { - const increment = async () => { - await fetch('/api/views', { - method: 'POST', - body: JSON.stringify({ - slug - }), - headers: { - 'Content-Type': 'application/json' - } - }) - } + const incrementMutation = api.views.increment.useMutation({ + onSettled: () => utils.views.get.invalidate() + }) + + const viewsQuery = api.views.get.useQuery({ + slug + }) + + const commentsQuery = api.comments.getCount.useQuery({ + slug + }) - increment() - }, [slug]) + const incremented = useRef(false) + + useEffect(() => { + if (!incremented.current) { + incrementMutation.mutate({ slug }) + incremented.current = true + } + }, [incrementMutation, slug]) return (
@@ -75,11 +71,15 @@ const Header = (props: HeaderProps) => {
Views
- {viewsIsLoading ? '--' :
{viewsData?.views}
} + {viewsQuery.isLoading ? '--' :
{viewsQuery.data?.views}
}
Comments
- {commentsIsLoading ? '--' :
{commentsData?.value}
} + {commentsQuery.isLoading ? ( + '--' + ) : ( +
{commentsQuery.data?.value}
+ )}
diff --git a/apps/web/src/app/blog/[slug]/like-button.tsx b/apps/web/src/app/blog/[slug]/like-button.tsx index 9d41167b..4b7c2948 100644 --- a/apps/web/src/app/blog/[slug]/like-button.tsx +++ b/apps/web/src/app/blog/[slug]/like-button.tsx @@ -1,15 +1,14 @@ +'use client' + /** * Inspired by: https://framer.university/resources/like-button-component */ -'use client' -import { Separator, toast } from '@tszhong0411/ui' +import { Separator } from '@tszhong0411/ui' import { motion } from 'framer-motion' -import * as React from 'react' -import useSWR from 'swr' +import { useRef, useState } from 'react' import { useDebouncedCallback } from 'use-debounce' -import { fetcher } from '@/lib/fetcher' -import { type Likes } from '@/types' +import { api } from '@/trpc/react' type LikeButtonProps = { slug: string @@ -17,15 +16,38 @@ type LikeButtonProps = { const LikeButton = (props: LikeButtonProps) => { const { slug } = props - const [cacheCount, setCacheCount] = React.useState(0) - const buttonRef = React.useRef(null) + const [cacheCount, setCacheCount] = useState(0) + const buttonRef = useRef(null) + const utils = api.useUtils() - const { data, isLoading, mutate } = useSWR( - `/api/likes?slug=${slug}`, - fetcher - ) + const likesQuery = api.likes.get.useQuery({ slug }) + const likesMutation = api.likes.patch.useMutation({ + onMutate: (newData) => { + utils.likes.get.cancel({ slug }) + + const previousData = utils.likes.get.getData({ slug }) + + utils.likes.get.setData({ slug }, (old) => { + if (!old) return old - const handleConfetti = async () => { + return { + ...old, + likes: old.likes + newData.value, + currentUserLikes: old.currentUserLikes + newData.value + } + }) + + return { previousData } + }, + onError: (_, __, ctx) => { + if (ctx?.previousData) { + utils.likes.get.setData({ slug }, ctx.previousData) + } + }, + onSettled: () => utils.likes.get.invalidate() + }) + + const confettiHandler = async () => { const { clientWidth, clientHeight } = document.documentElement const boundingBox = buttonRef.current?.getBoundingClientRect?.() @@ -48,34 +70,24 @@ const LikeButton = (props: LikeButtonProps) => { }) } - const onLikeSaving = useDebouncedCallback(async (value: number) => { - try { - const res = await fetch('/api/likes', { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ slug, value }) - }) - - const newData = (await res.json()) as Likes - - await mutate(newData) - } catch { - toast.error('Unable to like this post. Please try again.') - } finally { - setCacheCount(0) - } + const onLikeSaving = useDebouncedCallback((value: number) => { + likesMutation.mutate({ slug, value }) + setCacheCount(0) }, 1000) - const handleLike = () => { - if (isLoading || !data || data.currentUserLikes + cacheCount >= 3) return + const likeHandler = () => { + if ( + likesQuery.isLoading || + !likesQuery.data || + likesQuery.data.currentUserLikes + cacheCount >= 3 + ) + return const value = cacheCount === 3 ? cacheCount : cacheCount + 1 setCacheCount(value) - if (data.currentUserLikes + cacheCount === 2) { - handleConfetti() + if (likesQuery.data.currentUserLikes + cacheCount === 2) { + confettiHandler() } return onLikeSaving(value) @@ -87,7 +99,7 @@ const LikeButton = (props: LikeButtonProps) => { ref={buttonRef} className='flex items-center gap-3 rounded-xl bg-zinc-900 px-4 py-2 text-lg text-white' type='button' - onClick={handleLike} + onClick={likeHandler} aria-label='Like this post' > { y: '100%' }} animate={{ - y: data - ? `${100 - (data.currentUserLikes + cacheCount) * 33}%` + y: likesQuery.data + ? `${100 - (likesQuery.data.currentUserLikes + cacheCount) * 33}%` : '100%' }} /> - Like{data && data.likes + cacheCount === 1 ? '' : 's'} + Like + {likesQuery.data && likesQuery.data.likes + cacheCount === 1 ? '' : 's'} - {isLoading || !data ? ( + {likesQuery.isLoading ? (
--
) : ( -
{data.likes + cacheCount}
+
{likesQuery.data!.likes + cacheCount}
)} diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index 5cc58811..67d9c8c1 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -1,10 +1,8 @@ import type { Metadata, ResolvingMetadata } from 'next' import { notFound } from 'next/navigation' -import * as React from 'react' import { type Article, type WithContext } from 'schema-dts' import Comments from '@/components/comments' -import CommentsLoading from '@/components/comments/comments-loading' import { flags, SITE_NAME, SITE_URL } from '@/lib/constants' import { type BlogMetadata, getAllPages, getPage } from '@/lib/mdx' @@ -133,11 +131,7 @@ const BlogPostPage = (props: BlogPostPageProps) => {