├── .env.development ├── .env.production ├── .eslintignore ├── .eslintrc.json ├── .firebaserc ├── .github └── workflows │ └── deployment.yaml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── commitlint.config.js ├── environment.d.ts ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── jest.config.js ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── assets │ ├── blog │ │ ├── custom-layout-in-nextjs │ │ │ └── banner.jpg │ │ ├── data-fetching-in-nextjs │ │ │ └── banner.jpg │ │ └── hello-world │ │ │ └── banner.jpg │ ├── emilia.png │ ├── inter-bold.ttf │ ├── inter-medium.ttf │ ├── inter-regular.ttf │ ├── inter-semibold.ttf │ ├── pop.mp3 │ └── projects │ │ └── twitter-clone │ │ └── banner.png ├── favicon.ico ├── logo192.png ├── logo512.png └── site.webmanifest ├── renovate.json ├── src ├── components │ ├── blog │ │ ├── blog-card.tsx │ │ ├── blog-stats.tsx │ │ ├── blog-tag.tsx │ │ ├── sort-listbox.tsx │ │ └── subscribe-card.tsx │ ├── common │ │ ├── app-head.tsx │ │ ├── seo.tsx │ │ ├── spotify-card.tsx │ │ └── theme-switch.tsx │ ├── content │ │ ├── custom-pre.tsx │ │ ├── likes-counter.tsx │ │ ├── mdx-components.tsx │ │ ├── table-of-contents.tsx │ │ └── views-counter.tsx │ ├── guestbook │ │ ├── guestbook-card.tsx │ │ ├── guestbook-entry.tsx │ │ └── guestbook-form.tsx │ ├── layout │ │ ├── content-layout.tsx │ │ ├── footer.tsx │ │ ├── header.tsx │ │ └── layout.tsx │ ├── link │ │ ├── custom-link.tsx │ │ └── unstyled-link.tsx │ ├── modal │ │ ├── image-preview.tsx │ │ └── modal.tsx │ ├── project │ │ ├── project-card.tsx │ │ ├── project-stats.tsx │ │ └── tech-icons.tsx │ ├── statistics │ │ ├── sort-icon.tsx │ │ └── table.tsx │ └── ui │ │ ├── accent.tsx │ │ ├── button.tsx │ │ ├── lazy-image.tsx │ │ ├── loading.tsx │ │ └── tooltip.tsx ├── lib │ ├── __test__ │ │ └── mdx.test.ts │ ├── api.ts │ ├── env.ts │ ├── fetcher.ts │ ├── firebase │ │ ├── app.ts │ │ ├── collections.ts │ │ └── config.ts │ ├── format.ts │ ├── helper-server.ts │ ├── helper.ts │ ├── hooks │ │ ├── useActiveHeading.ts │ │ ├── useContentLikes.ts │ │ ├── useContentViews.ts │ │ ├── useGuestbook.ts │ │ ├── useHeadingData.ts │ │ ├── useModal.ts │ │ ├── useMounted.ts │ │ ├── useNowPlayingTrack.ts │ │ └── useSessionStorage.ts │ ├── mdx-utils.ts │ ├── mdx.ts │ ├── spotify.ts │ ├── transition.ts │ └── types │ │ ├── api.ts │ │ ├── contents.ts │ │ ├── github.ts │ │ ├── guestbook.ts │ │ ├── helper.ts │ │ ├── meta.ts │ │ ├── spotify.ts │ │ └── statistics.ts ├── middleware.ts ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── about.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ ├── content │ │ │ └── [type].ts │ │ ├── guestbook │ │ │ ├── [id].tsx │ │ │ └── index.ts │ │ ├── likes │ │ │ └── [slug].ts │ │ ├── og.tsx │ │ ├── spotify.ts │ │ ├── statistics │ │ │ └── [type].ts │ │ └── views │ │ │ └── [slug].ts │ ├── blog.tsx │ ├── blog │ │ ├── custom-layout-in-nextjs.mdx │ │ ├── data-fetching-in-nextjs.mdx │ │ └── hello-world.mdx │ ├── design.tsx │ ├── guestbook.tsx │ ├── index.tsx │ ├── projects.tsx │ ├── projects │ │ └── twitter-clone.mdx │ ├── statistics.tsx │ └── subscribe.tsx └── styles │ ├── globals.scss │ ├── mdx.scss │ ├── nprogress.scss │ └── table.scss ├── tailwind.config.ts └── tsconfig.json /.env.development: -------------------------------------------------------------------------------- 1 | # Dev URL 2 | NEXT_PUBLIC_URL=http://localhost 3 | 4 | # Owner Secret 5 | NEXT_PUBLIC_OWNER_BEARER_TOKEN= 6 | 7 | # Email 8 | EMAIL_ADDRESS= 9 | EMAIL_PASSWORD= 10 | EMAIL_TARGET= 11 | 12 | # OAuth Authentication 13 | NEXTAUTH_URL=http://localhost 14 | NEXTAUTH_SECRET= 15 | GITHUB_ID= 16 | GITHUB_SECRET= 17 | 18 | # IP Address Salt 19 | IP_ADDRESS_SALT= 20 | 21 | # Firebase 22 | API_KEY= 23 | AUTH_DOMAIN= 24 | PROJECT_ID= 25 | STORAGE_BUCKET= 26 | MESSAGING_SENDER_ID= 27 | APP_ID= 28 | 29 | # GitHub 30 | GITHUB_TOKEN= 31 | 32 | # Spotify 33 | SPOTIFY_CLIENT_ID= 34 | SPOTIFY_CLIENT_SECRET= 35 | SPOTIFY_REFRESH_TOKEN= 36 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # Preview URL 2 | NEXT_PUBLIC_URL=https://$NEXT_PUBLIC_VERCEL_URL 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # next config 2 | next.config.mjs 3 | 4 | # tailwind config 5 | tailwind.config.js 6 | postcss.config.js 7 | 8 | # jest config 9 | jest.config.js 10 | 11 | # commitlint config 12 | commitlint.config.js 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "plugins": ["@typescript-eslint"], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:import/recommended", 10 | "plugin:import/typescript", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 13 | "next/core-web-vitals" 14 | ], 15 | "settings": { 16 | "import/resolver": { 17 | "typescript": true, 18 | "node": true 19 | } 20 | }, 21 | "rules": { 22 | "semi": ["error", "always"], 23 | "curly": ["warn", "multi"], 24 | "quotes": ["error", "single", { "avoidEscape": true }], 25 | "jsx-quotes": ["error", "prefer-single"], 26 | "linebreak-style": ["error", "unix"], 27 | "no-console": "warn", 28 | "comma-dangle": ["error", "never"], 29 | "no-unused-expressions": "error", 30 | "no-constant-binary-expression": "error", 31 | "import/order": [ 32 | "warn", 33 | { 34 | "pathGroups": [ 35 | { 36 | "pattern": "*.scss", 37 | "group": "builtin", 38 | "position": "before", 39 | "patternOptions": { "matchBase": true } 40 | }, 41 | { 42 | "pattern": "@lib/**", 43 | "group": "external", 44 | "position": "after" 45 | }, 46 | { 47 | "pattern": "@components/**", 48 | "group": "external", 49 | "position": "after" 50 | } 51 | ], 52 | "warnOnUnassignedImports": true, 53 | "pathGroupsExcludedImportTypes": ["type"], 54 | "groups": [ 55 | "builtin", 56 | "external", 57 | "internal", 58 | "parent", 59 | "sibling", 60 | "index", 61 | "object", 62 | "type" 63 | ] 64 | } 65 | ], 66 | "@typescript-eslint/consistent-type-imports": "warn", 67 | "@typescript-eslint/prefer-nullish-coalescing": "warn", 68 | "@typescript-eslint/explicit-function-return-type": "warn", 69 | "@typescript-eslint/no-unused-vars": [ 70 | "warn", 71 | { "args": "all", "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } 72 | ], 73 | "@typescript-eslint/no-misused-promises": [ 74 | "error", 75 | { 76 | "checksVoidReturn": { "attributes": false } 77 | } 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "portofolio-ccrsxx" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.github/workflows/deployment.yaml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deployment 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | 9 | jobs: 10 | type-check: 11 | name: ✅ Type Check 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v4 16 | 17 | - name: Download deps 18 | run: npm ci 19 | 20 | - name: Check types 21 | run: npm run type-check 22 | 23 | eslint: 24 | name: 🧪 ESLint 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout repo 28 | uses: actions/checkout@v4 29 | 30 | - name: Download deps 31 | run: npm ci 32 | 33 | - name: Lint 34 | run: npm run lint 35 | 36 | prettier: 37 | name: 🔍 Prettier 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Checkout repo 41 | uses: actions/checkout@v4 42 | 43 | - name: Download deps 44 | run: npm ci 45 | 46 | - name: Format 47 | run: npm run format 48 | 49 | jest: 50 | name: 🃏 Jest 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Checkout repo 54 | uses: actions/checkout@v4 55 | 56 | - name: Download deps 57 | run: npm ci 58 | 59 | - name: Test 60 | run: npm run test:ci 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.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 | 38 | # python 39 | *.py 40 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run type-check 5 | npm run sort-imports 6 | npm run lint 7 | 8 | npx lint-staged 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # testing 2 | /coverage 3 | 4 | # next.js 5 | /.next/ 6 | /out/ 7 | 8 | # production 9 | /build 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ccrsxx.me 2 | 3 | Coming soon... 4 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'] 3 | }; 4 | -------------------------------------------------------------------------------- /environment.d.ts: -------------------------------------------------------------------------------- 1 | type CustomEnvKeys = 2 | // Dev URL 3 | | 'NEXT_PUBLIC_URL' 4 | 5 | // Owner Secret 6 | | 'NEXT_PUBLIC_OWNER_BEARER_TOKEN' 7 | 8 | // Email 9 | | 'EMAIL_ADDRESS' 10 | | 'EMAIL_PASSWORD' 11 | | 'EMAIL_TARGET' 12 | 13 | // OAuth Authentication 14 | | 'NEXTAUTH_URL' 15 | | 'NEXTAUTH_SECRET' 16 | | 'GITHUB_ID' 17 | | 'GITHUB_SECRET' 18 | 19 | // IP Address Salt 20 | | 'IP_ADDRESS_SALT' 21 | 22 | // Firebase 23 | | 'API_KEY' 24 | | 'AUTH_DOMAIN' 25 | | 'PROJECT_ID' 26 | | 'STORAGE_BUCKET' 27 | | 'MESSAGING_SENDER_ID' 28 | | 'APP_ID' 29 | 30 | // GitHub 31 | | 'GITHUB_TOKEN' 32 | 33 | // Spotify 34 | | 'SPOTIFY_CLIENT_ID' 35 | | 'SPOTIFY_CLIENT_SECRET' 36 | | 'SPOTIFY_REFRESH_TOKEN'; 37 | 38 | type CustomEnv = Record; 39 | 40 | declare namespace NodeJS { 41 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 42 | interface ProcessEnv extends CustomEnv {} 43 | } 44 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /contents/{slug} { 5 | allow read, create, update: if true; 6 | } 7 | 8 | match /guestbook/{guestbookId} { 9 | allow read, create, delete: if true; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // jest.config.js 2 | const nextJest = require('next/jest'); 3 | 4 | const createJestConfig = nextJest({ 5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 6 | dir: './' 7 | }); 8 | 9 | // Add any custom config to be passed to Jest 10 | const customJestConfig = { 11 | // Add more setup options before each test is run 12 | // setupFilesAfterEnv: ['/jest.setup.js'], 13 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work 14 | moduleDirectories: ['node_modules', '/'], 15 | testEnvironment: 'jest-environment-jsdom', 16 | // Math aliases too instead of just baseUrl 17 | moduleNameMapper: { 18 | '^@components(.*)$': '/src/components$1', 19 | '^@lib(.*)$': '/src/lib$1', 20 | '^@styles(.*)$': '/src/styles$1' 21 | } 22 | }; 23 | 24 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 25 | module.exports = createJestConfig(customJestConfig); 26 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import nextMDX from '@next/mdx'; 2 | import rehypeSlug from 'rehype-slug'; 3 | import rehypeAutolinkHeadings from 'rehype-autolink-headings'; 4 | import rehypePrettyCode from 'rehype-pretty-code'; 5 | 6 | /** @type {import('rehype-autolink-headings').Options} */ 7 | const rehypeAutolinkHeadingsOptions = { 8 | behavior: 'wrap' 9 | }; 10 | 11 | /** @type {import('rehype-pretty-code').Options} */ 12 | const rehypePrettyCodeOptions = { 13 | // Use one of Shiki's packaged themes 14 | theme: { 15 | light: 'light-plus', 16 | dark: 'dark-plus' 17 | }, 18 | 19 | // Keep the background or use a custom background color? 20 | keepBackground: false, 21 | 22 | onVisitLine(element) { 23 | // Add a custom class to each line 24 | element.properties.className = ['line']; 25 | }, 26 | 27 | onVisitHighlightedLine(element) { 28 | // Add a custom class to each highlighted line 29 | element.properties.className.push('highlighted'); 30 | }, 31 | 32 | onVisitHighlightedChars(element) { 33 | // Add a custom class to each highlighted character 34 | element.properties.className = ['word']; 35 | } 36 | }; 37 | 38 | const withMDX = nextMDX({ 39 | extension: /\.mdx?$/, 40 | options: { 41 | // If you use remark-gfm, you'll need to use next.config.mjs 42 | // as the package is ESM only 43 | // https://github.com/remarkjs/remark-gfm#install 44 | remarkPlugins: [], 45 | rehypePlugins: [ 46 | rehypeSlug, 47 | [rehypeAutolinkHeadings, rehypeAutolinkHeadingsOptions], 48 | [rehypePrettyCode, rehypePrettyCodeOptions] 49 | ], 50 | // If you use `MDXProvider`, uncomment the following line. 51 | providerImportSource: '@mdx-js/react' 52 | } 53 | }); 54 | 55 | export default withMDX({ 56 | reactStrictMode: true, 57 | swcMinify: true, 58 | images: { 59 | domains: ['avatars.githubusercontent.com', 'i.scdn.co'] 60 | }, 61 | pageExtensions: ['ts', 'tsx', 'md', 'mdx'] 62 | }); 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "portofolio", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -p 80", 7 | "build": "next build", 8 | "start": "next start", 9 | "sort-imports": "next lint --fix .", 10 | "type-check": "tsc --noEmit", 11 | "format": "prettier --check .", 12 | "lint": "next lint --max-warnings=0", 13 | "test": "jest --watch", 14 | "test:ci": "jest --ci", 15 | "prepare": "husky install" 16 | }, 17 | "dependencies": { 18 | "@headlessui/react": "^1.7.7", 19 | "@mdx-js/loader": "^3.0.0", 20 | "@mdx-js/react": "^3.0.0", 21 | "@next/mdx": "^13.0.7", 22 | "@tanstack/match-sorter-utils": "^8.7.6", 23 | "@tanstack/react-table": "^8.7.9", 24 | "@vercel/analytics": "^1.0.1", 25 | "@vercel/og": "^0.6.0", 26 | "clsx": "^2.0.0", 27 | "firebase": "^10.0.0", 28 | "framer-motion": "^11.0.0", 29 | "next": "^13.1.4", 30 | "next-auth": "^4.19.2", 31 | "next-themes": "^0.3.0", 32 | "nodemailer": "^6.9.1", 33 | "nprogress": "^0.2.0", 34 | "react": "18.3.1", 35 | "react-dom": "18.3.1", 36 | "react-icons": "^5.0.0", 37 | "swr": "^2.0.1" 38 | }, 39 | "devDependencies": { 40 | "@commitlint/cli": "^19.0.0", 41 | "@commitlint/config-conventional": "^19.0.0", 42 | "@tailwindcss/typography": "^0.5.8", 43 | "@testing-library/jest-dom": "^6.0.0", 44 | "@testing-library/react": "^15.0.0", 45 | "@testing-library/user-event": "^14.0.0", 46 | "@types/node": "20.12.11", 47 | "@types/nodemailer": "^6.4.7", 48 | "@types/nprogress": "^0.2.0", 49 | "@types/react": "18.3.1", 50 | "@types/react-dom": "18.3.0", 51 | "@typescript-eslint/eslint-plugin": "^6.0.0", 52 | "@typescript-eslint/parser": "^6.0.0", 53 | "autoprefixer": "^10.4.8", 54 | "eslint": "8.57.0", 55 | "eslint-config-next": "13.5.6", 56 | "eslint-import-resolver-typescript": "^3.4.0", 57 | "eslint-plugin-import": "^2.26.0", 58 | "husky": "^9.0.0", 59 | "jest": "^29.0.0", 60 | "jest-environment-jsdom": "^29.0.0", 61 | "lint-staged": "^15.0.0", 62 | "postcss": "^8.4.16", 63 | "prettier": "^3.0.0", 64 | "prettier-plugin-tailwindcss": "^0.5.0", 65 | "reading-time": "^1.5.0", 66 | "rehype-autolink-headings": "^7.0.0", 67 | "rehype-pretty-code": "^0.13.1", 68 | "rehype-slug": "^6.0.0", 69 | "sass": "^1.54.4", 70 | "shiki": "^1.0.0", 71 | "tailwindcss": "^3.2.4", 72 | "typescript": "5.4.5" 73 | }, 74 | "lint-staged": { 75 | "**/*": "prettier --write --ignore-unknown" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /public/assets/blog/custom-layout-in-nextjs/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/assets/blog/custom-layout-in-nextjs/banner.jpg -------------------------------------------------------------------------------- /public/assets/blog/data-fetching-in-nextjs/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/assets/blog/data-fetching-in-nextjs/banner.jpg -------------------------------------------------------------------------------- /public/assets/blog/hello-world/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/assets/blog/hello-world/banner.jpg -------------------------------------------------------------------------------- /public/assets/emilia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/assets/emilia.png -------------------------------------------------------------------------------- /public/assets/inter-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/assets/inter-bold.ttf -------------------------------------------------------------------------------- /public/assets/inter-medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/assets/inter-medium.ttf -------------------------------------------------------------------------------- /public/assets/inter-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/assets/inter-regular.ttf -------------------------------------------------------------------------------- /public/assets/inter-semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/assets/inter-semibold.ttf -------------------------------------------------------------------------------- /public/assets/pop.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/assets/pop.mp3 -------------------------------------------------------------------------------- /public/assets/projects/twitter-clone/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/assets/projects/twitter-clone/banner.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccrsxx/portofolio/9bebac0aa61c9f44b80b8df608b426ccb84532de/public/logo512.png -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Risal Amin | Fullstack Developer", 3 | "short_name": "Risal Amin | Fullstack Developer", 4 | "description": "An online portfolio and blog by Risal Amin. Showcase some of my past projects and some of my thoughts on the world of web development.", 5 | "display": "standalone", 6 | "start_url": "/", 7 | "theme_color": "#fff", 8 | "background_color": "#000000", 9 | "orientation": "portrait", 10 | "icons": [ 11 | { 12 | "src": "/logo192.png", 13 | "type": "image/png", 14 | "sizes": "192x192" 15 | }, 16 | { 17 | "src": "/logo512.png", 18 | "type": "image/png", 19 | "sizes": "512x512" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base", "schedule:weekly", "group:allNonMajor"], 4 | "timezone": "Asia/Jakarta" 5 | } 6 | -------------------------------------------------------------------------------- /src/components/blog/blog-card.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import Image from 'next/image'; 3 | import { formatDate } from '@lib/format'; 4 | import { Accent } from '@components/ui/accent'; 5 | import { BlogStats } from './blog-stats'; 6 | import { BlogTag } from './blog-tag'; 7 | import type { Blog } from '@lib/types/contents'; 8 | import type { BlogWithViews } from '@lib/api'; 9 | import type { CustomTag, ValidTag } from '@lib/types/helper'; 10 | 11 | type BlogCardProps = CustomTag & 12 | Blog & 13 | Partial> & { 14 | isTagSelected?: (tag: string) => boolean; 15 | }; 16 | 17 | const DEFAULT_TAG = 'article' as const; 18 | 19 | export function BlogCard({ 20 | tag = DEFAULT_TAG, 21 | slug, 22 | tags, 23 | title, 24 | banner, 25 | readTime, 26 | bannerAlt, 27 | publishedAt, 28 | description, 29 | views: _views, 30 | bannerLink: _bannerLink, 31 | isTagSelected, 32 | ...rest 33 | }: BlogCardProps): JSX.Element { 34 | const CustomTag: ValidTag = tag; 35 | 36 | bannerAlt ??= title; 37 | 38 | const techTags = tags.split(','); 39 | 40 | return ( 41 | 42 | 43 |
44 | {bannerAlt} 51 |
    52 | {techTags.map((tag) => ( 53 | 58 | {isTagSelected && isTagSelected(tag) ? ( 59 | {tag} 60 | ) : ( 61 | tag 62 | )} 63 | 64 | ))} 65 |
66 |
67 |
68 |

69 | {title} 70 |

71 | 72 |

73 | {formatDate(publishedAt)} 74 |

75 |

76 | {description} 77 |

78 |
79 | 80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/components/blog/blog-stats.tsx: -------------------------------------------------------------------------------- 1 | import { HiEye, HiClock } from 'react-icons/hi2'; 2 | import { Accent } from '@components/ui/accent'; 3 | import { ViewsCounter } from '@components/content/views-counter'; 4 | import type { Blog } from '@lib/types/contents'; 5 | import type { PropsForViews } from '@lib/types/helper'; 6 | 7 | type BlogStatProps = PropsForViews>; 8 | 9 | export function BlogStats({ 10 | slug, 11 | readTime, 12 | increment 13 | }: BlogStatProps): JSX.Element { 14 | return ( 15 |
16 |
17 | 18 | {readTime} 19 |
20 |
21 | 22 | 23 | 24 | 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/blog/blog-tag.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import type { CustomTag, ValidTag } from '@lib/types/helper'; 3 | 4 | const DEFAULT_TAG = 'button' as const; 5 | 6 | export function BlogTag({ 7 | tag = DEFAULT_TAG, 8 | children, 9 | className, 10 | ...rest 11 | }: CustomTag): JSX.Element { 12 | const CustomTag: ValidTag = tag; 13 | 14 | return ( 15 | 24 | {children} 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/blog/sort-listbox.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from 'framer-motion'; 2 | import { clsx } from 'clsx'; 3 | import { Listbox } from '@headlessui/react'; 4 | import { HiEye, HiCheck, HiCalendar, HiArrowsUpDown } from 'react-icons/hi2'; 5 | import type { Dispatch, SetStateAction } from 'react'; 6 | import type { MotionProps } from 'framer-motion'; 7 | 8 | type SortListboxProps = { 9 | sortOrder: SortOption; 10 | onSortOrderChange: Dispatch>; 11 | }; 12 | 13 | export function SortListbox({ 14 | sortOrder, 15 | onSortOrderChange 16 | }: SortListboxProps): JSX.Element { 17 | return ( 18 | 19 | {({ open }): JSX.Element => ( 20 |
21 | 27 | 28 | {sortOrder === 'date' ? : } 29 | Sort by {sortOrder} 30 | 31 | 32 | 33 | 34 | 35 | 36 | {open && ( 37 | 44 | {sortOptions.map((sortOption) => ( 45 | 47 | clsx( 48 | `relative cursor-pointer select-none py-2 pl-10 pr-4 transition-colors 49 | hover:bg-blue-300/10 dark:hover:bg-blue-300/25`, 50 | active && 'bg-blue-300/10 dark:bg-blue-300/25' 51 | ) 52 | } 53 | value={sortOption} 54 | key={sortOption} 55 | > 56 | {({ selected }): JSX.Element => ( 57 | <> 58 | 64 | Sort by {sortOption} 65 | 66 | {selected && ( 67 | 71 | 72 | 73 | )} 74 | 75 | )} 76 | 77 | ))} 78 | 79 | )} 80 | 81 |
82 | )} 83 |
84 | ); 85 | } 86 | 87 | export type SortOption = (typeof sortOptions)[number]; 88 | 89 | export const sortOptions = ['date', 'views'] as const; 90 | 91 | const variants: MotionProps = { 92 | initial: { opacity: 0, y: 20 }, 93 | animate: { 94 | opacity: 1, 95 | y: 0, 96 | transition: { type: 'spring', duration: 0.4 } 97 | }, 98 | exit: { opacity: 0, y: 20, transition: { duration: 0.2 } } 99 | }; 100 | -------------------------------------------------------------------------------- /src/components/blog/subscribe-card.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@components/ui/button'; 2 | import { Accent } from '@components/ui/accent'; 3 | 4 | export function SubscribeCard(): JSX.Element { 5 | return ( 6 |
7 |

8 | Subscribe to the newsletter 9 |

10 |

11 | Get emails from me about web development, tech, and early access to new 12 | articles. 13 |

14 |
15 | 20 | 26 |
27 |

28 | Join 69 other subscribers 29 |

30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/common/app-head.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { Inter } from 'next/font/google'; 3 | 4 | const inter = Inter({ 5 | subsets: ['latin'] 6 | }); 7 | 8 | export function AppHead(): JSX.Element { 9 | return ( 10 | <> 11 | 12 | 13 | 14 | 15 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/common/seo.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { useRouter } from 'next/router'; 3 | import { useTheme } from 'next-themes'; 4 | import { PUBLIC_URL } from '@lib/env'; 5 | import type { Content, ContentType } from '@lib/types/contents'; 6 | 7 | export type Article = Pick< 8 | Content, 9 | 'tags' | 'banner' | 'publishedAt' | 'lastUpdatedAt' 10 | > & { 11 | type: ContentType; 12 | }; 13 | 14 | type MainLayoutProps = { 15 | tag?: string; 16 | title: string; 17 | image?: string; 18 | article?: Article; 19 | description: string; 20 | }; 21 | 22 | export function SEO({ 23 | title, 24 | article, 25 | description 26 | }: MainLayoutProps): JSX.Element { 27 | const { theme } = useTheme(); 28 | const { asPath } = useRouter(); 29 | 30 | const ogImageQuery = new URLSearchParams(); 31 | 32 | ogImageQuery.set('title', title); 33 | ogImageQuery.set('description', description ?? 'Description'); 34 | 35 | const { type, tags, banner, publishedAt, lastUpdatedAt } = article ?? {}; 36 | 37 | if (article) { 38 | ogImageQuery.set('type', type as string); 39 | ogImageQuery.set('article', 'true'); 40 | ogImageQuery.set('image', PUBLIC_URL + (banner?.src as string)); 41 | } 42 | 43 | const isHomepage = asPath === '/'; 44 | const isDarkMode = theme === 'dark'; 45 | 46 | const { colorScheme, themeColor } = systemTheme[+isDarkMode]; 47 | 48 | const ogTitle = `${title} | ${ 49 | isHomepage ? 'Fullstack Developer' : 'Risal Amin' 50 | }`; 51 | 52 | const ogImageUrl = `${PUBLIC_URL}/api/og?${ogImageQuery.toString()}`; 53 | 54 | const ogUrl = `${PUBLIC_URL}${isHomepage ? '' : asPath}`; 55 | 56 | return ( 57 | 58 | {ogTitle} 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | {article ? ( 84 | <> 85 | 86 | 87 | 88 | 89 | {tags 90 | ?.split(',') 91 | .map((tag) => ( 92 | 93 | ))} 94 | {lastUpdatedAt && ( 95 | 96 | )} 97 | 98 | ) : ( 99 | 100 | )} 101 | 102 | ); 103 | } 104 | 105 | type SystemTheme = { 106 | themeColor: string; 107 | colorScheme: 'dark' | 'light'; 108 | }; 109 | 110 | const systemTheme: SystemTheme[] = [ 111 | { 112 | themeColor: '#FFFFFF', 113 | colorScheme: 'light' 114 | }, 115 | { 116 | themeColor: '#222222', 117 | colorScheme: 'dark' 118 | } 119 | ]; 120 | -------------------------------------------------------------------------------- /src/components/common/spotify-card.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from 'framer-motion'; 2 | import { SiSpotify } from 'react-icons/si'; 3 | import { useNowPlayingTrack } from '@lib/hooks/useNowPlayingTrack'; 4 | import { setTransition } from '@lib/transition'; 5 | import { LazyImage } from '@components/ui/lazy-image'; 6 | import { UnstyledLink } from '@components/link/unstyled-link'; 7 | import { Tooltip } from '@components/ui/tooltip'; 8 | import type { IsPlaying } from '@lib/types/spotify'; 9 | 10 | export function SpotifyCard(): JSX.Element { 11 | const { track } = useNowPlayingTrack(); 12 | 13 | const { 14 | trackUrl, 15 | albumName, 16 | trackName, 17 | isPlaying, 18 | artistName, 19 | albumImageUrl 20 | } = (track as IsPlaying) ?? {}; 21 | 22 | return ( 23 | 24 | {!isPlaying ? null : ( 25 | 26 | 30 | 34 | {albumImageUrl && ( 35 | 43 | )} 44 |
45 |

46 | {trackName} 47 |

48 |

52 | by {artistName} 53 |

54 |

58 | on {albumName} 59 |

60 |
61 | 62 |
63 |
64 |
65 | )} 66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/common/theme-switch.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from 'framer-motion'; 2 | import { HiOutlineSun, HiOutlineMoon } from 'react-icons/hi2'; 3 | import { useTheme } from 'next-themes'; 4 | import { useMounted } from '@lib/hooks/useMounted'; 5 | import type { MotionProps } from 'framer-motion'; 6 | 7 | export function ThemeSwitch(): JSX.Element | null { 8 | const { theme, setTheme } = useTheme(); 9 | const mounted = useMounted(); 10 | 11 | if (!mounted) return null; 12 | 13 | const isDarkMode = theme === 'dark'; 14 | 15 | const flipTheme = (): void => setTheme(isDarkMode ? 'light' : 'dark'); 16 | 17 | return ( 18 | 38 | ); 39 | } 40 | 41 | const variants: MotionProps[] = [ 42 | { 43 | initial: { x: '50px', y: '25px' }, 44 | animate: { scale: 1, x: 0, y: 0, transition: { duration: 0.8 } }, 45 | exit: { x: '50px', y: '25px', transition: { duration: 0.5 } } 46 | }, 47 | { 48 | initial: { x: '-50px', y: '25px' }, 49 | animate: { scale: 1, x: 0, y: 0, transition: { duration: 0.8 } }, 50 | exit: { x: '-50px', y: '25px', transition: { duration: 0.5 } } 51 | } 52 | ]; 53 | 54 | const [moonVariants, sunVariants] = variants; 55 | -------------------------------------------------------------------------------- /src/components/content/custom-pre.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from 'framer-motion'; 2 | import { useState, useRef } from 'react'; 3 | import { HiClipboard, HiClipboardDocumentCheck } from 'react-icons/hi2'; 4 | import { useMounted } from '@lib/hooks/useMounted'; 5 | import type { 6 | CSSProperties, 7 | PropsWithChildren, 8 | ComponentPropsWithoutRef 9 | } from 'react'; 10 | import type { MotionProps } from 'framer-motion'; 11 | 12 | type PrettyCodeProps = PropsWithChildren<{ 13 | style: Pick; 14 | 'data-theme': string; 15 | 'data-language': string; 16 | }>; 17 | 18 | type CustomPreProps = ComponentPropsWithoutRef<'pre'> & 19 | Partial; 20 | 21 | export function CustomPre({ children, ...rest }: CustomPreProps): JSX.Element { 22 | const [copied, setCopied] = useState(false); 23 | const mounted = useMounted(); 24 | 25 | const preRef = useRef(null); 26 | 27 | const handleCopied = async (): Promise => { 28 | if (copied) return; 29 | setCopied(true); 30 | await navigator.clipboard.writeText(preRef.current?.textContent ?? ''); 31 | setTimeout(() => setCopied(false), 2000); 32 | }; 33 | 34 | const dataLanguage = rest['data-language']; 35 | 36 | return ( 37 | <> 38 | {mounted &&
{dataLanguage}
} 39 |
40 |         {mounted && (
41 |           
58 |         )}
59 |         {children}
60 |       
61 | 62 | ); 63 | } 64 | 65 | const variants: MotionProps = { 66 | initial: { opacity: 0, scale: 0.5 }, 67 | animate: { opacity: 1, scale: 1, transition: { duration: 0.15 } }, 68 | exit: { opacity: 0, scale: 0.5, transition: { duration: 0.1 } } 69 | }; 70 | -------------------------------------------------------------------------------- /src/components/content/likes-counter.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion'; 2 | import { clsx } from 'clsx'; 3 | import { useContentLikes } from '@lib/hooks/useContentLikes'; 4 | import type { MotionProps } from 'framer-motion'; 5 | import type { Content } from '@lib/types/contents'; 6 | 7 | export function LikesCounter({ slug }: Pick): JSX.Element { 8 | const { likeStatus, isLoading, registerLikes } = useContentLikes(slug); 9 | 10 | const { likes, userLikes } = likeStatus ?? {}; 11 | 12 | const likesLimitReached = !!(userLikes !== undefined && userLikes >= 5); 13 | const likesIsDisabled = !likeStatus || likesLimitReached; 14 | 15 | return ( 16 |
22 | 30 |

38 | {isLoading ? '...' : likes} 39 |

40 |
41 | ); 42 | } 43 | 44 | function GradientHeart({ likes }: { likes: number }): JSX.Element { 45 | return ( 46 | <> 47 | 51 | 🥳 52 | 53 | 54 | 55 | 56 | 62 | 68 | 69 | 73 | 74 | 75 | 76 | 77 | 81 | 86 | 87 | 88 | 89 | ); 90 | } 91 | 92 | const animate: Pick = { 93 | animate: { 94 | opacity: [1, 1, 1, 0], 95 | y: -48, 96 | transition: { 97 | duration: 0.7 98 | } 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /src/components/content/mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import { CustomLink } from '@components/link/custom-link'; 2 | import { CustomPre } from './custom-pre'; 3 | import type { MDXComponents } from 'mdx/types'; 4 | 5 | export const components: MDXComponents = { 6 | a: CustomLink, 7 | pre: CustomPre 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/content/table-of-contents.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import { useHeadingData } from '@lib/hooks/useHeadingData'; 3 | import { useActiveHeading } from '@lib/hooks/useActiveHeading'; 4 | import type { PropsWithChildren } from 'react'; 5 | 6 | export function TableOfContents({ children }: PropsWithChildren): JSX.Element { 7 | const headingData = useHeadingData(); 8 | const activeHeadingId = useActiveHeading(); 9 | 10 | return ( 11 | 45 | ); 46 | } 47 | 48 | const linkStyles = [ 49 | 'text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200', 50 | 'text-gray-900 dark:text-gray-100' 51 | ] as const; 52 | 53 | function getHeadingStyle( 54 | currentActiveId: string | null, 55 | targetId: string 56 | ): string { 57 | return clsx( 58 | 'smooth-tab transition', 59 | linkStyles[+(currentActiveId === targetId)] 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/content/views-counter.tsx: -------------------------------------------------------------------------------- 1 | import { formatNumber } from '@lib/format'; 2 | import { useContentViews } from '@lib/hooks/useContentViews'; 3 | import type { PropsForViews } from '@lib/types/helper'; 4 | 5 | export function ViewsCounter({ slug, increment }: PropsForViews): JSX.Element { 6 | const { views } = useContentViews(slug, { increment }); 7 | 8 | return

{typeof views === 'number' ? formatNumber(views) : '---'} views

; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/guestbook/guestbook-card.tsx: -------------------------------------------------------------------------------- 1 | import { Accent } from '@components/ui/accent'; 2 | import type { PropsWithChildren } from 'react'; 3 | 4 | export function GuestbookCard({ children }: PropsWithChildren): JSX.Element { 5 | return ( 6 |
7 |

8 | Sign the Guestbook 9 |

10 |

Share a message for a future visitor of my site.

11 | {children} 12 |

13 | Your information is only used to display your name, username, image, and 14 | reply by email. 15 |

16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/guestbook/guestbook-entry.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { motion } from 'framer-motion'; 3 | import { HiTrash } from 'react-icons/hi2'; 4 | import { formatFullTimeStamp, formatTimestamp } from '@lib/format'; 5 | import { UnstyledLink } from '@components/link/unstyled-link'; 6 | import { Button } from '@components/ui/button'; 7 | import { Tooltip } from '@components/ui/tooltip'; 8 | import { LazyImage } from '@components/ui/lazy-image'; 9 | import type { MotionProps } from 'framer-motion'; 10 | import type { CustomSession } from '@lib/types/api'; 11 | import type { Guestbook } from '@lib/types/guestbook'; 12 | 13 | type GuestbookEntryProps = Guestbook & { 14 | session: CustomSession | null; 15 | unRegisterGuestbook: (id: string) => Promise; 16 | }; 17 | 18 | export function GuestbookEntry({ 19 | id, 20 | text, 21 | name, 22 | image, 23 | session, 24 | username, 25 | createdAt, 26 | createdBy, 27 | unRegisterGuestbook 28 | }: GuestbookEntryProps): JSX.Element { 29 | const [loading, setLoading] = useState(false); 30 | 31 | const handleUnRegisterGuestbook = async (): Promise => { 32 | setLoading(true); 33 | await unRegisterGuestbook(id); 34 | }; 35 | 36 | const isOwner = session?.user.id === createdBy || session?.user.admin; 37 | 38 | const githubProfileUrl = `https://github.com/${username}`; 39 | 40 | return ( 41 | 46 | 47 | 54 | 55 |
56 |
57 | 62 | {name} 63 | 64 | 68 | 71 | 72 |
73 |

{text}

74 |
75 | {isOwner && ( 76 | 85 | )} 86 |
87 | ); 88 | } 89 | 90 | const variants: MotionProps = { 91 | initial: { opacity: 0 }, 92 | animate: { opacity: 1, transition: { duration: 0.8 } }, 93 | exit: { opacity: 0, transition: { duration: 0.2 } } 94 | }; 95 | -------------------------------------------------------------------------------- /src/components/guestbook/guestbook-form.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { signIn, signOut } from 'next-auth/react'; 3 | import { clsx } from 'clsx'; 4 | import { SiGithub } from 'react-icons/si'; 5 | import { Button } from '@components/ui/button'; 6 | import type { Text } from '@lib/types/guestbook'; 7 | import type { FormEvent } from 'react'; 8 | import type { CustomSession } from '@lib/types/api'; 9 | 10 | type GuestbookCardProps = { 11 | session: CustomSession | null; 12 | registerGuestbook: (text: Text) => Promise; 13 | }; 14 | 15 | export function GuestbookForm({ 16 | session, 17 | registerGuestbook 18 | }: GuestbookCardProps): JSX.Element { 19 | const [loading, setLoading] = useState(false); 20 | 21 | const handleSubmit = async (e: FormEvent): Promise => { 22 | e.preventDefault(); 23 | 24 | setLoading(true); 25 | 26 | const input = e.currentTarget[0] as HTMLInputElement; 27 | 28 | input.blur(); 29 | 30 | const { value } = input; 31 | 32 | await registerGuestbook(value); 33 | 34 | input.value = ''; 35 | 36 | setLoading(false); 37 | }; 38 | 39 | return ( 40 | <> 41 |
45 | 57 | {session ? ( 58 | 65 | ) : ( 66 | 74 | )} 75 |
76 | {session && ( 77 | 86 | )} 87 | 88 | ); 89 | } 90 | 91 | function handleSignIn(): void { 92 | void signIn('github'); 93 | } 94 | 95 | function handleSignOut(): void { 96 | void signOut(); 97 | } 98 | -------------------------------------------------------------------------------- /src/components/layout/content-layout.tsx: -------------------------------------------------------------------------------- 1 | import { MDXProvider } from '@mdx-js/react'; 2 | import { motion } from 'framer-motion'; 3 | import { MdHistory } from 'react-icons/md'; 4 | import { setTransition } from '@lib/transition'; 5 | import { formatDate } from '@lib/format'; 6 | import { components } from '@components/content/mdx-components'; 7 | import { SEO } from '@components/common/seo'; 8 | import { BlogCard } from '@components/blog/blog-card'; 9 | import { ProjectCard } from '@components/project/project-card'; 10 | import { BlogStats } from '@components/blog/blog-stats'; 11 | import { ImagePreview } from '@components/modal/image-preview'; 12 | import { ProjectStats } from '@components/project/project-stats'; 13 | import { TableOfContents } from '@components/content/table-of-contents'; 14 | import { UnstyledLink } from '@components/link/unstyled-link'; 15 | import { CustomLink } from '@components/link/custom-link'; 16 | import { LikesCounter } from '@components/content/likes-counter'; 17 | import { Accent } from '@components/ui/accent'; 18 | import type { ReactElement } from 'react'; 19 | import type { Blog, Project, Content } from '@lib/types/contents'; 20 | import type { ContentSlugProps } from '@lib/mdx'; 21 | import type { Article } from '@components/common/seo'; 22 | 23 | type ContentLayoutProps = { 24 | children: ReactElement; 25 | meta: Pick< 26 | Content, 27 | 'title' | 'tags' | 'publishedAt' | 'description' | 'banner' 28 | > & 29 | Pick & 30 | Pick; 31 | }; 32 | 33 | export function ContentLayout({ 34 | meta, 35 | children 36 | }: ContentLayoutProps): JSX.Element { 37 | const [ 38 | { title, description, publishedAt, banner, bannerAlt, bannerLink, tags }, 39 | { type, slug, readTime, lastUpdatedAt, suggestedContents } 40 | ] = [meta, children.props]; 41 | 42 | const contentIsBlog = type === 'blog'; 43 | 44 | const githubCommitHistoryUrl = `https://github.com/ccrsxx/portofolio/commits/main/src/pages/${type}/${slug}.mdx`; 45 | const githubContentUrl = `https://github.com/ccrsxx/portofolio/blob/main/src/pages/${type}/${slug}.mdx`; 46 | 47 | const article: Article = { 48 | type, 49 | tags, 50 | banner, 51 | publishedAt, 52 | lastUpdatedAt 53 | }; 54 | 55 | return ( 56 | 57 | 58 | 65 |
66 |

{title}

67 |

68 | Written on {formatDate(publishedAt)} by Risal Amin 69 |

70 | {lastUpdatedAt && ( 71 |
72 |

Last updated on {formatDate(lastUpdatedAt)}.

73 | 77 | 78 | View history 79 | 80 |
81 | )} 82 |
83 | {contentIsBlog ? ( 84 | 85 | ) : ( 86 | 87 | )} 88 |
89 |
90 |
91 |
92 |
93 | {children} 94 |
95 | 96 | 97 | 98 |
99 |
100 |

101 | Other {contentIsBlog ? 'posts' : type} you might like 102 |

103 |
104 | {contentIsBlog 105 | ? (suggestedContents as Blog[]).map((suggestedContent, index) => ( 106 | 107 | )) 108 | : (suggestedContents as Project[]).map( 109 | (suggestedContent, index) => ( 110 | 111 | ) 112 | )} 113 |
114 |
115 | {/* {contentIsBlog && ( 116 |
117 | 118 |
119 | )} */} 120 |
121 | ← Back to {type} 122 | Edit this on GitHub 123 |
124 |
125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /src/components/layout/footer.tsx: -------------------------------------------------------------------------------- 1 | import { HiEnvelope } from 'react-icons/hi2'; 2 | import { SiDiscord, SiGithub, SiLinkedin, SiTwitter } from 'react-icons/si'; 3 | import { Tooltip } from '@components/ui/tooltip'; 4 | import { UnstyledLink } from '@components/link/unstyled-link'; 5 | import { SpotifyCard } from '@components/common/spotify-card'; 6 | import type { IconType } from 'react-icons'; 7 | 8 | export function Footer(): JSX.Element { 9 | return ( 10 |
11 | 27 |
28 | 29 |
30 |
31 |

Reach me out

32 |
33 | {socialLinks.map(({ tip, name, href, Icon }) => ( 34 | 37 | {tip} {name} 38 | 39 | } 40 | key={name} 41 | > 42 | 46 | 47 | 48 | 49 | ))} 50 |
51 |
52 |

53 | © Risal Amin 2023 •{' '} 54 | 58 | Got any feedback? 59 | 60 |

61 |
62 | ); 63 | } 64 | 65 | type FooterLink = { 66 | name: string; 67 | href: string; 68 | tip: string | JSX.Element; 69 | }; 70 | 71 | const footerLinks: FooterLink[] = [ 72 | { 73 | name: 'Source code', 74 | href: 'https://github.com/ccrsxx/portofolio', 75 | tip: ( 76 | <> 77 | This website is open source! 78 | 79 | ) 80 | }, 81 | { 82 | name: 'Design', 83 | href: '/design', 84 | tip: 'risalamin.com color palette' 85 | }, 86 | { 87 | name: 'Statistics', 88 | href: '/statistics', 89 | tip: 'Blog & Projects statistics' 90 | } 91 | // { 92 | // name: 'Subscribe', 93 | // href: '/subscribe', 94 | // tip: 'Get notified when I publish a new post' 95 | // } 96 | ]; 97 | 98 | type SocialLink = { 99 | tip: string; 100 | name: string; 101 | href: string; 102 | Icon: IconType; 103 | }; 104 | 105 | const socialLinks: SocialLink[] = [ 106 | { 107 | tip: 'Contact me at', 108 | name: 'me@risalamin.com', 109 | href: 'mailto:me@risalamin.com', 110 | Icon: HiEnvelope 111 | }, 112 | { 113 | tip: "I'm also on", 114 | name: 'Discord', 115 | href: 'https://discord.com/users/414304208649453568', 116 | Icon: SiDiscord 117 | }, 118 | { 119 | tip: 'See my other projects on', 120 | name: 'GitHub', 121 | href: 'https://github.com/ccrsxx', 122 | Icon: SiGithub 123 | }, 124 | { 125 | tip: 'Find me on', 126 | name: 'LinkedIn', 127 | href: 'https://linkedin.com/in/risalamin', 128 | Icon: SiLinkedin 129 | }, 130 | { 131 | tip: 'Follow me on', 132 | name: 'Twitter', 133 | href: 'https://twitter.com/ccrsxx', 134 | Icon: SiTwitter 135 | } 136 | ]; 137 | -------------------------------------------------------------------------------- /src/components/layout/header.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { useRef } from 'react'; 3 | import { useRouter } from 'next/router'; 4 | import { useInView } from 'framer-motion'; 5 | import { clsx } from 'clsx'; 6 | import { ThemeSwitch } from '@components/common/theme-switch'; 7 | 8 | export function Header(): JSX.Element { 9 | const ref = useRef(null); 10 | const inView = useInView(ref, { margin: '40px 0px 0px', amount: 'all' }); 11 | 12 | const { pathname } = useRouter(); 13 | 14 | const baseRoute = '/' + pathname.split('/')[1]; 15 | 16 | return ( 17 | <> 18 |
19 |
25 |
26 |
27 | 41 | 42 |
43 |
44 | 45 | ); 46 | } 47 | 48 | const navLinks = [ 49 | { name: 'Home', href: '/' }, 50 | { name: 'Blog', href: '/blog' }, 51 | { name: 'Projects', href: '/projects' }, 52 | { name: 'Guestbook', href: '/guestbook' }, 53 | { name: 'About', href: '/about' } 54 | ] as const; 55 | -------------------------------------------------------------------------------- /src/components/layout/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from './footer'; 2 | import { Header } from './header'; 3 | import type { PropsWithChildren } from 'react'; 4 | 5 | export function Layout({ children }: PropsWithChildren): JSX.Element { 6 | return ( 7 | <> 8 |
9 | {children} 10 |