├── README.md
├── .husky
├── commit-msg
└── pre-commit
├── firestore.indexes.json
├── .env.production
├── .firebaserc
├── public
├── favicon.ico
├── logo192.png
├── logo512.png
├── assets
│ ├── pop.mp3
│ ├── blog
│ │ ├── hello-world
│ │ │ └── banner.jpg
│ │ ├── custom-layout-in-nextjs
│ │ │ └── banner.jpg
│ │ └── data-fetching-in-nextjs
│ │ │ └── banner.jpg
│ └── projects
│ │ └── twitter-clone
│ │ └── banner.png
└── site.webmanifest
├── firebase.json
├── .prettierignore
├── commitlint.config.ts
├── renovate.json
├── src
├── components
│ ├── ui
│ │ ├── accent.tsx
│ │ ├── loading.tsx
│ │ ├── lazy-image.tsx
│ │ ├── button.tsx
│ │ └── tooltip.tsx
│ ├── content
│ │ ├── mdx-components.tsx
│ │ ├── views-counter.tsx
│ │ ├── table-of-contents.tsx
│ │ ├── custom-pre.tsx
│ │ └── likes-counter.tsx
│ ├── layout
│ │ ├── layout.tsx
│ │ ├── header.tsx
│ │ ├── footer.tsx
│ │ └── content-layout.tsx
│ ├── link
│ │ ├── custom-link.tsx
│ │ └── unstyled-link.tsx
│ ├── common
│ │ ├── app-head.tsx
│ │ ├── theme-switch.tsx
│ │ ├── seo.tsx
│ │ └── currently-playing-card.tsx
│ ├── guestbook
│ │ ├── guestbook-card.tsx
│ │ ├── guestbook-form.tsx
│ │ └── guestbook-entry.tsx
│ ├── blog
│ │ ├── blog-tag.tsx
│ │ ├── blog-stats.tsx
│ │ ├── subscribe-card.tsx
│ │ ├── blog-card.tsx
│ │ └── sort-listbox.tsx
│ ├── project
│ │ ├── project-card.tsx
│ │ ├── project-stats.tsx
│ │ └── tech-icons.tsx
│ ├── statistics
│ │ ├── sort-icon.tsx
│ │ └── table.tsx
│ └── modal
│ │ ├── modal.tsx
│ │ └── image-preview.tsx
├── pages
│ ├── _document.tsx
│ ├── api
│ │ ├── auth
│ │ │ └── [...nextauth].ts
│ │ ├── content
│ │ │ └── [type].ts
│ │ ├── statistics
│ │ │ └── [type].ts
│ │ ├── views
│ │ │ └── [slug].ts
│ │ ├── guestbook
│ │ │ ├── [id].tsx
│ │ │ └── index.ts
│ │ └── likes
│ │ │ └── [slug].ts
│ ├── subscribe.tsx
│ ├── _app.tsx
│ ├── projects.tsx
│ ├── 404.tsx
│ ├── blog
│ │ ├── hello-world.mdx
│ │ ├── custom-layout-in-nextjs.mdx
│ │ └── data-fetching-in-nextjs.mdx
│ ├── statistics.tsx
│ ├── guestbook.tsx
│ ├── projects
│ │ └── twitter-clone.mdx
│ ├── design.tsx
│ ├── about.tsx
│ ├── index.tsx
│ └── blog.tsx
├── lib
│ ├── hooks
│ │ ├── use-mounted.ts
│ │ ├── use-modal.ts
│ │ ├── use-currently-playing.ts
│ │ ├── use-local-storage.ts
│ │ ├── use-session-storage.ts
│ │ ├── use-content-likes.ts
│ │ ├── use-heading-data.ts
│ │ ├── use-content-views.ts
│ │ ├── use-guestbook.ts
│ │ ├── use-active-heading.ts
│ │ ├── use-currently-playing-sse.ts
│ │ └── use-lanyard.ts
│ ├── types
│ │ ├── currently-playing.ts
│ │ ├── statistics.ts
│ │ ├── github.ts
│ │ ├── contents.ts
│ │ ├── helper.ts
│ │ ├── guestbook.ts
│ │ ├── meta.ts
│ │ └── api.ts
│ ├── firebase
│ │ ├── collections.ts
│ │ └── app.ts
│ ├── fetcher.ts
│ ├── helper-server-node.ts
│ ├── env.ts
│ ├── env-server.ts
│ ├── helper.ts
│ ├── transition.ts
│ ├── helper-server.ts
│ ├── mdx.ts
│ ├── mdx-utils.ts
│ ├── format.ts
│ └── api.ts
├── styles
│ ├── nprogress.scss
│ ├── table.scss
│ ├── mdx.scss
│ └── globals.scss
└── middleware.ts
├── .prettierrc.mjs
├── firestore.rules
├── postcss.config.mjs
├── .env.development
├── .gitignore
├── tailwind.config.ts
├── tsconfig.json
├── .github
└── workflows
│ └── deployment.yaml
├── next.config.mjs
├── package.json
└── eslint.config.ts
/README.md:
--------------------------------------------------------------------------------
1 | # Portofolio
2 |
3 | Coming soon...
4 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | npx --no -- commitlint --edit $1
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npm run type-check
2 | npm run lint
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/firestore.indexes.json:
--------------------------------------------------------------------------------
1 | {
2 | "indexes": [],
3 | "fieldOverrides": []
4 | }
5 |
--------------------------------------------------------------------------------
/.env.production:
--------------------------------------------------------------------------------
1 | # Preview URL
2 | NEXT_PUBLIC_URL=https://$NEXT_PUBLIC_VERCEL_URL
3 |
--------------------------------------------------------------------------------
/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "default": "portofolio-ccrsxx"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ccrsxx/portofolio/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ccrsxx/portofolio/HEAD/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ccrsxx/portofolio/HEAD/public/logo512.png
--------------------------------------------------------------------------------
/public/assets/pop.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ccrsxx/portofolio/HEAD/public/assets/pop.mp3
--------------------------------------------------------------------------------
/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "firestore": {
3 | "rules": "firestore.rules",
4 | "indexes": "firestore.indexes.json"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/public/assets/blog/hello-world/banner.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ccrsxx/portofolio/HEAD/public/assets/blog/hello-world/banner.jpg
--------------------------------------------------------------------------------
/public/assets/projects/twitter-clone/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ccrsxx/portofolio/HEAD/public/assets/projects/twitter-clone/banner.png
--------------------------------------------------------------------------------
/public/assets/blog/custom-layout-in-nextjs/banner.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ccrsxx/portofolio/HEAD/public/assets/blog/custom-layout-in-nextjs/banner.jpg
--------------------------------------------------------------------------------
/public/assets/blog/data-fetching-in-nextjs/banner.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ccrsxx/portofolio/HEAD/public/assets/blog/data-fetching-in-nextjs/banner.jpg
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # cache
2 | ./mypy_cache
3 |
4 | # testing
5 | /coverage
6 |
7 | # next.js
8 | /.next/
9 | /out/
10 |
11 | # production
12 | /build
13 |
--------------------------------------------------------------------------------
/commitlint.config.ts:
--------------------------------------------------------------------------------
1 | import type { UserConfig } from '@commitlint/types';
2 |
3 | export default {
4 | extends: ['@commitlint/config-conventional']
5 | } satisfies UserConfig;
6 |
--------------------------------------------------------------------------------
/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/ui/accent.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from 'react';
2 |
3 | export function Accent({ children }: PropsWithChildren): React.JSX.Element {
4 | return {children} ;
5 | }
6 |
--------------------------------------------------------------------------------
/.prettierrc.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | export default /** @satisfies {import('prettier').Config} */ ({
4 | plugins: ['prettier-plugin-tailwindcss'],
5 | singleQuote: true,
6 | jsxSingleQuote: true,
7 | trailingComma: 'none'
8 | });
9 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from 'next/document';
2 |
3 | export default function Document(): React.JSX.Element {
4 | return (
5 |
6 |
8 |
9 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | /**
4 | * @typedef {Object} Config
5 | * @property {Record<'tailwindcss' | 'autoprefixer', Record>} plugins
6 | */
7 |
8 | /** @type {Config} */
9 | const config = {
10 | plugins: {
11 | tailwindcss: {},
12 | autoprefixer: {}
13 | }
14 | };
15 |
16 | export default config;
17 |
--------------------------------------------------------------------------------
/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): React.JSX.Element {
6 | return (
7 | <>
8 |
9 | {children}
10 |
11 | >
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-mounted.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | /**
4 | * Returns a boolean value indicating whether the component is mounted on the client.
5 | */
6 | export function useMounted(): boolean {
7 | const [mounted, setMounted] = useState(false);
8 |
9 | useEffect(() => setMounted(true), []);
10 |
11 | return mounted;
12 | }
13 |
--------------------------------------------------------------------------------
/src/styles/nprogress.scss:
--------------------------------------------------------------------------------
1 | #nprogress {
2 | @apply pointer-events-none;
3 |
4 | .bar {
5 | @apply fixed left-0 top-0 z-40 h-0.5 w-full bg-black;
6 |
7 | .peg {
8 | width: 100px;
9 | box-shadow:
10 | 0 0 10px black,
11 | 0 0 5px black;
12 |
13 | @apply absolute right-0 block h-full -translate-y-1 translate-x-0 rotate-3 opacity-100;
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/link/custom-link.tsx:
--------------------------------------------------------------------------------
1 | import { Accent } from '@components/ui/accent';
2 | import { UnstyledLink, type UnstyledLinkProps } from './unstyled-link';
3 |
4 | export function CustomLink({
5 | children,
6 | ...rest
7 | }: UnstyledLinkProps): React.JSX.Element {
8 | return (
9 |
10 | {children}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/lib/types/currently-playing.ts:
--------------------------------------------------------------------------------
1 | export type Platform = 'spotify' | 'jellyfin';
2 |
3 | export type Track = {
4 | trackUrl: string | null;
5 | trackName: string;
6 | albumName: string;
7 | artistName: string;
8 | progressMs: number;
9 | durationMs: number;
10 | albumImageUrl: string | null;
11 | };
12 |
13 | export type CurrentlyPlaying = {
14 | item: Track;
15 | platform: Platform;
16 | isPlaying: boolean;
17 | };
18 |
--------------------------------------------------------------------------------
/src/lib/types/statistics.ts:
--------------------------------------------------------------------------------
1 | import type { ContentType } from './contents';
2 | import type { ContentMeta } from './meta';
3 |
4 | export type ContentStatistics = Pick & {
5 | totalPosts: number;
6 | totalViews: number;
7 | totalLikes: number;
8 | };
9 |
10 | export type ContentColumn = Pick;
11 |
12 | export type ContentData = {
13 | type: ContentType;
14 | data: ContentColumn[];
15 | };
16 |
--------------------------------------------------------------------------------
/src/lib/firebase/collections.ts:
--------------------------------------------------------------------------------
1 | import { collection } from 'firebase/firestore';
2 | import { contentMetaConverter } from '@lib/types/meta';
3 | import { guestbookConverter } from '@lib/types/guestbook';
4 | import { db } from './app';
5 |
6 | export const contentsCollection = collection(db, 'contents').withConverter(
7 | contentMetaConverter
8 | );
9 |
10 | export const guestbookCollection = collection(db, 'guestbook').withConverter(
11 | guestbookConverter
12 | );
13 |
--------------------------------------------------------------------------------
/src/components/content/views-counter.tsx:
--------------------------------------------------------------------------------
1 | import { formatNumber } from '@lib/format';
2 | import { useContentViews } from '@lib/hooks/use-content-views';
3 | import type { PropsForViews } from '@lib/types/helper';
4 |
5 | export function ViewsCounter({
6 | slug,
7 | increment
8 | }: PropsForViews): React.JSX.Element {
9 | const { views } = useContentViews(slug, { increment });
10 |
11 | return {typeof views === 'number' ? formatNumber(views) : '---'} views
;
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/types/github.ts:
--------------------------------------------------------------------------------
1 | export type FileCommitHistory = {
2 | sha: string;
3 | commit: Commit;
4 | node_id: string;
5 | };
6 |
7 | type Commit = {
8 | message: string;
9 | author: {
10 | name: string;
11 | date: string;
12 | email: string;
13 | };
14 | };
15 |
16 | export type GithubUser = {
17 | id: number;
18 | bio: string | null;
19 | name: string | null;
20 | login: string;
21 | email: string | null;
22 | node_id: string;
23 | avatar_url: string;
24 | };
25 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-modal.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | type UseModal = {
4 | open: boolean;
5 | openModal: () => void;
6 | closeModal: () => void;
7 | };
8 |
9 | /**
10 | * Returns a object with the modal state and functions to open and close it.
11 | */
12 | export function useModal(): UseModal {
13 | const [open, setOpen] = useState(false);
14 |
15 | const openModal = (): void => setOpen(true);
16 | const closeModal = (): void => setOpen(false);
17 |
18 | return { open, openModal, closeModal };
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/ui/loading.tsx:
--------------------------------------------------------------------------------
1 | import { clsx } from 'clsx';
2 | import { ImSpinner8 } from 'react-icons/im';
3 |
4 | type LoadingProps = {
5 | className?: string;
6 | iconClassName?: string;
7 | };
8 |
9 | export function Loading({
10 | className = 'p-4',
11 | iconClassName = 'h-7 w-7'
12 | }: LoadingProps): React.JSX.Element {
13 | return (
14 |
15 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/.env.development:
--------------------------------------------------------------------------------
1 | # Dev URL
2 | NEXT_PUBLIC_URL=http://localhost
3 | NEXT_PUBLIC_BACKEND_URL=http://localhost
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=http://localhost
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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/src/lib/fetcher.ts:
--------------------------------------------------------------------------------
1 | import { frontendEnv } from './env';
2 | import type { ValidApiEndpoints } from './types/api';
3 |
4 | /**
5 | * A fetcher function that adds the owner bearer token to the request.
6 | */
7 | export async function fetcher(
8 | input: ValidApiEndpoints | Request,
9 | init?: RequestInit
10 | ): Promise {
11 | const res = await fetch(input, {
12 | ...init,
13 | headers: {
14 | ...init?.headers,
15 | Authorization: `Bearer ${frontendEnv.NEXT_PUBLIC_OWNER_BEARER_TOKEN}`
16 | }
17 | });
18 |
19 | const data = (await res.json()) as T;
20 |
21 | return data;
22 | }
23 |
--------------------------------------------------------------------------------
/src/lib/types/contents.ts:
--------------------------------------------------------------------------------
1 | import type { StaticImageData } from 'next/image';
2 |
3 | export type Content = {
4 | tags: string;
5 | slug: string;
6 | title: string;
7 | banner: StaticImageData;
8 | readTime: string;
9 | description: string;
10 | publishedAt: string;
11 | lastUpdatedAt?: string;
12 | };
13 |
14 | export type Blog = Content & {
15 | bannerAlt?: string;
16 | bannerLink?: string;
17 | };
18 |
19 | export type Project = Content & {
20 | link?: string;
21 | github?: string;
22 | youtube?: string;
23 | category?: string;
24 | };
25 |
26 | export type ContentType = 'blog' | 'projects';
27 |
--------------------------------------------------------------------------------
/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(): React.JSX.Element {
9 | return (
10 | <>
11 |
12 |
13 |
14 |
15 |
22 | >
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/lib/types/helper.ts:
--------------------------------------------------------------------------------
1 | import type { Content } from './contents';
2 | import type {
3 | HTMLAttributes,
4 | PropsWithChildren,
5 | ComponentPropsWithoutRef
6 | } from 'react';
7 |
8 | export type ValidTag = keyof React.JSX.IntrinsicElements;
9 |
10 | export type CustomTag = PropsWithChildren<
11 | {
12 | tag?: T | ValidTag;
13 | } & (ComponentPropsWithoutRef & HTMLAttributes)
14 | >;
15 |
16 | export type APIResponse = T | { message: string };
17 |
18 | export type PropsForViews = T &
19 | Pick & {
20 | increment?: boolean;
21 | };
22 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import colors from 'tailwindcss/colors';
2 | import typography from '@tailwindcss/typography';
3 | import type { Config } from 'tailwindcss';
4 |
5 | export default {
6 | content: ['./src/pages/**/*.tsx', './src/components/**/*.tsx'],
7 | darkMode: 'class',
8 | theme: {
9 | extend: {
10 | fontFamily: {
11 | inter: ['var(--font-inter)']
12 | },
13 | colors: {
14 | accent: {
15 | main: colors.pink[400],
16 | start: colors.purple[500],
17 | end: colors.pink[400]
18 | }
19 | }
20 | }
21 | },
22 | plugins: [typography]
23 | } satisfies Config;
24 |
--------------------------------------------------------------------------------
/src/lib/helper-server-node.ts:
--------------------------------------------------------------------------------
1 | import { createHash } from 'crypto';
2 | import { backendEnv } from './env-server';
3 | import type { NextApiRequest } from 'next';
4 |
5 | /**
6 | * Returns a hashed session id from the user's IP address.
7 | */
8 | export function getSessionId(req: NextApiRequest): string {
9 | const ipAddressFromHeaders = req.headers['x-forwarded-for'] as
10 | | string
11 | | undefined;
12 |
13 | const ipAddress = ipAddressFromHeaders ?? '127.0.0.1';
14 |
15 | const hashedIpAddress = createHash('md5')
16 | .update(ipAddress + backendEnv.IP_ADDRESS_SALT)
17 | .digest('hex');
18 |
19 | return hashedIpAddress;
20 | }
21 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse, type NextRequest } from 'next/server';
2 | import { getBearerToken, generateNextResponse } from '@lib/helper-server';
3 | import { frontendEnv } from '@lib/env';
4 |
5 | export function middleware(req: NextRequest): NextResponse {
6 | const bearerToken = getBearerToken(req);
7 |
8 | if (bearerToken !== frontendEnv.NEXT_PUBLIC_OWNER_BEARER_TOKEN) {
9 | return generateNextResponse(401, 'Unauthorized');
10 | }
11 |
12 | return NextResponse.next();
13 | }
14 |
15 | type Config = {
16 | matcher: string;
17 | };
18 |
19 | export const config: Config = {
20 | // Match all API routes except /api/auth/*
21 | matcher: '/api/((?!auth).*)'
22 | };
23 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/lib/types/guestbook.ts:
--------------------------------------------------------------------------------
1 | import type { FirestoreDataConverter, Timestamp } from 'firebase/firestore';
2 |
3 | export type Guestbook = {
4 | id: string;
5 | text: string;
6 | name: string;
7 | email: string;
8 | image: string;
9 | username: string;
10 | createdAt: Timestamp;
11 | createdBy: string;
12 | };
13 |
14 | export type Text = Guestbook['text'];
15 |
16 | export const guestbookConverter: FirestoreDataConverter = {
17 | toFirestore(guestbook) {
18 | return guestbook;
19 | },
20 | fromFirestore(snapshot, options) {
21 | const { id } = snapshot;
22 | const data = snapshot.data(options);
23 |
24 | return { id, ...data } as Guestbook;
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/src/lib/firebase/app.ts:
--------------------------------------------------------------------------------
1 | import { type FirebaseApp, getApp, initializeApp } from 'firebase/app';
2 | import { getFirestore } from 'firebase/firestore';
3 | import { backendEnv } from '@lib/env-server';
4 |
5 | function initializeFirebaseApp(): FirebaseApp {
6 | try {
7 | return getApp();
8 | } catch {
9 | return initializeApp({
10 | appId: backendEnv.APP_ID,
11 | apiKey: backendEnv.API_KEY,
12 | projectId: backendEnv.PROJECT_ID,
13 | authDomain: backendEnv.AUTH_DOMAIN,
14 | storageBucket: backendEnv.STORAGE_BUCKET,
15 | messagingSenderId: backendEnv.MESSAGING_SENDER_ID
16 | });
17 | }
18 | }
19 |
20 | initializeFirebaseApp();
21 |
22 | export const db = getFirestore();
23 |
--------------------------------------------------------------------------------
/src/components/guestbook/guestbook-card.tsx:
--------------------------------------------------------------------------------
1 | import { Accent } from '@components/ui/accent';
2 | import type { PropsWithChildren } from 'react';
3 |
4 | export function GuestbookCard({
5 | children
6 | }: PropsWithChildren): React.JSX.Element {
7 | return (
8 |
9 |
10 | Sign the Guestbook
11 |
12 |
Share a message for a future visitor of my site.
13 | {children}
14 |
15 | Your information is only used to display your name, username, image, and
16 | reply by email.
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/ui/lazy-image.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { clsx } from 'clsx';
3 | import Image, { type ImageProps } from 'next/image';
4 |
5 | export function LazyImage({
6 | src,
7 | alt,
8 | width,
9 | height,
10 | className,
11 | ...rest
12 | }: ImageProps): React.JSX.Element {
13 | const [loading, setLoading] = useState(true);
14 |
15 | const handleLoadingComplete = (): void => setLoading(false);
16 |
17 | return (
18 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": ".",
18 | "paths": {
19 | "@components/*": ["src/components/*"],
20 | "@lib/*": ["src/lib/*"],
21 | "@styles/*": ["src/styles/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.mjs"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/src/lib/types/meta.ts:
--------------------------------------------------------------------------------
1 | import type { FirestoreDataConverter } from 'firebase/firestore';
2 | import type { ContentType } from './contents';
3 |
4 | export type ContentMeta = {
5 | slug: string;
6 | type: ContentType;
7 | views: number;
8 | likes: number;
9 | likesBy: Record;
10 | };
11 |
12 | export type Views = ContentMeta['views'];
13 |
14 | export type LikeStatus = {
15 | likes: number;
16 | userLikes: number;
17 | };
18 |
19 | export const contentMetaConverter: FirestoreDataConverter = {
20 | toFirestore(contentMeta) {
21 | return contentMeta;
22 | },
23 | fromFirestore(snapshot, options) {
24 | const { id: slug } = snapshot;
25 | const data = snapshot.data(options);
26 |
27 | return { slug, ...data } as ContentMeta;
28 | }
29 | };
30 |
--------------------------------------------------------------------------------
/src/components/link/unstyled-link.tsx:
--------------------------------------------------------------------------------
1 | import Link, { type LinkProps } from 'next/link';
2 | import type { ComponentPropsWithoutRef } from 'react';
3 |
4 | export type UnstyledLinkProps = ComponentPropsWithoutRef<'a'> &
5 | Partial;
6 |
7 | export function UnstyledLink({
8 | href = '',
9 | children,
10 | ...rest
11 | }: UnstyledLinkProps): React.JSX.Element {
12 | const openInNewTab = !href.startsWith('/');
13 |
14 | if (!openInNewTab)
15 | return (
16 |
17 | {children}
18 |
19 | );
20 |
21 | const linkIsExternal = href.startsWith('http');
22 |
23 | return (
24 |
29 | {children}
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/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): React.JSX.Element {
12 | const CustomTag: ValidTag = tag;
13 |
14 | return (
15 |
24 | {children}
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/styles/table.scss:
--------------------------------------------------------------------------------
1 | table {
2 | @apply w-full table-auto border-collapse text-sm;
3 |
4 | &,
5 | tbody {
6 | @apply divide-y divide-gray-300 dark:divide-gray-600;
7 | }
8 |
9 | & > :is(thead, tbody, tfoot) > tr > :is(th, td) {
10 | @apply p-4 font-medium;
11 | }
12 |
13 | & > :is(thead, tfoot) > tr > :is(th, td) {
14 | @apply uppercase tracking-wider text-gray-500 dark:text-gray-200;
15 | }
16 |
17 | & > :is(tbody, tfoot) > tr > td {
18 | &:first-child {
19 | @apply text-left;
20 | }
21 |
22 | &:not(:first-child) {
23 | @apply text-right;
24 | }
25 | }
26 |
27 | thead > tr > th:first-child > div {
28 | @apply flex-row-reverse;
29 | }
30 |
31 | tbody > tr > td {
32 | &:first-child {
33 | @apply text-gray-900 dark:text-gray-50;
34 | }
35 |
36 | &:not(:first-child) {
37 | @apply text-gray-500 dark:text-gray-50;
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-currently-playing.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 | import { fetcher } from '@lib/fetcher';
3 | import { frontendEnv } from '@lib/env';
4 | import type {
5 | ValidApiEndpoints,
6 | BackendSuccessApiResponse
7 | } from '@lib/types/api';
8 | import type { CurrentlyPlaying } from '@lib/types/currently-playing';
9 |
10 | type UseCurrentlyPlaying = {
11 | data?: BackendSuccessApiResponse;
12 | isLoading: boolean;
13 | };
14 |
15 | /**
16 | * Get the current playing track from Spotify.
17 | */
18 | export function useCurrentlyPlaying(): UseCurrentlyPlaying {
19 | const { data, isLoading } = useSWR<
20 | BackendSuccessApiResponse,
21 | unknown,
22 | ValidApiEndpoints
23 | >(
24 | `${frontendEnv.NEXT_PUBLIC_BACKEND_URL}/spotify/currently-playing`,
25 | fetcher,
26 | {
27 | refreshInterval: 5000
28 | }
29 | );
30 |
31 | return { data, isLoading };
32 | }
33 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-local-storage.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, type Dispatch, type SetStateAction } from 'react';
2 |
3 | /**
4 | * Get state from local storage.
5 | *
6 | * @param key The key of the state.
7 | * @param initialValue The initial value of the state.
8 | * @returns The state and the state setter. State is fallback to the initial value if the local storage is empty.
9 | */
10 | export function useLocalStorage(
11 | key: string,
12 | initialValue: T
13 | ): [T, Dispatch>] {
14 | const [value, setValue] = useState(() => {
15 | const savedValue =
16 | typeof window !== 'undefined' ? localStorage.getItem(key) : null;
17 | const parsedValue = savedValue ? (JSON.parse(savedValue) as T) : null;
18 | return parsedValue ?? initialValue;
19 | });
20 |
21 | useEffect(() => {
22 | localStorage.setItem(key, JSON.stringify(value));
23 | }, [key, value]);
24 |
25 | return [value, setValue];
26 | }
27 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-session-storage.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, type Dispatch, type SetStateAction } from 'react';
2 |
3 | /**
4 | * Get state from session storage.
5 | *
6 | * @param key The key of the state.
7 | * @param initialValue The initial value of the state.
8 | * @returns The state and the state setter. State is fallback to the initial value if the session storage is empty.
9 | */
10 | export function useSessionStorage(
11 | key: string,
12 | initialValue: T
13 | ): [T, Dispatch>] {
14 | const [value, setValue] = useState(() => {
15 | const savedValue =
16 | typeof window !== 'undefined' ? sessionStorage.getItem(key) : null;
17 | const parsedValue = savedValue ? (JSON.parse(savedValue) as T) : null;
18 | return parsedValue ?? initialValue;
19 | });
20 |
21 | useEffect(() => {
22 | sessionStorage.setItem(key, JSON.stringify(value));
23 | }, [key, value]);
24 |
25 | return [value, setValue];
26 | }
27 |
--------------------------------------------------------------------------------
/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): React.JSX.Element {
14 | return (
15 |
16 |
17 |
18 |
{readTime}
19 |
20 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/.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:check
48 |
--------------------------------------------------------------------------------
/src/components/project/project-card.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import Image from 'next/image';
3 | import { TechIcons } from '@components/project/tech-icons';
4 | import type { Project } from '@lib/types/contents';
5 |
6 | export function ProjectCard({
7 | slug,
8 | tags,
9 | title,
10 | banner,
11 | description
12 | }: Omit): React.JSX.Element {
13 | return (
14 |
15 |
16 | {title}
17 |
18 | {description}
19 |
20 |
21 |
27 | See more →
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-content-likes.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 | import { fetcher } from '@lib/fetcher';
3 | import type { ValidApiEndpoints } from '@lib/types/api';
4 | import type { LikeStatus } from '@lib/types/meta';
5 |
6 | type UseContentLikes = {
7 | likeStatus?: LikeStatus;
8 | isLoading: boolean;
9 | registerLikes: () => Promise;
10 | };
11 |
12 | /**
13 | * Returns the likes of the content and a function to register likes.
14 | */
15 | export function useContentLikes(slug: string): UseContentLikes {
16 | const {
17 | data: likeStatus,
18 | isLoading,
19 | mutate
20 | } = useSWR(
21 | `/api/likes/${slug}`,
22 | fetcher
23 | );
24 |
25 | const registerLikes = async (): Promise => {
26 | if (!likeStatus || likeStatus.userLikes >= 5) return;
27 |
28 | const likes = await fetcher(`/api/likes/${slug}`, {
29 | method: 'POST'
30 | });
31 |
32 | await mutate(likes);
33 | };
34 |
35 | return { likeStatus, isLoading, registerLikes };
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { clsx } from 'clsx';
2 | import { Loading } from './loading';
3 | import type { ComponentProps } from 'react';
4 |
5 | type ButtonProps = ComponentProps<'button'> & {
6 | loading?: boolean;
7 | };
8 |
9 | export function Button({
10 | className,
11 | loading,
12 | disabled,
13 | children,
14 | ...rest
15 | }: ButtonProps): React.JSX.Element {
16 | // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
17 | const isDisabled = loading || disabled;
18 |
19 | return (
20 |
31 | {loading && (
32 |
36 | )}
37 | {children}
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import NextAuth from 'next-auth/next';
2 | import GithubProvider from 'next-auth/providers/github';
3 | import { backendEnv } from '@lib/env-server';
4 | import { getGithubUsername } from '@lib/helper-server';
5 | import type { AuthOptions, Session } from 'next-auth';
6 | import type { CustomSession, AssertedUser } from '@lib/types/api';
7 |
8 | export const authOptions: AuthOptions = {
9 | providers: [
10 | GithubProvider({
11 | clientId: backendEnv.GITHUB_ID,
12 | clientSecret: backendEnv.GITHUB_SECRET
13 | })
14 | ],
15 | callbacks: {
16 | async session({ session, token }): Promise {
17 | const id = token.sub as string;
18 | const username = await getGithubUsername(id);
19 |
20 | const typedSession = session as Session & { user: AssertedUser };
21 |
22 | const admin = username === 'ccrsxx';
23 |
24 | return {
25 | ...typedSession,
26 | user: { ...typedSession.user, id, username, admin }
27 | };
28 | }
29 | }
30 | };
31 |
32 | export default NextAuth(authOptions);
33 |
--------------------------------------------------------------------------------
/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(): React.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 |
24 | Subscribe
25 |
26 |
27 |
28 | Join 69 other subscribers
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/statistics/sort-icon.tsx:
--------------------------------------------------------------------------------
1 | import { clsx } from 'clsx';
2 | import { HiChevronUp, HiChevronDown } from 'react-icons/hi2';
3 | import type { IconType } from 'react-icons';
4 | import type { SortDirection } from '@tanstack/react-table';
5 |
6 | type SortIconProps = {
7 | isSorted: false | SortDirection;
8 | sortDirection: SortDirection;
9 | };
10 |
11 | export function SortIcon({
12 | isSorted,
13 | sortDirection
14 | }: SortIconProps): React.JSX.Element {
15 | const Icon = Icons[sortDirection];
16 |
17 | return (
18 |
24 |
30 |
31 | );
32 | }
33 |
34 | const Icons: Record = {
35 | asc: HiChevronUp,
36 | desc: HiChevronDown
37 | };
38 |
39 | const arrowStyles = [
40 | 'text-gray-400 dark:text-gray-500',
41 | 'text-gray-700 dark:text-gray-200'
42 | ] as const;
43 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-heading-data.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | type UseHeadingData = {
4 | id: string;
5 | title: string;
6 | items: Omit[];
7 | };
8 |
9 | /**
10 | * Returns an array of heading data.
11 | */
12 | export function useHeadingData(): UseHeadingData[] {
13 | const [headingData, setHeadingData] = useState([]);
14 |
15 | useEffect(() => {
16 | const headingElements: HTMLHeadingElement[] = Array.from(
17 | document.querySelectorAll('#mdx-article :is(h2, h3)')
18 | );
19 |
20 | const newHeadingData = headingElements.reduce((acc, heading) => {
21 | const { id, nodeName, textContent } = heading;
22 | const title = textContent as string;
23 |
24 | if (nodeName === 'H2') acc.push({ id, title, items: [] });
25 | else if (nodeName === 'H3' && acc.length) {
26 | const lastNestedHeading = acc[acc.length - 1];
27 | lastNestedHeading.items.push({ id, title });
28 | }
29 |
30 | return acc;
31 | }, [] as UseHeadingData[]);
32 |
33 | setHeadingData(newHeadingData);
34 | }, []);
35 |
36 | return headingData;
37 | }
38 |
--------------------------------------------------------------------------------
/src/pages/api/content/[type].ts:
--------------------------------------------------------------------------------
1 | import { isValidContentType } from '@lib/helper-server';
2 | import { getContentData } from '@lib/api';
3 | import type { NextApiRequest, NextApiResponse } from 'next';
4 | import type { APIResponse } from '@lib/types/helper';
5 | import type { ContentType } from '@lib/types/contents';
6 | import type { ContentData } from '@lib/types/statistics';
7 |
8 | export default async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse>
11 | ): Promise {
12 | const { type } = req.query as { type: ContentType };
13 |
14 | if (!isValidContentType(type))
15 | return res.status(400).json({ message: 'Invalid content type' });
16 |
17 | try {
18 | if (req.method === 'get') {
19 | const contentData = await getContentData(type);
20 |
21 | return res.status(200).json(contentData);
22 | }
23 | } catch (error) {
24 | if (error instanceof Error)
25 | return res.status(500).json({ message: error.message });
26 |
27 | return res.status(500).json({ message: 'Internal server error' });
28 | }
29 |
30 | return res.status(405).json({ message: 'Method not allowed' });
31 | }
32 |
--------------------------------------------------------------------------------
/src/pages/api/statistics/[type].ts:
--------------------------------------------------------------------------------
1 | import { isValidContentType } from '@lib/helper-server';
2 | import { getContentStatistics } from '@lib/api';
3 | import type { NextApiRequest, NextApiResponse } from 'next';
4 | import type { APIResponse } from '@lib/types/helper';
5 | import type { ContentType } from '@lib/types/contents';
6 | import type { ContentStatistics } from '@lib/types/statistics';
7 |
8 | export default async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse>
11 | ): Promise {
12 | const { type } = req.query as { type: ContentType };
13 |
14 | if (!isValidContentType(type))
15 | return res.status(400).json({ message: 'Invalid content type' });
16 |
17 | try {
18 | if (req.method === 'GET') {
19 | const contentStatistics = await getContentStatistics(type);
20 |
21 | return res.status(200).json(contentStatistics);
22 | }
23 | } catch (error) {
24 | if (error instanceof Error)
25 | return res.status(500).json({ message: error.message });
26 |
27 | return res.status(500).json({ message: 'Internal server error' });
28 | }
29 |
30 | return res.status(405).json({ message: 'Method not allowed' });
31 | }
32 |
--------------------------------------------------------------------------------
/src/pages/subscribe.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import { setTransition } from '@lib/transition';
3 | import { SubscribeCard } from '@components/blog/subscribe-card';
4 | import { SEO } from '@components/common/seo';
5 | import { Accent } from '@components/ui/accent';
6 |
7 | export default function Subscribe(): React.JSX.Element {
8 | return (
9 |
10 |
14 |
15 |
19 | Subscribe to risalamin.com
20 |
21 |
25 | Get notified when I publish a new post.
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/lib/env.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | export const validStringSchema = z.string().trim().min(1);
4 |
5 | const envSchema = z.object({
6 | NEXT_PUBLIC_URL: validStringSchema,
7 | NEXT_PUBLIC_BACKEND_URL: validStringSchema,
8 | NEXT_PUBLIC_OWNER_BEARER_TOKEN: validStringSchema
9 | });
10 |
11 | type EnvSchema = z.infer;
12 |
13 | function validateEnv(): EnvSchema {
14 | let { data, error } = envSchema.safeParse({
15 | NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL,
16 | NEXT_PUBLIC_BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL,
17 | NEXT_PUBLIC_OWNER_BEARER_TOKEN: process.env.NEXT_PUBLIC_OWNER_BEARER_TOKEN
18 | });
19 |
20 | const runningOnCi = process.env.CI === 'true';
21 |
22 | if (runningOnCi) {
23 | data = process.env as unknown as EnvSchema;
24 | }
25 |
26 | const shouldThrowError = error && !runningOnCi;
27 |
28 | if (shouldThrowError) {
29 | throw new Error(`Environment validation error: ${error.message}`);
30 | }
31 |
32 | return data as EnvSchema;
33 | }
34 |
35 | export const frontendEnv = validateEnv();
36 |
37 | export const IS_DEVELOPMENT = process.env.NODE_ENV === 'development';
38 |
39 | export const IS_PRODUCTION = process.env.NODE_ENV === 'production';
40 |
--------------------------------------------------------------------------------
/src/lib/types/api.ts:
--------------------------------------------------------------------------------
1 | import type { Session, User } from 'next-auth';
2 | import type { ContentType } from './contents';
3 |
4 | type SlugEndPoints = 'views' | 'likes' | 'guestbook';
5 |
6 | type NextJsApiEndpoints =
7 | | '/api/contents'
8 | | '/api/guestbook'
9 | | `/api/contents/${ContentType}`
10 | | `/api/${SlugEndPoints}/${string}`;
11 |
12 | type BackendApiEndpoints =
13 | | `${string}/og`
14 | | `${string}/spotify/currently-playing`;
15 |
16 | export type BackendSuccessApiResponse = {
17 | data: T | null;
18 | };
19 |
20 | export type BackendErrorApiResponse = {
21 | error: {
22 | id: string;
23 | message: string;
24 | details: string[];
25 | };
26 | };
27 |
28 | export type BackendApiResponse =
29 | | BackendSuccessApiResponse
30 | | BackendErrorApiResponse;
31 |
32 | export type ValidApiEndpoints = NextJsApiEndpoints | BackendApiEndpoints;
33 |
34 | type DefaultUser = Required>;
35 |
36 | export type AssertedUser = {
37 | [K in keyof DefaultUser]: NonNullable;
38 | };
39 |
40 | type CustomUser = AssertedUser & {
41 | id: string;
42 | admin: boolean;
43 | username: string;
44 | };
45 |
46 | export type CustomSession = Session & {
47 | user: CustomUser;
48 | };
49 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-content-views.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect } from 'react';
2 | import useSWR from 'swr';
3 | import { fetcher } from '@lib/fetcher';
4 | import type { ValidApiEndpoints } from '@lib/types/api';
5 | import type { Views } from '@lib/types/meta';
6 |
7 | type UseContentViews = {
8 | views?: Views;
9 | isLoading: boolean;
10 | };
11 |
12 | /**
13 | * Returns the views of the content.
14 | */
15 | export function useContentViews(
16 | slug: string,
17 | { increment }: { increment?: boolean } = {}
18 | ): UseContentViews {
19 | const {
20 | data: views,
21 | isLoading,
22 | mutate
23 | } = useSWR(`/api/views/${slug}`, fetcher);
24 |
25 | const firstRender = useRef(true);
26 |
27 | useEffect(() => {
28 | if (!increment || !firstRender.current) return;
29 |
30 | const registerViews = async (): Promise => {
31 | const views = await fetcher(`/api/views/${slug}`, {
32 | method: 'POST'
33 | });
34 |
35 | await mutate(views);
36 | };
37 |
38 | void registerViews();
39 |
40 | return (): void => {
41 | firstRender.current = false;
42 | };
43 | // eslint-disable-next-line react-hooks/exhaustive-deps
44 | }, []);
45 |
46 | return { views, isLoading };
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import { clsx } from 'clsx';
2 | import type { ValidTag, CustomTag } from '@lib/types/helper';
3 |
4 | type TooltipProps = CustomTag & {
5 | tip: string | React.JSX.Element;
6 | tooltipClassName?: string;
7 | };
8 |
9 | const DEFAULT_TAG = 'div' as const;
10 |
11 | export function Tooltip({
12 | tag = DEFAULT_TAG,
13 | tip,
14 | children,
15 | className,
16 | tooltipClassName = 'group-hover:-translate-y-16 peer-focus-visible:-translate-y-16',
17 | ...rest
18 | }: TooltipProps): React.JSX.Element {
19 | const CustomTag: ValidTag = tag;
20 |
21 | return (
22 |
23 | {children}
24 |
32 | {tip}
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/pages/api/views/[slug].ts:
--------------------------------------------------------------------------------
1 | import { doc, getDoc, increment, updateDoc } from 'firebase/firestore';
2 | import { contentsCollection } from '@lib/firebase/collections';
3 | import type { NextApiRequest, NextApiResponse } from 'next';
4 | import type { APIResponse } from '@lib/types/helper';
5 | import type { Views } from '@lib/types/meta';
6 |
7 | export default async function handler(
8 | req: NextApiRequest,
9 | res: NextApiResponse>
10 | ): Promise {
11 | const { slug } = req.query as { slug: string };
12 |
13 | try {
14 | const docRef = doc(contentsCollection, slug);
15 |
16 | const snapshot = await getDoc(docRef);
17 | const data = snapshot.data();
18 |
19 | if (!data) return res.status(404).json({ message: 'Content not found' });
20 |
21 | const { views } = data;
22 |
23 | if (req.method === 'GET') return res.status(200).json(views);
24 |
25 | if (req.method === 'POST') {
26 | await updateDoc(docRef, {
27 | views: increment(1)
28 | });
29 |
30 | return res.status(201).json(views + 1);
31 | }
32 | } catch (error) {
33 | if (error instanceof Error)
34 | return res.status(500).json({ message: error.message });
35 |
36 | return res.status(500).json({ message: 'Internal server error' });
37 | }
38 |
39 | return res.status(405).json({ message: 'Method not allowed' });
40 | }
41 |
--------------------------------------------------------------------------------
/src/lib/env-server.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 | import { validStringSchema } from './env';
3 |
4 | const envSchema = z.object({
5 | // Email
6 | EMAIL_TARGET: validStringSchema,
7 | EMAIL_ADDRESS: validStringSchema,
8 | EMAIL_PASSWORD: validStringSchema,
9 |
10 | // Secrets
11 | GITHUB_TOKEN: validStringSchema,
12 | IP_ADDRESS_SALT: validStringSchema,
13 |
14 | // NextAuth
15 | NEXTAUTH_URL: validStringSchema,
16 | NEXTAUTH_SECRET: validStringSchema,
17 | GITHUB_ID: validStringSchema,
18 | GITHUB_SECRET: validStringSchema,
19 |
20 | // Firebase
21 | API_KEY: validStringSchema,
22 | AUTH_DOMAIN: validStringSchema,
23 | PROJECT_ID: validStringSchema,
24 | STORAGE_BUCKET: validStringSchema,
25 | MESSAGING_SENDER_ID: validStringSchema,
26 | APP_ID: validStringSchema
27 | });
28 |
29 | type EnvSchema = z.infer;
30 |
31 | function validateEnv(): EnvSchema {
32 | let { data, error } = envSchema.safeParse(process.env);
33 |
34 | const runningOnCi = process.env.CI === 'true';
35 |
36 | if (runningOnCi) {
37 | data = process.env as unknown as EnvSchema;
38 | }
39 |
40 | const shouldThrowError = error && !runningOnCi;
41 |
42 | if (shouldThrowError) {
43 | throw new Error(`Environment validation error: ${error.message}`);
44 | }
45 |
46 | return data as EnvSchema;
47 | }
48 |
49 | export const backendEnv = validateEnv();
50 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import '@styles/globals.scss';
2 |
3 | import { configure, start, done } from 'nprogress';
4 | import { AnimatePresence } from 'framer-motion';
5 | import { Router, useRouter } from 'next/router';
6 | import { useEffect } from 'react';
7 | import { Analytics } from '@vercel/analytics/react';
8 | import { ThemeProvider } from 'next-themes';
9 | import { Layout } from '@components/layout/layout';
10 | import { AppHead } from '@components/common/app-head';
11 | import type { AppProps } from 'next/app';
12 |
13 | configure({ showSpinner: false });
14 |
15 | Router.events.on('routeChangeStart', start);
16 | Router.events.on('routeChangeError', done);
17 | Router.events.on('routeChangeComplete', done);
18 |
19 | const popAudio =
20 | typeof window !== 'undefined' ? new Audio('/assets/pop.mp3') : null;
21 |
22 | export default function App({
23 | Component,
24 | pageProps
25 | }: AppProps): React.JSX.Element {
26 | const { pathname } = useRouter();
27 |
28 | useEffect(() => void popAudio?.play().catch(() => void 0), [pathname]);
29 |
30 | return (
31 | <>
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | >
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/lib/helper.ts:
--------------------------------------------------------------------------------
1 | import type { SyntheticEvent } from 'react';
2 | import type { Blog } from './types/contents';
3 |
4 | type PreventBubblingProps = {
5 | preventDefault?: boolean;
6 | callback?: (...args: never[]) => unknown;
7 | };
8 |
9 | /**
10 | * Prevents the event from bubbling up the DOM tree.
11 | */
12 | export function preventBubbling({
13 | preventDefault,
14 | callback
15 | }: PreventBubblingProps = {}) {
16 | return (e: SyntheticEvent): void => {
17 | e.stopPropagation();
18 |
19 | if (preventDefault) e.preventDefault();
20 | if (callback) callback();
21 | };
22 | }
23 |
24 | /**
25 | * Returns the content without the extension.
26 | */
27 | export function removeContentExtension(content: string): string {
28 | return content.replace(/\.mdx$/, '');
29 | }
30 |
31 | /**
32 | * Returns an array of unique tags from the contents.
33 | */
34 | export function getTags(contents: Blog[]): string[] {
35 | const validTags = contents.flatMap(({ tags }) =>
36 | tags.split(',').map((tag) => tag.trim())
37 | );
38 |
39 | const uniqueTags = Array.from(new Set(validTags));
40 |
41 | return uniqueTags;
42 | }
43 |
44 | /**
45 | * Returns a boolean value indicating whether the target text includes the filter text.
46 | */
47 | export function textIncludes(target: string, filter: string): boolean {
48 | const [newTarget, newFilter] = [target, filter].map((text) =>
49 | text.replace(/\W/g, '').toLowerCase()
50 | );
51 | return newTarget.includes(newFilter);
52 | }
53 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-guestbook.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 | import { fetcher } from '@lib/fetcher';
3 | import type { ValidApiEndpoints } from '@lib/types/api';
4 | import type { Guestbook, Text } from '@lib/types/guestbook';
5 |
6 | type UseGuestbook = {
7 | guestbook?: Guestbook[];
8 | isLoading: boolean;
9 | registerGuestbook: (text: Text) => Promise;
10 | unRegisterGuestbook: (id: string) => Promise;
11 | };
12 |
13 | /**
14 | * Returns the guestbook data and a function to register guestbook.
15 | */
16 | export function useGuestbook(fallbackData: Guestbook[]): UseGuestbook {
17 | const {
18 | data: guestbook,
19 | isLoading,
20 | mutate
21 | } = useSWR(
22 | '/api/guestbook',
23 | fetcher,
24 | { fallbackData }
25 | );
26 |
27 | const registerGuestbook = async (text: Text): Promise => {
28 | const newGuestbook = await fetcher('/api/guestbook', {
29 | method: 'POST',
30 | headers: { 'Content-Type': 'application/json' },
31 | body: JSON.stringify({ text })
32 | });
33 |
34 | await mutate([newGuestbook, ...(guestbook ?? [])]);
35 | };
36 |
37 | const unRegisterGuestbook = async (id: string): Promise => {
38 | await fetcher(`/api/guestbook/${id}`, { method: 'DELETE' });
39 |
40 | const newGuestbook = guestbook?.filter((entry) => entry.id !== id);
41 |
42 | await mutate(newGuestbook);
43 | };
44 |
45 | return { guestbook, isLoading, registerGuestbook, unRegisterGuestbook };
46 | }
47 |
--------------------------------------------------------------------------------
/src/pages/api/guestbook/[id].tsx:
--------------------------------------------------------------------------------
1 | import { getServerSession, type AuthOptions } from 'next-auth';
2 | import { doc, getDoc, deleteDoc } from 'firebase/firestore';
3 | import { guestbookCollection } from '@lib/firebase/collections';
4 | import { authOptions } from '../auth/[...nextauth]';
5 | import type { NextApiRequest, NextApiResponse } from 'next';
6 | import type { CustomSession } from '@lib/types/api';
7 | import type { APIResponse } from '@lib/types/helper';
8 |
9 | export default async function handler(
10 | req: NextApiRequest,
11 | res: NextApiResponse
12 | ): Promise {
13 | if (req.method === 'DELETE')
14 | try {
15 | const session = await getServerSession(
16 | req,
17 | res,
18 | authOptions
19 | );
20 |
21 | if (!session) return res.status(401).json({ message: 'Unauthorized' });
22 |
23 | const { id } = req.query as { id: string };
24 |
25 | const docRef = doc(guestbookCollection, id);
26 |
27 | const guestbookData = (await getDoc(docRef)).data();
28 |
29 | if (!guestbookData) return res.status(404).json({ message: 'Not found' });
30 |
31 | const {
32 | user: { id: createdBy, admin }
33 | } = session;
34 |
35 | const isOwner = createdBy === guestbookData.createdBy || admin;
36 |
37 | if (!isOwner) return res.status(403).json({ message: 'Forbidden' });
38 |
39 | await deleteDoc(docRef);
40 |
41 | return res.status(200).json({ message: 'Deleted successfully' });
42 | } catch (error) {
43 | if (error instanceof Error)
44 | return res.status(500).json({ message: error.message });
45 |
46 | return res.status(500).json({ message: 'Internal server error' });
47 | }
48 |
49 | return res.status(405).json({ message: 'Method not allowed' });
50 | }
51 |
--------------------------------------------------------------------------------
/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(): React.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 |
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/pages/projects.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import { getAllContents } from '@lib/mdx';
3 | import { setTransition } from '@lib/transition';
4 | import { SEO } from '@components/common/seo';
5 | import { ProjectCard } from '@components/project/project-card';
6 | import { Accent } from '@components/ui/accent';
7 | import type { GetStaticPropsResult, InferGetStaticPropsType } from 'next/types';
8 | import type { Project } from '@lib/types/contents';
9 |
10 | export default function Projects({
11 | projects
12 | }: InferGetStaticPropsType): React.JSX.Element {
13 | return (
14 |
15 |
19 |
20 |
24 | Projects
25 |
26 |
30 | A showcase of my projects on the web development.
31 |
32 |
33 |
37 | {projects.map((post) => (
38 |
39 | ))}
40 |
41 |
42 | );
43 | }
44 |
45 | type BlogProps = {
46 | projects: Project[];
47 | };
48 |
49 | export async function getStaticProps(): Promise<
50 | GetStaticPropsResult
51 | > {
52 | const projects = await getAllContents('projects');
53 |
54 | return {
55 | props: {
56 | projects
57 | }
58 | };
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/common/theme-switch.tsx:
--------------------------------------------------------------------------------
1 | import { AnimatePresence, motion, type MotionProps } from 'framer-motion';
2 | import { HiOutlineSun, HiOutlineMoon } from 'react-icons/hi2';
3 | import { useTheme } from 'next-themes';
4 | import { useMounted } from '@lib/hooks/use-mounted';
5 |
6 | export function ThemeSwitch(): React.JSX.Element | null {
7 | const { theme, setTheme } = useTheme();
8 | const mounted = useMounted();
9 |
10 | if (!mounted) return null;
11 |
12 | const isDarkMode = theme === 'dark';
13 |
14 | const flipTheme = (): void => setTheme(isDarkMode ? 'light' : 'dark');
15 |
16 | return (
17 |
25 |
26 | {isDarkMode ? (
27 |
28 |
29 |
30 | ) : (
31 |
32 |
33 |
34 | )}
35 |
36 |
37 | );
38 | }
39 |
40 | const variants: MotionProps[] = [
41 | {
42 | initial: { x: '50px', y: '25px' },
43 | animate: { scale: 1, x: 0, y: 0, transition: { duration: 0.8 } },
44 | exit: { x: '50px', y: '25px', transition: { duration: 0.5 } }
45 | },
46 | {
47 | initial: { x: '-50px', y: '25px' },
48 | animate: { scale: 1, x: 0, y: 0, transition: { duration: 0.8 } },
49 | exit: { x: '-50px', y: '25px', transition: { duration: 0.5 } }
50 | }
51 | ];
52 |
53 | const [moonVariants, sunVariants] = variants;
54 |
--------------------------------------------------------------------------------
/src/components/modal/modal.tsx:
--------------------------------------------------------------------------------
1 | import { AnimatePresence, motion, type MotionProps } from 'framer-motion';
2 | import { Dialog } from '@headlessui/react';
3 | import { clsx } from 'clsx';
4 | import type { PropsWithChildren } from 'react';
5 |
6 | type ModalProps = PropsWithChildren<{
7 | open: boolean;
8 | className?: string;
9 | modalClassName?: string;
10 | closePanelOnClick?: boolean;
11 | closeModal: () => void;
12 | }>;
13 |
14 | export function Modal({
15 | open,
16 | children,
17 | className = 'grid place-items-center',
18 | modalClassName,
19 | closePanelOnClick,
20 | closeModal
21 | }: ModalProps): React.JSX.Element {
22 | return (
23 |
24 | {open && (
25 |
31 |
36 |
37 |
43 | {children}
44 |
45 |
46 |
47 | )}
48 |
49 | );
50 | }
51 |
52 | const variants: MotionProps[] = [
53 | {
54 | initial: { opacity: 0 },
55 | animate: { opacity: 1 },
56 | exit: { opacity: 0 }
57 | },
58 | {
59 | initial: { opacity: 0, scale: 0.8 },
60 | animate: {
61 | opacity: 1,
62 | scale: 1,
63 | transition: { type: 'spring', duration: 0.5, bounce: 0.4 }
64 | },
65 | exit: { opacity: 0, scale: 0.8, transition: { duration: 0.15 } }
66 | }
67 | ];
68 |
69 | const [backdrop, modal] = variants;
70 |
--------------------------------------------------------------------------------
/src/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useRouter } from 'next/router';
3 | import { motion, type MotionProps } from 'framer-motion';
4 | import { clsx } from 'clsx';
5 | import { setTransition } from '@lib/transition';
6 | import { SEO } from '@components/common/seo';
7 | import { CustomLink } from '@components/link/custom-link';
8 |
9 | export default function NotFound(): React.JSX.Element {
10 | const [currentUrl, setCurrentUrl] = useState(null);
11 |
12 | const { asPath, isReady } = useRouter();
13 |
14 | useEffect(() => {
15 | if (!isReady) return;
16 |
17 | const timeoutId = setTimeout(() => setCurrentUrl(asPath), 500);
18 | return (): void => clearTimeout(timeoutId);
19 | }, [asPath, isReady]);
20 |
21 | return (
22 |
23 |
27 |
31 | 404
32 |
33 | Page{' '}
34 |
42 | {currentUrl ?? '...'}
43 | {' '}
44 | not found
45 |
46 | Go back home
47 |
48 |
49 | );
50 | }
51 |
52 | const variants: MotionProps = {
53 | initial: { opacity: 0 },
54 | animate: { opacity: 1 }
55 | };
56 |
--------------------------------------------------------------------------------
/src/lib/transition.ts:
--------------------------------------------------------------------------------
1 | import type { MotionProps, Transition } from 'framer-motion';
2 |
3 | type SetTransition = Pick;
4 | type TransitionType = 'spring' | 'tween' | 'inertia';
5 |
6 | type setTransitionProps = {
7 | typeIn?: TransitionType;
8 | typeOut?: TransitionType;
9 | delayIn?: number;
10 | delayOut?: number;
11 | distance?: number;
12 | durationIn?: number;
13 | durationOut?: number;
14 | };
15 |
16 | /**
17 | * Set the transition for the component.
18 | *
19 | * @param transitionProps a set of props to set the transition.
20 | * @returns MotionProps object with initial, animate, and exit.
21 | */
22 | export function setTransition({
23 | typeIn = 'tween',
24 | typeOut = 'tween',
25 | delayIn = 0,
26 | delayOut = 0,
27 | distance = 50,
28 | durationIn,
29 | durationOut
30 | }: setTransitionProps = {}): SetTransition {
31 | const transitionIn: Transition = {
32 | type: typeIn,
33 | delay: delayIn,
34 | duration: durationIn
35 | };
36 |
37 | const transitionOut: Transition = {
38 | type: typeOut,
39 | delay: delayOut,
40 | duration: durationOut
41 | };
42 |
43 | return {
44 | initial: {
45 | opacity: 0,
46 | y: distance
47 | },
48 | animate: {
49 | opacity: 1,
50 | y: 0,
51 | transition: transitionIn
52 | },
53 | exit: {
54 | opacity: 0,
55 | y: distance,
56 | transition: transitionOut
57 | }
58 | };
59 | }
60 |
61 | type FadeInWhenVisible = Pick<
62 | MotionProps,
63 | 'viewport' | 'initial' | 'whileInView' | 'transition'
64 | >;
65 |
66 | /**
67 | * Set the component to fade in when it's visible.
68 | *
69 | * @returns MotionProps object with viewport, initial, whileInView and transition properties.
70 | */
71 | export function fadeInWhenVisible(): FadeInWhenVisible {
72 | return {
73 | viewport: { margin: '0px 0px -240px' },
74 | initial: { y: 50, opacity: 0 },
75 | whileInView: { y: 0, opacity: 1 },
76 | transition: { type: 'tween' }
77 | };
78 | }
79 |
--------------------------------------------------------------------------------
/src/components/content/table-of-contents.tsx:
--------------------------------------------------------------------------------
1 | import { clsx } from 'clsx';
2 | import { useHeadingData } from '@lib/hooks/use-heading-data';
3 | import { useActiveHeading } from '@lib/hooks/use-active-heading';
4 | import type { PropsWithChildren } from 'react';
5 |
6 | export function TableOfContents({
7 | children
8 | }: PropsWithChildren): React.JSX.Element {
9 | const headingData = useHeadingData();
10 | const activeHeadingId = useActiveHeading();
11 |
12 | return (
13 |
14 |
15 |
16 | Table of Contents
17 |
18 |
19 | {headingData.map(({ id, title, items }) => (
20 | <>
21 |
26 | {title}
27 |
28 | {!!items.length &&
29 | items.map(({ id, title }) => (
30 |
38 | {title}
39 |
40 | ))}
41 | >
42 | ))}
43 |
44 |
45 | {children}
46 |
47 | );
48 | }
49 |
50 | const linkStyles = [
51 | 'text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200',
52 | 'text-gray-900 dark:text-gray-100'
53 | ] as const;
54 |
55 | function getHeadingStyle(
56 | currentActiveId: string | null,
57 | targetId: string
58 | ): string {
59 | return clsx(
60 | 'smooth-tab transition',
61 | linkStyles[+(currentActiveId === targetId)]
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/pages/api/likes/[slug].ts:
--------------------------------------------------------------------------------
1 | import { doc, getDoc, increment, updateDoc } from 'firebase/firestore';
2 | import { contentsCollection } from '@lib/firebase/collections';
3 | import { getTotalLikes } from '@lib/helper-server';
4 | import { getSessionId } from '@lib/helper-server-node';
5 | import type { LikeStatus } from '@lib/types/meta';
6 | import type { APIResponse } from '@lib/types/helper';
7 | import type { NextApiRequest, NextApiResponse } from 'next';
8 |
9 | export default async function handler(
10 | req: NextApiRequest,
11 | res: NextApiResponse>
12 | ): Promise {
13 | const { slug } = req.query as { slug: string };
14 |
15 | const sessionId = getSessionId(req);
16 | const sessionIdFieldReference = `likesBy.${sessionId}`;
17 |
18 | try {
19 | const docRef = doc(contentsCollection, slug);
20 |
21 | const snapshot = await getDoc(docRef);
22 | const data = snapshot.data();
23 |
24 | if (!data) return res.status(404).json({ message: 'Content not found' });
25 |
26 | const { likesBy } = data;
27 |
28 | if (!(sessionId in likesBy)) likesBy[sessionId] = 0;
29 |
30 | const userLikes = likesBy[sessionId];
31 |
32 | if (req.method === 'GET') {
33 | const likes = getTotalLikes(likesBy);
34 |
35 | await updateDoc(docRef, {
36 | likes,
37 | [sessionIdFieldReference]: userLikes
38 | });
39 |
40 | return res.status(200).json({ likes, userLikes });
41 | }
42 |
43 | if (req.method === 'POST') {
44 | if (userLikes >= 5)
45 | return res.status(422).json({ message: 'Likes limit reached' });
46 |
47 | const likes = getTotalLikes(likesBy) + 1;
48 |
49 | await updateDoc(docRef, {
50 | likes,
51 | [sessionIdFieldReference]: increment(1)
52 | });
53 |
54 | return res.status(201).json({ likes, userLikes });
55 | }
56 | } catch (error) {
57 | if (error instanceof Error)
58 | return res.status(500).json({ message: error.message });
59 |
60 | return res.status(500).json({ message: 'Internal server error' });
61 | }
62 |
63 | return res.status(405).json({ message: 'Method not allowed' });
64 | }
65 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-active-heading.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react';
2 |
3 | type HeadingElementsRef = Record;
4 |
5 | /**
6 | * Returns the current active heading.
7 | */
8 | export function useActiveHeading(): string | null {
9 | const [activeHeading, setActiveHeading] = useState(null);
10 |
11 | const headingElementsRef = useRef(null);
12 |
13 | useEffect(() => {
14 | const getIndexFromHeadingId = (id: string): number =>
15 | headingElements.findIndex((heading) => heading.id === id);
16 |
17 | const callback = (entries: IntersectionObserverEntry[]): void => {
18 | headingElementsRef.current = entries.reduce(
19 | (acc, headingElement) => ({
20 | ...acc,
21 | [headingElement.target.id]: headingElement
22 | }),
23 | headingElementsRef.current
24 | );
25 |
26 | const visibleHeadings: IntersectionObserverEntry[] = [];
27 |
28 | for (const headingElement of Object.values(
29 | headingElementsRef.current as HeadingElementsRef
30 | ))
31 | if (headingElement.isIntersecting) visibleHeadings.push(headingElement);
32 |
33 | if (visibleHeadings.length === 1)
34 | setActiveHeading(visibleHeadings[0].target.id);
35 | else if (visibleHeadings.length > 1) {
36 | const [firstVisibleHeading] = visibleHeadings.sort(
37 | ({ target: { id: firstId } }, { target: { id: secondId } }) =>
38 | getIndexFromHeadingId(firstId) - getIndexFromHeadingId(secondId)
39 | );
40 | setActiveHeading(firstVisibleHeading.target.id);
41 | }
42 | };
43 |
44 | const headingObserver = new IntersectionObserver(callback, {
45 | rootMargin: '0px 0px -40%'
46 | });
47 |
48 | const headingElements: HTMLHeadingElement[] = Array.from(
49 | document.querySelectorAll('#mdx-article :is(h2, h3)')
50 | );
51 |
52 | headingElements.forEach((headingElement) =>
53 | headingObserver.observe(headingElement)
54 | );
55 |
56 | return (): void => headingObserver.disconnect();
57 | }, []);
58 |
59 | return activeHeading;
60 | }
61 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | /* eslint-disable @typescript-eslint/explicit-function-return-type */
4 |
5 | import nextMDX from '@next/mdx';
6 | import rehypeSlug from 'rehype-slug';
7 | import rehypeAutolinkHeadings from 'rehype-autolink-headings';
8 | import rehypePrettyCode from 'rehype-pretty-code';
9 |
10 | /** @type {import('rehype-autolink-headings').Options} */
11 | const rehypeAutolinkHeadingsOptions = {
12 | behavior: 'wrap'
13 | };
14 |
15 | /** @type {import('rehype-pretty-code').Options} */
16 | const rehypePrettyCodeOptions = {
17 | // Use one of Shiki's packaged themes
18 | theme: {
19 | light: 'light-plus',
20 | dark: 'dark-plus'
21 | },
22 |
23 | // Keep the background or use a custom background color?
24 | keepBackground: false,
25 |
26 | onVisitLine(element) {
27 | // Add a custom class to each line
28 | element.properties.className = ['line'];
29 | },
30 |
31 | onVisitHighlightedLine(element) {
32 | // Add a custom class to each highlighted line
33 | element.properties.className?.push('highlighted');
34 | },
35 |
36 | onVisitHighlightedChars(element) {
37 | // Add a custom class to each highlighted character
38 | element.properties.className = ['word'];
39 | }
40 | };
41 |
42 | const withMDX = nextMDX({
43 | extension: /\.mdx?$/,
44 | options: {
45 | // If you use remark-gfm, you'll need to use next.config.mjs
46 | // as the package is ESM only
47 | // https://github.com/remarkjs/remark-gfm#install
48 | remarkPlugins: [],
49 | rehypePlugins: [
50 | rehypeSlug,
51 | [rehypeAutolinkHeadings, rehypeAutolinkHeadingsOptions],
52 | [rehypePrettyCode, rehypePrettyCodeOptions]
53 | ],
54 | // If you use `MDXProvider`, uncomment the following line.
55 | providerImportSource: '@mdx-js/react'
56 | }
57 | });
58 |
59 | export default withMDX({
60 | reactStrictMode: true,
61 | images: {
62 | remotePatterns: [
63 | new URL('https://i.scdn.co/image/**'),
64 | new URL('https://avatars.githubusercontent.com/u/**?v=4'),
65 | new URL('https://proxy.ccrsxx.com/Items/*/Images/Primary')
66 | ]
67 | },
68 | pageExtensions: ['ts', 'tsx', 'md', 'mdx']
69 | });
70 |
--------------------------------------------------------------------------------
/src/pages/api/guestbook/index.ts:
--------------------------------------------------------------------------------
1 | import { getServerSession, type AuthOptions } from 'next-auth';
2 | import {
3 | addDoc,
4 | Timestamp,
5 | serverTimestamp,
6 | type WithFieldValue
7 | } from 'firebase/firestore';
8 | import { getGuestbook, sendEmail } from '@lib/api';
9 | import { guestbookCollection } from '@lib/firebase/collections';
10 | import { authOptions } from '../auth/[...nextauth]';
11 | import type { NextApiRequest, NextApiResponse } from 'next';
12 | import type { CustomSession } from '@lib/types/api';
13 | import type { APIResponse } from '@lib/types/helper';
14 | import type { Guestbook } from '@lib/types/guestbook';
15 |
16 | export default async function handler(
17 | req: NextApiRequest,
18 | res: NextApiResponse>
19 | ): Promise {
20 | try {
21 | if (req.method === 'GET') {
22 | const guestbook = await getGuestbook();
23 |
24 | return res.status(200).json(guestbook);
25 | }
26 |
27 | if (req.method === 'POST') {
28 | const session = await getServerSession(
29 | req,
30 | res,
31 | authOptions
32 | );
33 |
34 | if (!session) return res.status(401).json({ message: 'Unauthorized' });
35 |
36 | const { text } = req.body as Pick;
37 |
38 | if (!text)
39 | return res.status(422).json({ message: "Text can't be empty" });
40 |
41 | const { id: createdBy, admin: _, ...rest } = session.user;
42 |
43 | const data: WithFieldValue> = {
44 | ...rest,
45 | text,
46 | createdBy,
47 | createdAt: serverTimestamp()
48 | };
49 |
50 | const { id } = await addDoc(guestbookCollection, data);
51 |
52 | await sendEmail(text, session);
53 |
54 | const newestGuestbook = {
55 | ...data,
56 | id,
57 | createdAt: Timestamp.now()
58 | } as Guestbook;
59 |
60 | return res.status(201).json(newestGuestbook);
61 | }
62 | } catch (error) {
63 | if (error instanceof Error)
64 | return res.status(500).json({ message: error.message });
65 |
66 | return res.status(500).json({ message: 'Internal server error' });
67 | }
68 |
69 | return res.status(405).json({ message: 'Method not allowed' });
70 | }
71 |
--------------------------------------------------------------------------------
/src/pages/blog/hello-world.mdx:
--------------------------------------------------------------------------------
1 | import Banner from '../../../public/assets/blog/hello-world/banner.jpg';
2 | import { ContentLayout } from '@components/layout/content-layout';
3 | import { getContentSlug } from '@lib/mdx';
4 |
5 | export const meta = {
6 | title: '"Hello World" - A New Beginning',
7 | publishedAt: '2022-12-24',
8 | banner: Banner,
9 | bannerAlt: 'Photo by Clay Banks on Unsplash',
10 | bannerLink: 'https://unsplash.com/photos/8q6e5hu3Ilc',
11 | description:
12 | "In this post I'll introduces myself and what you can expect from my blog. Follow along for tips, resources, and personal anecdotes.",
13 | tags: 'self'
14 | };
15 |
16 | export const getStaticProps = getContentSlug('blog', 'hello-world');
17 |
18 | export default ({ children }) => (
19 | {children}
20 | );
21 |
22 | {/* content start */}
23 |
24 | ## Introduction
25 |
26 | Welcome to my first blog post! I am excited to share my thoughts and experiences with you through this platform.
27 |
28 | As the title suggests, this is a classic "Hello World" post - a staple in the world of programming and a great way to kick off a new blog.
29 |
30 | ## About Me
31 |
32 | In this section, I'll introduce myself and give you a brief overview of what you can expect from my blog.
33 |
34 | My name is Risal Amin and I am currently a university student studying Computer Science at University of Bina Sarana Informatika Indonesia. As someone who has always been interested in technology, I have recently decided to pursue a career as a full stack developer.
35 |
36 | While I'm still in the early stages of my journey, I am constantly learning and growing as a developer. I started this blog as a way to document my progress and connect with like-minded individuals. Whether you're just starting out in your field or are a seasoned professional, I hope that you will find something of value here.
37 |
38 | ## What to Expect
39 |
40 | In this section, I'll give you a better idea of what you can expect to find on my blog.
41 |
42 | On my blog, you can expect to find posts about my experiences as a budding full stack developer. I'll share tips, resources, and personal anecdotes that I hope will inspire and inform you.
43 |
44 | I'm looking forward to this new journey and hope that you'll join me on it. Thanks for reading and stay tuned for more!
45 |
--------------------------------------------------------------------------------
/src/components/project/project-stats.tsx:
--------------------------------------------------------------------------------
1 | import { HiEye, HiUser, HiClock, HiLink } from 'react-icons/hi2';
2 | import { SiGithub, SiYoutube } from 'react-icons/si';
3 | import { CustomLink } from '@components/link/custom-link';
4 | import { ViewsCounter } from '@components/content/views-counter';
5 | import type { IconType } from 'react-icons';
6 | import type { Project } from '@lib/types/contents';
7 | import type { PropsForViews } from '@lib/types/helper';
8 |
9 | type ProjectLinks = {
10 | name: string;
11 | link: string | undefined;
12 | Icon: IconType;
13 | };
14 |
15 | type ProjectStatsProps = PropsForViews<
16 | Pick
17 | >;
18 |
19 | export function ProjectStats({
20 | slug,
21 | link,
22 | github,
23 | youtube,
24 | readTime,
25 | category,
26 | increment
27 | }: ProjectStatsProps): React.JSX.Element {
28 | const projectLinks: ProjectLinks[] = [
29 | {
30 | name: 'Repository',
31 | link: github,
32 | Icon: SiGithub
33 | },
34 | {
35 | name: 'Demo',
36 | link: youtube,
37 | Icon: SiYoutube
38 | },
39 | {
40 | name: 'Open Live Site',
41 | link: link,
42 | Icon: HiLink
43 | }
44 | ];
45 |
46 | return (
47 | <>
48 |
49 |
50 |
51 |
{readTime}
52 |
53 |
54 |
55 |
56 |
57 | {projectLinks.map(
58 | ({ name, link, Icon }) =>
59 | link && (
60 |
61 |
62 |
63 |
64 | {name}
65 |
66 | )
67 | )}
68 |
69 |
70 |
71 |
72 |
73 |
{category}
74 |
75 | >
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "portofolio",
3 | "version": "1.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev -p 3000",
7 | "build": "next build",
8 | "start": "next start",
9 | "type-check": "tsc --noEmit",
10 | "format": "prettier --write .",
11 | "format:check": "prettier --check .",
12 | "lint": "next lint --max-warnings=0 .",
13 | "lint:fix": "next lint --fix .",
14 | "prepare": "husky"
15 | },
16 | "dependencies": {
17 | "@headlessui/react": "^1.7.7",
18 | "@mdx-js/loader": "^3.0.0",
19 | "@mdx-js/react": "^3.0.0",
20 | "@next/mdx": "^15.3.8",
21 | "@prequist/lanyard": "^1.1.0",
22 | "@tanstack/match-sorter-utils": "^8.7.6",
23 | "@tanstack/react-table": "^8.7.9",
24 | "@vercel/analytics": "^1.0.1",
25 | "clsx": "^2.0.0",
26 | "firebase": "^10.0.0",
27 | "framer-motion": "^11.0.0",
28 | "next": "^15.3.6",
29 | "next-auth": "^4.19.2",
30 | "next-themes": "^0.3.0",
31 | "nodemailer": "^6.9.1",
32 | "nprogress": "^0.2.0",
33 | "react": "18.3.1",
34 | "react-dom": "18.3.1",
35 | "react-icons": "^5.0.0",
36 | "swr": "^2.0.1",
37 | "zod": "^4.0.5"
38 | },
39 | "devDependencies": {
40 | "@commitlint/cli": "^19.0.0",
41 | "@commitlint/config-conventional": "^19.0.0",
42 | "@eslint/eslintrc": "^3.3.1",
43 | "@eslint/js": "^9.31.0",
44 | "@tailwindcss/typography": "^0.5.8",
45 | "@types/node": "20.12.11",
46 | "@types/nodemailer": "^6.4.7",
47 | "@types/nprogress": "^0.2.0",
48 | "@types/react": "19.1.8",
49 | "@types/react-dom": "19.1.6",
50 | "autoprefixer": "^10.4.8",
51 | "eslint": "^9.31.0",
52 | "eslint-config-next": "^15.3.8",
53 | "husky": "^9.0.0",
54 | "jest": "^29.0.0",
55 | "jest-environment-jsdom": "^29.0.0",
56 | "lint-staged": "^15.0.0",
57 | "postcss": "^8.4.16",
58 | "prettier": "^3.0.0",
59 | "prettier-plugin-tailwindcss": "^0.5.0",
60 | "reading-time": "^1.5.0",
61 | "rehype-autolink-headings": "^7.0.0",
62 | "rehype-pretty-code": "^0.13.1",
63 | "rehype-slug": "^6.0.0",
64 | "sass": "^1.54.4",
65 | "shiki": "^1.0.0",
66 | "tailwindcss": "^3.2.4",
67 | "typescript": "^5.4.5",
68 | "typescript-eslint": "^8.36.0"
69 | },
70 | "lint-staged": {
71 | "**/*": "prettier --write --ignore-unknown"
72 | },
73 | "volta": {
74 | "node": "24.4.0"
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/lib/helper-server.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse, type NextRequest } from 'next/server';
2 | import { backendEnv } from './env-server';
3 | import type { GithubUser } from './types/github';
4 |
5 | /**
6 | * Returns the username with the given user id from the GitHub API.
7 | */
8 | export async function getGithubUsername(userId: string): Promise {
9 | const response = await fetch(`https://api.github.com/user/${userId}`, {
10 | headers: { Authorization: `Bearer ${backendEnv.GITHUB_TOKEN}` }
11 | });
12 |
13 | const { login } = (await response.json()) as GithubUser;
14 |
15 | return login;
16 | }
17 |
18 | /**
19 | * Returns total likes from the given likes object.
20 | */
21 | export function getTotalLikes(likes: Record): number {
22 | return Object.values(likes).reduce((accLikes, like) => accLikes + like, 0);
23 | }
24 |
25 | /**
26 | * Returns the bearer token from the request headers.
27 | */
28 | export function getBearerToken(req: NextRequest): string | null {
29 | const authorization = req.headers.get('authorization');
30 |
31 | if (!authorization) return null;
32 |
33 | const [authType, bearerToken] = authorization.split(' ');
34 |
35 | if (authType.toLowerCase() !== 'bearer' || !bearerToken) return null;
36 |
37 | return bearerToken;
38 | }
39 |
40 | /**
41 | * Returns the origin from the request headers.
42 | */
43 | export function getOrigin(req: NextRequest): string | null {
44 | const origin = req.headers.get('origin');
45 |
46 | if (origin) return origin;
47 |
48 | const referer = req.headers.get('referer');
49 |
50 | if (!referer) return null;
51 |
52 | const originFromReferer = new URL(referer).origin;
53 |
54 | return originFromReferer;
55 | }
56 |
57 | type ValidContentTypes = (typeof VALID_CONTENT_TYPES)[number];
58 |
59 | export const VALID_CONTENT_TYPES = ['blog', 'projects'] as const;
60 |
61 | /**
62 | * Returns true if the content type is `blog` or `projects`.
63 | */
64 | export function isValidContentType(type: string): type is ValidContentTypes {
65 | return VALID_CONTENT_TYPES.includes(type as ValidContentTypes);
66 | }
67 |
68 | /**
69 | * Returns a NextResponse with the given status and message.
70 | */
71 | export function generateNextResponse(
72 | status: number,
73 | message: string
74 | ): NextResponse {
75 | return new NextResponse(JSON.stringify({ message }), {
76 | status,
77 | headers: { 'content-type': 'application/json' }
78 | });
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/content/custom-pre.tsx:
--------------------------------------------------------------------------------
1 | import { AnimatePresence, motion, type MotionProps } from 'framer-motion';
2 | import {
3 | useState,
4 | useRef,
5 | type CSSProperties,
6 | type PropsWithChildren,
7 | type ComponentPropsWithoutRef
8 | } from 'react';
9 | import { HiClipboard, HiClipboardDocumentCheck } from 'react-icons/hi2';
10 | import { useMounted } from '@lib/hooks/use-mounted';
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({
22 | children,
23 | ...rest
24 | }: CustomPreProps): React.JSX.Element {
25 | const [copied, setCopied] = useState(false);
26 | const mounted = useMounted();
27 |
28 | const preRef = useRef(null);
29 |
30 | const handleCopied = async (): Promise => {
31 | if (copied) return;
32 | setCopied(true);
33 | await navigator.clipboard.writeText(preRef.current?.textContent ?? '');
34 | setTimeout(() => setCopied(false), 2000);
35 | };
36 |
37 | const dataLanguage = rest['data-language'];
38 |
39 | return (
40 | <>
41 | {mounted && {dataLanguage}
}
42 |
43 | {mounted && (
44 |
49 |
50 | {copied ? (
51 |
52 |
53 |
54 | ) : (
55 |
56 |
57 |
58 | )}
59 |
60 |
61 | )}
62 | {children}
63 |
64 | >
65 | );
66 | }
67 |
68 | const variants: MotionProps = {
69 | initial: { opacity: 0, scale: 0.5 },
70 | animate: { opacity: 1, scale: 1, transition: { duration: 0.15 } },
71 | exit: { opacity: 0, scale: 0.5, transition: { duration: 0.1 } }
72 | };
73 |
--------------------------------------------------------------------------------
/src/lib/mdx.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getContentFiles,
3 | getContentReadTime,
4 | getSuggestedContents,
5 | getContentLastUpdatedDate
6 | } from './mdx-utils';
7 | import { removeContentExtension } from './helper';
8 | import type { GetStaticPropsResult } from 'next';
9 | import type { Blog, Project, Content, ContentType } from '@lib/types/contents';
10 |
11 | export type ContentSlugProps = Pick & {
12 | type: ContentType;
13 | slug: string;
14 | suggestedContents: (Blog | Project)[];
15 | };
16 |
17 | /**
18 | * Returns the MDX contents props.
19 | */
20 | export function getContentSlug(type: ContentType, slug: string) {
21 | return async (): Promise> => {
22 | const lastUpdatedAt = await getContentLastUpdatedDate(type, slug);
23 | const suggestedContents = await getSuggestedContents(type);
24 |
25 | const readTime = await getContentReadTime(type, slug);
26 |
27 | return {
28 | props: {
29 | type,
30 | slug,
31 | readTime,
32 | suggestedContents,
33 | ...(lastUpdatedAt && { lastUpdatedAt })
34 | }
35 | };
36 | };
37 | }
38 |
39 | /**
40 | * Returns all the contents within the selected content directory.
41 | */
42 | export async function getAllContents(type: 'blog'): Promise;
43 | export async function getAllContents(type: 'projects'): Promise;
44 | export async function getAllContents(
45 | type: ContentType
46 | ): Promise<(Blog | Project)[]> {
47 | const contentPosts = await getContentFiles(type);
48 |
49 | const contents = await getContentByFiles(type, contentPosts);
50 |
51 | return contents;
52 | }
53 |
54 | /**
55 | * Get the contents by files.
56 | *
57 | * @param type The type of the content.
58 | * @param files The files of the content.
59 | * @returns The contents from the files.
60 | */
61 | export async function getContentByFiles(
62 | type: ContentType,
63 | files: string[]
64 | ): Promise<(Blog | Project)[]> {
65 | const contentPromises = files.map(async (file) => {
66 | const { meta } = (await import(`../pages/${type}/${file}`)) as {
67 | meta:
68 | | Omit
69 | | Omit;
70 | };
71 |
72 | const slug = removeContentExtension(file);
73 | const readTime = await getContentReadTime(type, slug);
74 |
75 | return { ...meta, slug, readTime };
76 | });
77 |
78 | const contents = await Promise.all(contentPromises);
79 |
80 | return contents;
81 | }
82 |
--------------------------------------------------------------------------------
/src/components/project/tech-icons.tsx:
--------------------------------------------------------------------------------
1 | import { IoLogoVercel } from 'react-icons/io5';
2 | import {
3 | SiGit,
4 | SiSass,
5 | SiReact,
6 | SiNotion,
7 | SiMongodb,
8 | SiFirebase,
9 | SiMarkdown,
10 | SiPrettier,
11 | SiNodedotjs,
12 | SiNextdotjs,
13 | SiJavascript,
14 | SiTypescript,
15 | SiTailwindcss,
16 | SiGoogleanalytics
17 | } from 'react-icons/si';
18 | import { Tooltip } from '../ui/tooltip';
19 | import type { IconType } from 'react-icons';
20 |
21 | export function TechIcons({ tags }: { tags: string }): React.JSX.Element {
22 | const techsArray = tags.split(',');
23 |
24 | return (
25 |
26 | {techsArray.map((tech) => {
27 | const { name, Icon } = techList[tech];
28 |
29 | return (
30 |
37 |
38 |
39 | );
40 | })}
41 |
42 | );
43 | }
44 |
45 | type TechList = Record;
46 |
47 | const techList: TechList = {
48 | react: {
49 | name: 'React',
50 | Icon: SiReact
51 | },
52 | nextjs: {
53 | name: 'Next.js',
54 | Icon: SiNextdotjs
55 | },
56 | tailwindcss: {
57 | name: 'Tailwind CSS',
58 | Icon: SiTailwindcss
59 | },
60 | scss: {
61 | name: 'SCSS',
62 | Icon: SiSass
63 | },
64 | javascript: {
65 | name: 'JavaScript',
66 | Icon: SiJavascript
67 | },
68 | typescript: {
69 | name: 'TypeScript',
70 | Icon: SiTypescript
71 | },
72 | nodejs: {
73 | name: 'Node.js',
74 | Icon: SiNodedotjs
75 | },
76 | firebase: {
77 | name: 'Firebase',
78 | Icon: SiFirebase
79 | },
80 | mongodb: {
81 | name: 'MongoDB',
82 | Icon: SiMongodb
83 | },
84 | swr: {
85 | name: 'SWR',
86 | Icon: IoLogoVercel
87 | },
88 | mdx: {
89 | name: 'MDX',
90 | Icon: SiMarkdown
91 | },
92 | prettier: {
93 | name: 'Prettier',
94 | Icon: SiPrettier
95 | },
96 | analytics: {
97 | name: 'Google Analytics',
98 | Icon: SiGoogleanalytics
99 | },
100 | git: {
101 | name: 'Git',
102 | Icon: SiGit
103 | },
104 | notion: {
105 | name: 'Notion API',
106 | Icon: SiNotion
107 | }
108 | };
109 |
--------------------------------------------------------------------------------
/src/lib/mdx-utils.ts:
--------------------------------------------------------------------------------
1 | import { readFile, readdir } from 'fs/promises';
2 | import { join } from 'path';
3 | import readingTime from 'reading-time';
4 | import { backendEnv } from './env-server';
5 | import { getContentByFiles } from './mdx';
6 | import type { Blog, Project, ContentType } from './types/contents';
7 | import type { FileCommitHistory } from './types/github';
8 |
9 | /**
10 | * Returns the content files within the selected content directory.
11 | */
12 | export async function getContentFiles(type: ContentType): Promise {
13 | const contentDirectory = join('src', 'pages', type);
14 | const contentPosts = await readdir(contentDirectory);
15 |
16 | return contentPosts;
17 | }
18 |
19 | /**
20 | * Returns the content read time.
21 | */
22 | export async function getContentReadTime(
23 | type: ContentType,
24 | slug: string
25 | ): Promise {
26 | const contentPath = join('src', 'pages', type, `${slug}.mdx`);
27 |
28 | const rawContent = await readFile(contentPath, 'utf8');
29 |
30 | const actualContent = rawContent.split('{/* content start */}')[1].trim();
31 |
32 | const { text } = readingTime(actualContent);
33 |
34 | return text;
35 | }
36 |
37 | /**
38 | * Returns the content last updated date from the GitHub API.
39 | */
40 | export async function getContentLastUpdatedDate(
41 | type: ContentType,
42 | slug: string
43 | ): Promise {
44 | const response = await fetch(
45 | `https://api.github.com/repos/ccrsxx/portofolio/commits?path=src/pages/${type}/${slug}.mdx`,
46 | { headers: { Authorization: `Bearer ${backendEnv.GITHUB_TOKEN}` } }
47 | );
48 |
49 | const commits = (await response.json()) as FileCommitHistory[];
50 |
51 | const featCommits = commits.filter(({ commit: { message } }) =>
52 | message.startsWith('feat')
53 | );
54 |
55 | if (featCommits.length <= 1) return null;
56 |
57 | const {
58 | commit: {
59 | author: { date }
60 | }
61 | } = featCommits[0];
62 |
63 | return date;
64 | }
65 |
66 | /**
67 | * Returns three random suggested contents.
68 | */
69 | export async function getSuggestedContents(
70 | type: ContentType
71 | ): Promise<(Blog | Project)[]> {
72 | const contentFiles = await getContentFiles(type);
73 |
74 | const shuffledFiles = contentFiles
75 | .map((value) => ({ value, sort: Math.random() }))
76 | .sort((a, b) => a.sort - b.sort)
77 | .map(({ value }) => value);
78 |
79 | const randomShuffledFiles = shuffledFiles.slice(0, 3);
80 |
81 | const suggestedContents = await getContentByFiles(type, randomShuffledFiles);
82 |
83 | return suggestedContents;
84 | }
85 |
--------------------------------------------------------------------------------
/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): React.JSX.Element {
34 | const CustomTag: ValidTag = tag;
35 |
36 | bannerAlt ??= title;
37 |
38 | const techTags = tags.split(',');
39 |
40 | return (
41 |
42 |
43 |
44 |
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/lib/hooks/use-currently-playing-sse.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { frontendEnv } from '@lib/env';
3 | import { useLocalStorage } from './use-local-storage';
4 | import type { BackendSuccessApiResponse } from '@lib/types/api';
5 | import type { CurrentlyPlaying } from '@lib/types/currently-playing';
6 |
7 | type UseCurrentlyPlayingSSE = {
8 | currentlyPlaying: BackendSuccessApiResponse | null;
9 | };
10 |
11 | /**
12 | * Get the current playing track from Spotify.
13 | */
14 | export function useCurrentlyPlayingSSE(): UseCurrentlyPlayingSSE {
15 | const [spotifyData, setSpotifyData] =
16 | useLocalStorage | null>(
17 | 'spotify',
18 | null
19 | );
20 |
21 | const [jellyfinData, setJellyfinData] =
22 | useLocalStorage | null>(
23 | 'jellyfin',
24 | null
25 | );
26 |
27 | useEffect(() => {
28 | const url = new URL(`${frontendEnv.NEXT_PUBLIC_BACKEND_URL}/sse`);
29 |
30 | url.searchParams.set('token', frontendEnv.NEXT_PUBLIC_OWNER_BEARER_TOKEN);
31 |
32 | const eventSource = new EventSource(url);
33 |
34 | eventSource.addEventListener('message', (_event: MessageEvent) => {
35 | // TODO: do something with the generic message event
36 | });
37 |
38 | eventSource.addEventListener('spotify', (event: MessageEvent) => {
39 | const data = JSON.parse(
40 | event.data
41 | ) as BackendSuccessApiResponse;
42 |
43 | setSpotifyData(data);
44 | });
45 |
46 | eventSource.addEventListener('jellyfin', (event: MessageEvent) => {
47 | const data = JSON.parse(
48 | event.data
49 | ) as BackendSuccessApiResponse;
50 |
51 | setJellyfinData(data);
52 | });
53 |
54 | eventSource.addEventListener('error', (error: Event) => {
55 | // eslint-disable-next-line no-console
56 | console.error('Failed to connect to SSE', error);
57 | });
58 |
59 | return (): void => eventSource.close();
60 | }, [setSpotifyData, setJellyfinData]);
61 |
62 | let parsedData: BackendSuccessApiResponse | null = null;
63 |
64 | if (jellyfinData?.data?.item) {
65 | parsedData = jellyfinData;
66 | } else if (spotifyData?.data?.item) {
67 | parsedData = spotifyData;
68 | }
69 |
70 | // If both platform item exist, but Jellyfin is paused while Spotify is playing, switch to Spotify
71 | const shouldPreferSpotify =
72 | jellyfinData?.data?.item &&
73 | spotifyData?.data?.item &&
74 | !jellyfinData?.data?.isPlaying &&
75 | spotifyData?.data?.isPlaying;
76 |
77 | if (shouldPreferSpotify) {
78 | parsedData = spotifyData;
79 | }
80 |
81 | return {
82 | currentlyPlaying: parsedData
83 | };
84 | }
85 |
--------------------------------------------------------------------------------
/src/components/guestbook/guestbook-form.tsx:
--------------------------------------------------------------------------------
1 | import { useState, type FormEvent } 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 { CustomSession } from '@lib/types/api';
8 |
9 | type GuestbookCardProps = {
10 | session: CustomSession | null;
11 | registerGuestbook: (text: Text) => Promise;
12 | };
13 |
14 | export function GuestbookForm({
15 | session,
16 | registerGuestbook
17 | }: GuestbookCardProps): React.JSX.Element {
18 | const [loading, setLoading] = useState(false);
19 |
20 | const handleSubmit = async (e: FormEvent): Promise => {
21 | e.preventDefault();
22 |
23 | setLoading(true);
24 |
25 | const input = e.currentTarget[0] as HTMLInputElement;
26 |
27 | input.blur();
28 |
29 | const { value } = input;
30 |
31 | await registerGuestbook(value);
32 |
33 | input.value = '';
34 |
35 | setLoading(false);
36 | };
37 |
38 | return (
39 | <>
40 |
75 | {session && (
76 |
83 | ← Sign out @{session.user.name}
84 |
85 | )}
86 | >
87 | );
88 | }
89 |
90 | function handleSignIn(): void {
91 | void signIn('github');
92 | }
93 |
94 | function handleSignOut(): void {
95 | void signOut();
96 | }
97 |
--------------------------------------------------------------------------------
/src/styles/mdx.scss:
--------------------------------------------------------------------------------
1 | .prose {
2 | & > :is(h2, h3) {
3 | @apply scroll-mt-24;
4 |
5 | a {
6 | border-bottom: 0 !important;
7 |
8 | span {
9 | @apply text-black dark:text-white;
10 | }
11 |
12 | &::after {
13 | @apply gradient-title ml-2 inline-block opacity-0
14 | transition-opacity duration-300 content-['#'];
15 | }
16 |
17 | &:hover,
18 | &:focus-visible {
19 | &::after {
20 | @apply opacity-100;
21 | }
22 | }
23 | }
24 | }
25 |
26 | & :where(code):not(:where([class~='not-prose'] *)) {
27 | @apply main-border rounded-md p-1;
28 | }
29 |
30 | @mixin border-gradient($position) {
31 | border-image: linear-gradient(
32 | to $position,
33 | theme('colors.accent.start'),
34 | theme('colors.accent.end')
35 | )
36 | 1;
37 | }
38 |
39 | blockquote {
40 | @include border-gradient(top);
41 |
42 | p:first-of-type::before,
43 | p:last-of-type::after {
44 | @apply content-none;
45 | }
46 | }
47 |
48 | hr {
49 | @include border-gradient(right);
50 | }
51 |
52 | figure[data-rehype-pretty-code-figure] {
53 | @apply relative grid;
54 |
55 | @each $element in 'figcaption', 'div' {
56 | #{$element}[data-rehype-pretty-code-title] {
57 | @apply gradient-title main-border absolute left-6 z-10 mt-0
58 | block rounded-b-md border-t-0 px-2 text-base font-medium;
59 |
60 | & + div {
61 | @apply hidden;
62 | }
63 | }
64 | }
65 |
66 | pre {
67 | padding: 0.75rem 0 !important;
68 |
69 | @apply main-border;
70 |
71 | &[data-theme*=' '],
72 | &[data-theme*=' '] span {
73 | color: var(--shiki-light);
74 | background-color: var(--shiki-light-bg);
75 | }
76 |
77 | @media (prefers-color-scheme: dark) {
78 | &[data-theme*=' '],
79 | &[data-theme*=' '] span {
80 | color: var(--shiki-dark);
81 | background-color: var(--shiki-dark-bg);
82 | }
83 | }
84 |
85 | code {
86 | counter-reset: line;
87 |
88 | @apply mt-8;
89 |
90 | .word {
91 | background: rgba(200, 200, 255, 0.15);
92 |
93 | @apply rounded p-1;
94 | }
95 |
96 | .line {
97 | @apply border-l-2 border-transparent px-5 py-0;
98 |
99 | &.highlighted {
100 | background: rgba(200, 200, 255, 0.1);
101 |
102 | @apply border-l-blue-400;
103 | }
104 | }
105 |
106 | &[data-line-numbers] {
107 | .line::before {
108 | content: counter(line);
109 | counter-increment: line;
110 |
111 | @apply mr-4 inline-block w-4 text-right text-slate-600;
112 | }
113 |
114 | .line.highlighted::before {
115 | @apply text-slate-800 dark:text-slate-400;
116 | }
117 | }
118 | }
119 | }
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/components/modal/image-preview.tsx:
--------------------------------------------------------------------------------
1 | import Image, { type ImageProps, type StaticImageData } from 'next/image';
2 | import { clsx } from 'clsx';
3 | import { useModal } from '@lib/hooks/use-modal';
4 | import { preventBubbling } from '@lib/helper';
5 | import { Modal } from './modal';
6 |
7 | type ImagePreviewProps = Omit & {
8 | src: StaticImageData;
9 | customLink?: string;
10 | wrapperClassName?: string;
11 | };
12 |
13 | export function ImagePreview({
14 | src,
15 | alt,
16 | className,
17 | customLink,
18 | wrapperClassName
19 | }: ImagePreviewProps): React.JSX.Element {
20 | const { open, openModal, closeModal } = useModal();
21 |
22 | const { src: imageLink } = src;
23 |
24 | const imageIsGif = imageLink.endsWith('.gif');
25 | const placeholder: ImageProps['placeholder'] = imageIsGif ? 'empty' : 'blur';
26 |
27 | customLink ??= imageLink;
28 |
29 | return (
30 | <>
31 |
32 |
67 |
68 |
72 |
82 |
83 | >
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/guestbook/guestbook-entry.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { motion, type MotionProps } 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 { CustomSession } from '@lib/types/api';
10 | import type { Guestbook } from '@lib/types/guestbook';
11 |
12 | type GuestbookEntryProps = Guestbook & {
13 | session: CustomSession | null;
14 | unRegisterGuestbook: (id: string) => Promise;
15 | };
16 |
17 | export function GuestbookEntry({
18 | id,
19 | text,
20 | name,
21 | image,
22 | session,
23 | username,
24 | createdAt,
25 | createdBy,
26 | unRegisterGuestbook
27 | }: GuestbookEntryProps): React.JSX.Element {
28 | const [loading, setLoading] = useState(false);
29 |
30 | const handleUnRegisterGuestbook = async (): Promise => {
31 | setLoading(true);
32 | await unRegisterGuestbook(id);
33 | };
34 |
35 | const isOwner = session?.user.id === createdBy || session?.user.admin;
36 |
37 | const githubProfileUrl = `https://github.com/${username}`;
38 |
39 | return (
40 |
45 |
46 |
53 |
54 |
55 |
56 |
61 | {name}
62 |
63 |
67 |
68 | {formatTimestamp(createdAt)}
69 |
70 |
71 |
72 |
{text}
73 |
74 | {isOwner && (
75 |
82 |
83 |
84 | )}
85 |
86 | );
87 | }
88 |
89 | const variants: MotionProps = {
90 | initial: { opacity: 0 },
91 | animate: { opacity: 1, transition: { duration: 0.8 } },
92 | exit: { opacity: 0, transition: { duration: 0.2 } }
93 | };
94 |
--------------------------------------------------------------------------------
/eslint.config.ts:
--------------------------------------------------------------------------------
1 | import { dirname } from 'path';
2 | import { fileURLToPath } from 'url';
3 | import eslint from '@eslint/js';
4 | import tseslint from 'typescript-eslint';
5 | import { FlatCompat } from '@eslint/eslintrc';
6 |
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = dirname(__filename);
9 |
10 | const compat = new FlatCompat({
11 | baseDirectory: __dirname
12 | });
13 |
14 | export default tseslint.config([
15 | ...compat.extends('next/core-web-vitals', 'next/typescript'),
16 | eslint.configs.recommended,
17 | tseslint.configs.recommendedTypeChecked,
18 | {
19 | ignores: ['*.mjs']
20 | },
21 | {
22 | files: ['**/*.{ts,tsx}'],
23 | languageOptions: {
24 | parserOptions: {
25 | projectService: true,
26 | tsconfigRootDir: import.meta.dirname
27 | }
28 | },
29 | rules: {
30 | // General rules
31 | semi: ['error', 'always'],
32 | quotes: [
33 | 'error',
34 | 'single',
35 | {
36 | avoidEscape: true
37 | }
38 | ],
39 | 'prefer-const': [
40 | 'error',
41 | {
42 | destructuring: 'all'
43 | }
44 | ],
45 | 'jsx-quotes': ['error', 'prefer-single'],
46 | 'linebreak-style': ['error', 'unix'],
47 | 'no-console': 'warn',
48 | 'comma-dangle': ['error', 'never'],
49 | 'no-unused-expressions': 'error',
50 | 'no-constant-binary-expression': 'error',
51 |
52 | // Import plugin rules
53 | 'import/order': [
54 | 'warn',
55 | {
56 | pathGroups: [
57 | {
58 | pattern: '*.scss',
59 | group: 'builtin',
60 | position: 'before',
61 |
62 | patternOptions: {
63 | matchBase: true
64 | }
65 | },
66 | {
67 | pattern: '@lib/**',
68 | group: 'external',
69 | position: 'after'
70 | },
71 | {
72 | pattern: '@components/**',
73 | group: 'external',
74 | position: 'after'
75 | }
76 | ],
77 | warnOnUnassignedImports: true,
78 | pathGroupsExcludedImportTypes: ['type'],
79 | groups: [
80 | 'builtin',
81 | 'external',
82 | 'internal',
83 | 'parent',
84 | 'sibling',
85 | 'index',
86 | 'object',
87 | 'type'
88 | ]
89 | }
90 | ],
91 | 'import/no-duplicates': ['warn', { 'prefer-inline': true }],
92 |
93 | // TypeScript plugin rules
94 | '@typescript-eslint/consistent-type-imports': 'warn',
95 | '@typescript-eslint/consistent-type-exports': 'warn',
96 | '@typescript-eslint/prefer-nullish-coalescing': 'warn',
97 | '@typescript-eslint/explicit-function-return-type': 'warn',
98 | '@typescript-eslint/no-unused-vars': [
99 | 'warn',
100 | {
101 | args: 'all',
102 | argsIgnorePattern: '^_',
103 | varsIgnorePattern: '^_'
104 | }
105 | ],
106 | '@typescript-eslint/no-misused-promises': [
107 | 'error',
108 | {
109 | checksVoidReturn: {
110 | attributes: false
111 | }
112 | }
113 | ]
114 | }
115 | }
116 | ]);
117 |
--------------------------------------------------------------------------------
/src/pages/statistics.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import { formatNumber } from '@lib/format';
3 | import { setTransition } from '@lib/transition';
4 | import { getAllContentsData, getAllContentsStatistics } from '@lib/api';
5 | import { SEO } from '@components/common/seo';
6 | import { Accent } from '@components/ui/accent';
7 | import { Table } from '@components/statistics/table';
8 | import type { InferGetStaticPropsType, GetStaticPropsResult } from 'next/types';
9 | import type { ContentData, ContentStatistics } from '@lib/types/statistics';
10 |
11 | export default function Statistics({
12 | contentsData,
13 | contentsStatistics
14 | }: InferGetStaticPropsType): React.JSX.Element {
15 | return (
16 |
17 |
18 |
19 |
23 | Statistics
24 |
25 |
29 | A statistics from blog and projects.
30 |
31 |
32 |
36 | {contentsStatistics.map(
37 | ({ type, totalPosts, totalViews, totalLikes }) => (
38 |
42 | {type}
43 |
44 |
45 | {formatNumber(totalPosts)} Posts
46 |
47 |
48 | {formatNumber(totalViews)} views
49 |
50 |
51 | {formatNumber(totalLikes)} likes
52 |
53 |
54 |
55 | )
56 | )}
57 |
58 | {contentsData.map(({ type, data }, index) => (
59 |
60 |
64 | {type}
65 |
66 |
67 |
68 |
69 |
70 | ))}
71 |
72 | );
73 | }
74 |
75 | type StatisticsProps = {
76 | contentsData: ContentData[];
77 | contentsStatistics: ContentStatistics[];
78 | };
79 |
80 | export async function getStaticProps(): Promise<
81 | GetStaticPropsResult
82 | > {
83 | const contentsData = await getAllContentsData();
84 | const contentsStatistics = await getAllContentsStatistics();
85 |
86 | return {
87 | props: {
88 | contentsData,
89 | contentsStatistics
90 | },
91 | revalidate: 60
92 | };
93 | }
94 |
--------------------------------------------------------------------------------
/src/styles/globals.scss:
--------------------------------------------------------------------------------
1 | @use 'nprogress';
2 | @use 'table';
3 | @use 'mdx';
4 |
5 | @tailwind base;
6 | @tailwind components;
7 | @tailwind utilities;
8 |
9 | @layer base {
10 | /*
11 | TODO: Big refactor for global styles.
12 |
13 | Make base rules for light and dark themes, so that the changes can be automatically applied
14 | when the user switches between themes, without manually changing dark modifiers in the code.
15 |
16 | For example, this color palette:
17 |
18 | - background: for the background
19 | - foreground: for the main text
20 | - primary: for the first accent color
21 | - secondary: for the second accent color
22 | - muted: for the third accent color
23 | - accent: for the main accent color
24 |
25 | Make both colors for light and dark themes, so that the changes can be automatically applied
26 | when the user switches between themes, without manually changing dark modifiers in the code.
27 | */
28 |
29 | html {
30 | // ! use important as a workaround for nextjs@13.1.0 bug that prevents smooth scroll
31 | // * remove it when the bug is fixed
32 | scroll-behavior: smooth !important;
33 | }
34 |
35 | body {
36 | @apply bg-white font-inter transition-colors dark:bg-black;
37 | }
38 |
39 | main {
40 | @apply layout md:pt-12;
41 | }
42 |
43 | ::selection {
44 | @apply bg-accent-main text-white;
45 | }
46 | }
47 |
48 | @layer components {
49 | .layout {
50 | @apply mx-auto max-w-6xl px-6;
51 | }
52 |
53 | .card-layout {
54 | @apply grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4;
55 | }
56 |
57 | .custom-button {
58 | @apply px-4 py-2 font-bold;
59 | }
60 |
61 | .main-border {
62 | @apply border border-gray-300 dark:border-gray-600;
63 | }
64 |
65 | .custom-input {
66 | @apply main-border rounded-md bg-white px-3 py-2 outline-none transition focus:border-accent-main dark:bg-black;
67 | }
68 |
69 | .smooth-tab {
70 | @apply rounded-md transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-main;
71 | }
72 |
73 | .clickable {
74 | @apply smooth-tab main-border rounded-md shadow-sm transition hover:scale-[1.03] hover:shadow-md active:scale-[0.97];
75 |
76 | &:focus-visible {
77 | @apply scale-[1.03] shadow-md;
78 | }
79 | }
80 |
81 | .gradient-title {
82 | @apply bg-gradient-to-r from-accent-start to-accent-end bg-clip-text text-transparent;
83 | }
84 |
85 | .gradient-background {
86 | @apply bg-gradient-to-r from-accent-start to-accent-end;
87 | }
88 |
89 | .custom-underline {
90 | @apply underline decoration-transparent outline-none transition [text-decoration-thickness:1px]
91 | hover:decoration-inherit focus-visible:decoration-inherit;
92 | }
93 |
94 | .animated-underline {
95 | background-size: 0 2px;
96 | background-position: 0 100%;
97 |
98 | @apply bg-gradient-to-r from-accent-start to-accent-end bg-no-repeat pb-0.5 no-underline
99 | outline-none transition-all duration-300;
100 |
101 | &.with-dots {
102 | @apply border-b border-dotted border-black dark:border-white;
103 | }
104 |
105 | &:hover,
106 | &:focus-visible,
107 | &:has(+ div:hover),
108 | .project-card:hover &,
109 | .project-card:focus-visible & {
110 | background-size: 100% 2px;
111 |
112 | &.with-dots {
113 | @apply border-transparent;
114 | }
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/components/content/likes-counter.tsx:
--------------------------------------------------------------------------------
1 | import { motion, type MotionProps } from 'framer-motion';
2 | import { clsx } from 'clsx';
3 | import { useContentLikes } from '@lib/hooks/use-content-likes';
4 | import type { Content } from '@lib/types/contents';
5 |
6 | export function LikesCounter({
7 | slug
8 | }: Pick): React.JSX.Element {
9 | const { likeStatus, isLoading, registerLikes } = useContentLikes(slug);
10 |
11 | const { likes, userLikes } = likeStatus ?? {};
12 |
13 | const likesLimitReached = !!(userLikes !== undefined && userLikes >= 5);
14 | const likesIsDisabled = !likeStatus || likesLimitReached;
15 |
16 | return (
17 |
23 |
29 |
30 |
31 |
39 | {isLoading ? '...' : likes}
40 |
41 |
42 | );
43 | }
44 |
45 | function GradientHeart({ likes }: { likes: number }): React.JSX.Element {
46 | return (
47 | <>
48 |
52 | 🥳
53 |
54 |
55 |
56 |
57 |
63 |
69 |
70 |
74 |
75 |
76 |
77 |
78 |
82 |
87 |
88 |
89 | >
90 | );
91 | }
92 |
93 | const animate: Pick = {
94 | animate: {
95 | opacity: [1, 1, 1, 0],
96 | y: -48,
97 | transition: {
98 | duration: 0.7
99 | }
100 | }
101 | };
102 |
--------------------------------------------------------------------------------
/src/lib/format.ts:
--------------------------------------------------------------------------------
1 | import type { Timestamp } from 'firebase/firestore';
2 |
3 | const NUMBER_FORMATTER = new Intl.NumberFormat();
4 |
5 | /**
6 | * Returns a formatted number string with thousand separators.
7 | */
8 | export function formatNumber(numberValue: number): string {
9 | return NUMBER_FORMATTER.format(numberValue);
10 | }
11 |
12 | const DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
13 | dateStyle: 'long'
14 | });
15 |
16 | /**
17 | * Get a formatted date string.
18 | *
19 | * @param dateString a string in `YYYY-MM-DD` format.
20 | * @returns a formatted date string.
21 | */
22 | export function formatDate(dateString: string): string {
23 | const date = new Date(dateString);
24 | return DATE_FORMATTER.format(date);
25 | }
26 |
27 | const SHORT_TIMESTAMP_FORMATTER = new Intl.DateTimeFormat(undefined, {
28 | timeStyle: 'short'
29 | });
30 |
31 | const LONG_TIMESTAMP_FORMATTER = new Intl.DateTimeFormat(undefined, {
32 | dateStyle: 'short'
33 | });
34 |
35 | type TimestampProps = Pick;
36 |
37 | /**
38 | * Get a formatted date from a Firestore timestamp.
39 | *
40 | * @param timestampProps The timestamp to format.
41 | * @returns A formatted date string.
42 | */
43 | export function formatTimestamp(timestamp: TimestampProps): string {
44 | const date = getDateFromTimestamp(timestamp);
45 |
46 | if (dateIsToday(date))
47 | return `Today at ${SHORT_TIMESTAMP_FORMATTER.format(date)}`;
48 |
49 | if (dateIsYesterday(date))
50 | return `Yesterday at ${SHORT_TIMESTAMP_FORMATTER.format(date)}`;
51 |
52 | return LONG_TIMESTAMP_FORMATTER.format(date);
53 | }
54 |
55 | /**
56 | * Formats milliseconds to a playback time string in the format "M:SS".
57 | *
58 | * @param ms The number of milliseconds to format.
59 | * @returns A formatted string representing the playback time.
60 | */
61 | export function formatMilisecondsToPlayback(ms: number): string {
62 | if (!ms || ms < 0) return '0:00';
63 |
64 | const totalSeconds = Math.floor(ms / 1000);
65 |
66 | const minutes = Math.floor(totalSeconds / 60);
67 | const seconds = totalSeconds % 60;
68 |
69 | const paddedSeconds = seconds.toString().padStart(2, '0');
70 |
71 | return `${minutes}:${paddedSeconds}`;
72 | }
73 |
74 | const FULL_TIMESTAMP_FORMATTER = new Intl.DateTimeFormat(undefined, {
75 | weekday: 'short',
76 | day: 'numeric',
77 | month: 'short',
78 | year: 'numeric',
79 | hour: 'numeric',
80 | minute: 'numeric',
81 | second: 'numeric'
82 | });
83 |
84 | /**
85 | * Get a full formatted date from a Firestore timestamp.
86 | *
87 | * @param timestamp The timestamp to format.
88 | * @returns A formatted date string.
89 | */
90 | export function formatFullTimeStamp(timestamp: TimestampProps): string {
91 | const date = getDateFromTimestamp(timestamp);
92 |
93 | return FULL_TIMESTAMP_FORMATTER.format(date);
94 | }
95 |
96 | /**
97 | * Returns a converted date from a Firestore timestamp.
98 | */
99 | function getDateFromTimestamp({ seconds, nanoseconds }: TimestampProps): Date {
100 | const miliseconds = seconds * 1000 + nanoseconds / 1_000_000;
101 | const date = new Date(miliseconds);
102 |
103 | return date;
104 | }
105 |
106 | /**
107 | * Returns a boolean whether the given date is today.
108 | */
109 | function dateIsToday(date: Date): boolean {
110 | return new Date().toDateString() === date.toDateString();
111 | }
112 |
113 | /**
114 | * Returns a boolean whether the given date is yesterday.
115 | */
116 | function dateIsYesterday(date: Date): boolean {
117 | const yesterday = new Date();
118 | yesterday.setDate(yesterday.getDate() - 1);
119 |
120 | return yesterday.toDateString() === date.toDateString();
121 | }
122 |
--------------------------------------------------------------------------------
/src/pages/guestbook.tsx:
--------------------------------------------------------------------------------
1 | import { getServerSession } from 'next-auth/next';
2 | import { AnimatePresence, motion } from 'framer-motion';
3 | import { getGuestbook } from '@lib/api';
4 | import { setTransition } from '@lib/transition';
5 | import { useGuestbook } from '@lib/hooks/use-guestbook';
6 | import { SEO } from '@components/common/seo';
7 | import { Accent } from '@components/ui/accent';
8 | import { GuestbookCard } from '@components/guestbook/guestbook-card';
9 | import { GuestbookForm } from '@components/guestbook/guestbook-form';
10 | import { GuestbookEntry } from '@components/guestbook/guestbook-entry';
11 | import { authOptions } from './api/auth/[...nextauth]';
12 | import type {
13 | GetServerSidePropsResult,
14 | GetServerSidePropsContext,
15 | InferGetServerSidePropsType
16 | } from 'next';
17 | import type { AuthOptions } from 'next-auth';
18 | import type { CustomSession } from '@lib/types/api';
19 | import type { Guestbook } from '@lib/types/guestbook';
20 |
21 | export default function Guestbook({
22 | session,
23 | guestbook: fallbackData
24 | }: InferGetServerSidePropsType): React.JSX.Element {
25 | const { guestbook, registerGuestbook, unRegisterGuestbook } =
26 | useGuestbook(fallbackData);
27 |
28 | return (
29 |
30 |
34 |
35 |
39 | Guestbook
40 |
41 |
45 | Leave a comment below. It could be anything - appreciation,
46 | information, wisdom, or even humor. Surprise me!
47 |
48 |
49 |
50 |
51 |
55 |
56 |
57 |
61 |
62 | {guestbook?.length ? (
63 | guestbook.map((entry) => (
64 |
70 | ))
71 | ) : (
72 |
76 | Sorry, Guestbook is currently empty :(
77 |
78 | )}
79 |
80 |
81 |
82 | );
83 | }
84 |
85 | type GuestbookProps = {
86 | session: CustomSession | null;
87 | guestbook: Guestbook[];
88 | };
89 |
90 | export async function getServerSideProps(
91 | context: GetServerSidePropsContext
92 | ): Promise> {
93 | const session = await getServerSession(
94 | context.req,
95 | context.res,
96 | authOptions
97 | );
98 |
99 | const guestbook = await getGuestbook();
100 |
101 | return {
102 | props: {
103 | session,
104 | guestbook
105 | }
106 | };
107 | }
108 |
--------------------------------------------------------------------------------
/src/pages/projects/twitter-clone.mdx:
--------------------------------------------------------------------------------
1 | import Banner from '../../../public/assets/projects/twitter-clone/banner.png';
2 | import { ContentLayout } from '@components/layout/content-layout';
3 | import { getContentSlug } from '@lib/mdx';
4 |
5 | export const meta = {
6 | title: 'Twitter Clone',
7 | publishedAt: '2023-01-05',
8 | banner: Banner,
9 | description:
10 | 'How I built a Twitter clone using Next.js, TypeScript, and Firebase.',
11 | tags: 'nextjs,tailwindcss,typescript,firebase',
12 | category: 'Personal Project',
13 | github: 'https://github.com/ccrsxx/twitter-clone',
14 | link: 'https://twitter-clone-ccrsxx.vercel.app'
15 | };
16 |
17 | export const getStaticProps = getContentSlug('projects', 'twitter-clone');
18 |
19 | export default ({ children }) => (
20 | {children}
21 | );
22 |
23 | {/* content start */}
24 |
25 | ## Overview
26 |
27 | As a self-taught fullstack developer, I was always interested in building my own version of popular web applications. Recently, I decided to tackle the challenge of building a Twitter clone using a combination of modern technologies.
28 |
29 | ## The Tech Stack
30 |
31 | For this project, I chose to use the following technologies:
32 |
33 | - Next.js for the frontend framework
34 | - TypeScript for static type checking
35 | - Tailwind CSS for styling
36 | - Firebase for the backend (database, authentication, and hosting)
37 | - Headless UI for the modal, menu, and popover components
38 | - React Hot Toast for interactive action notifications
39 | - Framer Motion for smooth animations
40 |
41 | ## Features
42 |
43 | I wanted to make sure that my Twitter clone had all the basic features of the real platform, so I implemented the following:
44 |
45 | - User authentication (sign up, log in, log out)
46 | - Tweet creation, editing, and deletion
47 | - Like and unlike tweets
48 | - Follow and unfollow users
49 | - View user profiles and tweet lists
50 | - Real-time updates for likes, follows, and tweets
51 | - Infinite scroll for the home timeline and user tweet lists
52 | - Responsive design for mobile devices
53 |
54 | Building this project was a great learning experience for me. Here are some of the highlights:
55 |
56 | - I started by setting up a Next.js project with TypeScript and installing the necessary dependencies (including Firebase).
57 | - I then set up the Firebase backend, including the Firestore database and authentication system.
58 | - Next, I worked on the user authentication flow for log in and log out. I used Firebase's built-in authentication functions to handle the logic.
59 | - With the authentication system in place, I moved on to the main feature: tweeting. This involved creating a form for creating and editing tweets, as well as displaying a list of tweets on the home timeline and user profiles. I used Firebase onSnapshot listeners to update the data in real time.
60 | - I then added the ability to like and unlike tweets, as well as follow and unfollow users. This required updating the data in the Firestore database and using real-time listeners to trigger updates on the frontend.
61 | - Next, I implemented profile page for user. This involved creating a new page for each user and displaying their tweets, likes, and followers.
62 | - Finally, I worked on the design and polish of the app. I used Tailwind CSS for the basic styles, and Headless UI and Framer Motion for the animations. I also added React Hot Toast for interactive notifications.
63 |
64 | ## Conclusion
65 |
66 | Overall, building this Twitter clone was a rewarding and educational experience. I learned a lot about using Next.js, TypeScript, and Firebase together, as well as various other tools and libraries.
67 |
68 | If you're interested in seeing the code or trying out the app, you can check out the repository on [GitHub](https://github.com/ccrsxx/twitter-clone).
69 |
70 | Thanks for reading!
71 |
--------------------------------------------------------------------------------
/src/components/blog/sort-listbox.tsx:
--------------------------------------------------------------------------------
1 | import { AnimatePresence, motion, type MotionProps } 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 |
7 | type SortListboxProps = {
8 | sortOrder: SortOption;
9 | onSortOrderChange: Dispatch>;
10 | };
11 |
12 | export function SortListbox({
13 | sortOrder,
14 | onSortOrderChange
15 | }: SortListboxProps): React.JSX.Element {
16 | return (
17 |
18 | {({ open }): React.JSX.Element => (
19 |
20 |
26 |
27 | {sortOrder === 'date' ? : }
28 | Sort by {sortOrder}
29 |
30 |
31 |
32 |
33 |
34 |
35 | {open && (
36 |
43 | {sortOptions.map((sortOption) => (
44 |
46 | clsx(
47 | `relative cursor-pointer select-none py-2 pl-10 pr-4 transition-colors
48 | hover:bg-blue-300/10 dark:hover:bg-blue-300/25`,
49 | active && 'bg-blue-300/10 dark:bg-blue-300/25'
50 | )
51 | }
52 | value={sortOption}
53 | key={sortOption}
54 | >
55 | {({ selected }): React.JSX.Element => (
56 | <>
57 |
63 | Sort by {sortOption}
64 |
65 | {selected && (
66 |
70 |
71 |
72 | )}
73 | >
74 | )}
75 |
76 | ))}
77 |
78 | )}
79 |
80 |
81 | )}
82 |
83 | );
84 | }
85 |
86 | export type SortOption = (typeof sortOptions)[number];
87 |
88 | export const sortOptions = ['date', 'views'] as const;
89 |
90 | const variants: MotionProps = {
91 | initial: { opacity: 0, y: 20 },
92 | animate: {
93 | opacity: 1,
94 | y: 0,
95 | transition: { type: 'spring', duration: 0.4 }
96 | },
97 | exit: { opacity: 0, y: 20, transition: { duration: 0.2 } }
98 | };
99 |
--------------------------------------------------------------------------------
/src/pages/design.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import { useTheme } from 'next-themes';
3 | import { clsx } from 'clsx';
4 | import { setTransition } from '@lib/transition';
5 | import { SEO } from '@components/common/seo';
6 | import { Accent } from '@components/ui/accent';
7 | import { ThemeSwitch } from '@components/common/theme-switch';
8 |
9 | export default function Design(): React.JSX.Element {
10 | const { theme } = useTheme();
11 |
12 | return (
13 |
14 |
15 |
16 |
20 | Design
21 |
22 |
26 | risalamin.com's color palette.
27 |
28 |
29 |
33 |
34 |
{theme} Mode
35 |
36 |
37 |
38 | Font Family: Inter
39 |
40 |
41 | {colorPalette.map(({ title, hexCode, className }) => (
42 |
43 |
46 |
47 |
{title}
48 |
49 | {hexCode}
50 |
51 |
52 |
53 | ))}
54 |
55 |
56 |
57 | );
58 | }
59 |
60 | const colorPalette = [
61 | {
62 | title: 'White Background',
63 | hexCode: '#ffffff',
64 | className: 'bg-white'
65 | },
66 | {
67 | title: 'Black Background',
68 | hexCode: '#222222',
69 | className: 'bg-black'
70 | },
71 | {
72 | title: 'White Text',
73 | hexCode: '#ffffff',
74 | className: 'bg-white'
75 | },
76 | {
77 | title: 'Black Text',
78 | hexCode: '#000000',
79 | className: 'bg-black'
80 | },
81 | {
82 | title: 'Gray 100 Text',
83 | hexCode: '#f3f4f6',
84 | className: 'bg-gray-100'
85 | },
86 | {
87 | title: 'Gray 200 Text',
88 | hexCode: '#e5e7eb',
89 | className: 'bg-gray-200'
90 | },
91 | {
92 | title: 'Gray 300 Text',
93 | hexCode: '#d1d5db',
94 | className: 'bg-gray-300'
95 | },
96 | {
97 | title: 'Gray 400 Text',
98 | hexCode: '#9ca3af',
99 | className: 'bg-gray-400'
100 | },
101 | {
102 | title: 'Gray 500 Text',
103 | hexCode: '#6b7280',
104 | className: 'bg-gray-500'
105 | },
106 | {
107 | title: 'Gray 600 Text',
108 | hexCode: '#4b5563',
109 | className: 'bg-gray-600'
110 | },
111 | {
112 | title: 'Gray 700 Text',
113 | hexCode: '#374151',
114 | className: 'bg-gray-700'
115 | },
116 | {
117 | title: 'Gray 800 Text',
118 | hexCode: '#1f2937',
119 | className: 'bg-gray-800'
120 | },
121 | {
122 | title: 'Gray 900 Text',
123 | hexCode: '#111827',
124 | className: 'bg-gray-900'
125 | },
126 | {
127 | title: 'Gradient Color',
128 | hexCode: '#a855f7 to #f472b6',
129 | className: 'bg-gradient-to-tr from-accent-start to-accent-end'
130 | }
131 | ] as const;
132 |
--------------------------------------------------------------------------------
/src/components/layout/footer.tsx:
--------------------------------------------------------------------------------
1 | import { HiEnvelope } from 'react-icons/hi2';
2 | import { SiDiscord, SiGithub, SiLinkedin, SiX } from 'react-icons/si';
3 | import { Tooltip } from '@components/ui/tooltip';
4 | import { UnstyledLink } from '@components/link/unstyled-link';
5 | import type { IconType } from 'react-icons';
6 |
7 | export function Footer(): React.JSX.Element {
8 | return (
9 |
58 | );
59 | }
60 |
61 | type FooterLink = {
62 | name: string;
63 | href: string;
64 | tip: string | React.JSX.Element;
65 | };
66 |
67 | const footerLinks: FooterLink[] = [
68 | {
69 | name: 'Source code',
70 | href: 'https://github.com/ccrsxx/portofolio',
71 | tip: (
72 | <>
73 | This website is open source!
74 | >
75 | )
76 | },
77 | {
78 | name: 'Design',
79 | href: '/design',
80 | tip: 'risalamin.com color palette'
81 | },
82 | {
83 | name: 'Statistics',
84 | href: '/statistics',
85 | tip: 'Blog & Projects statistics'
86 | }
87 | // {
88 | // name: 'Subscribe',
89 | // href: '/subscribe',
90 | // tip: 'Get notified when I publish a new post'
91 | // }
92 | ];
93 |
94 | type SocialLink = {
95 | tip: string;
96 | name: string;
97 | href: string;
98 | Icon: IconType;
99 | };
100 |
101 | const socialLinks: SocialLink[] = [
102 | {
103 | tip: 'Contact me at',
104 | name: 'me@risalamin.com',
105 | href: 'mailto:me@risalamin.com',
106 | Icon: HiEnvelope
107 | },
108 | {
109 | tip: "I'm also on",
110 | name: 'Discord',
111 | href: 'https://discord.com/users/414304208649453568',
112 | Icon: SiDiscord
113 | },
114 | {
115 | tip: 'See my other projects on',
116 | name: 'GitHub',
117 | href: 'https://github.com/ccrsxx',
118 | Icon: SiGithub
119 | },
120 | {
121 | tip: 'Find me on',
122 | name: 'LinkedIn',
123 | href: 'https://linkedin.com/in/risalamin',
124 | Icon: SiLinkedin
125 | },
126 | {
127 | tip: 'Follow me on',
128 | name: 'X',
129 | href: 'https://x.com/ccrsxx',
130 | Icon: SiX
131 | }
132 | ];
133 |
--------------------------------------------------------------------------------
/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 { frontendEnv } 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): React.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(
41 | 'image',
42 | frontendEnv.NEXT_PUBLIC_URL + (banner?.src as string)
43 | );
44 | }
45 |
46 | const isHomepage = asPath === '/';
47 | const isDarkMode = theme === 'dark';
48 |
49 | const { colorScheme, themeColor } = systemTheme[+isDarkMode];
50 |
51 | const ogTitle = `${title} | ${
52 | isHomepage ? 'Fullstack Developer' : 'Risal Amin'
53 | }`;
54 |
55 | const ogImageUrl = `${frontendEnv.NEXT_PUBLIC_BACKEND_URL}/og?${ogImageQuery.toString()}`;
56 |
57 | const ogUrl = `${frontendEnv.NEXT_PUBLIC_BACKEND_URL}${isHomepage ? '' : asPath}`;
58 |
59 | return (
60 |
61 | {ogTitle}
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | {article ? (
87 | <>
88 |
89 |
90 |
91 |
92 | {tags?.split(',').map((tag) => (
93 |
94 | ))}
95 | {lastUpdatedAt && (
96 |
97 | )}
98 | >
99 | ) : (
100 |
101 | )}
102 |
103 | );
104 | }
105 |
106 | type SystemTheme = {
107 | themeColor: string;
108 | colorScheme: 'dark' | 'light';
109 | };
110 |
111 | const systemTheme: SystemTheme[] = [
112 | {
113 | themeColor: '#FFFFFF',
114 | colorScheme: 'light'
115 | },
116 | {
117 | themeColor: '#222222',
118 | colorScheme: 'dark'
119 | }
120 | ];
121 |
--------------------------------------------------------------------------------
/src/pages/blog/custom-layout-in-nextjs.mdx:
--------------------------------------------------------------------------------
1 | import Banner from '../../../public/assets/blog/custom-layout-in-nextjs/banner.jpg';
2 | import { ContentLayout } from '@components/layout/content-layout';
3 | import { getContentSlug } from '@lib/mdx';
4 |
5 | export const meta = {
6 | title: 'Creating Custom Layouts in Next.js',
7 | publishedAt: '2022-12-26',
8 | banner: Banner,
9 | bannerAlt: 'Photo by Milad Fakurian on Unsplash',
10 | bannerLink: 'https://unsplash.com/photos/N_nbhTHtmYg',
11 | description:
12 | 'Learn how to create Single Shared Layouts and Per-Page Layouts in Next.js.',
13 | tags: 'nextjs'
14 | };
15 |
16 | export const getStaticProps = getContentSlug('blog', 'custom-layout-in-nextjs');
17 |
18 | export default ({ children }) => (
19 | {children}
20 | );
21 |
22 | {/* content start */}
23 |
24 | React's modular nature allows us to create reusable components, and layouts are no exception. In Next.js, there's a variety of ways to implement custom layouts. In this blog, we'll explore two of the most common approaches:
25 |
26 | - [Single Shared Layout](#single-shared-layout)
27 | - [Per-Page Layout](#per-page-layout)
28 |
29 | ## Single Shared Layout
30 |
31 | A Single Shared Layout in Next.js is a custom layout that serves as a blueprint for every page within our application. Consider a scenario where our application has a common structure, featuring a `navbar` and a `footer` that remain consistent across all pages. In such cases, we can define our layout like this:
32 |
33 | ```tsx title="components/layout/layout.tsx"
34 | import Navbar from './navbar';
35 | import Footer from './footer';
36 | import type { ReactNode } from 'react';
37 |
38 | type LayoutProps = {
39 | children: ReactNode;
40 | };
41 |
42 | export function Layout({ children }: LayoutProps): JSX.Element {
43 | return (
44 | <>
45 |
46 | {children}
47 |
48 | >
49 | );
50 | }
51 | ```
52 |
53 | To implement this custom layout, we can wrap the `Component` within our `_app.tsx` file:
54 |
55 | ```tsx title="pages/_app.tsx"
56 | import { Layout } from '@components/layout';
57 | import type { AppProps } from 'next/app';
58 |
59 | export default function App({ Component, pageProps }: AppProps): JSX.Element {
60 | return (
61 |
62 |
63 |
64 | );
65 | }
66 | ```
67 |
68 | One cool aspect of the Single Shared Layout is that it is consistently reused across all pages, which can also preserve the state of the layout.
69 |
70 | ## Per-Page Layout
71 |
72 | If we want to have different layouts (ex. authentication, dashboard, etc...), we can define a `getLayout` property to pages that will receive the page in `props`, and wrap it in the layout that we want. Since `getLayout` is a function, we can have nested layouts if we want.
73 |
74 | Here's an example of a page:
75 |
76 | ```tsx title="pages/index.tsx"
77 | import { Layout } from '@components/layout/layout'
78 | import { NestedLayout } from '@components/layout/nested-layout'
79 | import type { ReactElement, ReactNode } from 'react'
80 |
81 | function Page(): JSX.Element {
82 | return (
83 | // Content specific to our page...
84 | )
85 | }
86 |
87 | Page.getLayout = (page: ReactElement): ReactNode => {
88 | return (
89 |
90 | {page}
91 |
92 | )
93 | }
94 | ```
95 |
96 | For this approach to work, we'll need to make a few adjustments to our `_app.tsx`:
97 |
98 | ```tsx title="pages/_app.tsx"
99 | import type { ReactElement, ReactNode } from 'react';
100 | import type { NextPage } from 'next';
101 | import type { AppProps } from 'next/app';
102 |
103 | type NextPageWithLayout = NextPage & {
104 | // Define a getLayout function for each page
105 | getLayout?: (page: ReactElement) => ReactNode;
106 | };
107 |
108 | type AppPropsWithLayout = AppProps & {
109 | // Override the default Component type that comes with AppProps
110 | Component: NextPageWithLayout;
111 | };
112 |
113 | export default function App({
114 | Component,
115 | pageProps
116 | }: AppPropsWithLayout): JSX.Element {
117 | /**
118 | * Use the getLayout function defined on the page if available
119 | * Otherwise, fallback to using the default layout that wraps the page contents
120 | */
121 | const getLayout = Component.getLayout ?? ((page): ReactNode => page);
122 |
123 | return getLayout( );
124 | }
125 | ```
126 |
127 | > It's worth noting that Custom Layouts aren't considered as pages. So, if we want to fetch data inside our layout, we'll need to do it on the client-side. we can still use `getStaticProps` and `getServerSideProps` in our pages, but not in our layouts.
128 |
129 | That's how we can create custom layout in Next.js.
130 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-lanyard.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useLocalStorage } from './use-local-storage';
3 | import type { Types } from '@prequist/lanyard';
4 |
5 | export type Options = {
6 | /**
7 | * The Base URL of Lanyard's API. Defaults to `https://api.lanyard.rest`
8 | */
9 | api: {
10 | hostname: string;
11 | secure?: boolean;
12 | };
13 |
14 | /**
15 | * Initial data to use. Useful if server side rendering.
16 | */
17 | initialData?: Types.Presence;
18 | };
19 |
20 | const DEFAULT_OPTIONS: Options = {
21 | api: {
22 | hostname: 'api.lanyard.rest',
23 | secure: true
24 | }
25 | };
26 |
27 | export const SocketOpcode = {
28 | Event: 0,
29 | Hello: 1,
30 | Initialize: 2,
31 | Heartbeat: 3
32 | } as const;
33 |
34 | export type SocketOpcode = (typeof SocketOpcode)[keyof typeof SocketOpcode];
35 |
36 | export const SocketEvents = {
37 | INIT_STATE: 'INIT_STATE',
38 | PRESENCE_UPDATE: 'PRESENCE_UPDATE'
39 | } as const;
40 |
41 | export type SocketEvents = (typeof SocketEvents)[keyof typeof SocketEvents];
42 |
43 | export type SocketData = Types.Presence & {
44 | heartbeat_interval?: number;
45 | };
46 |
47 | export type SocketMessage = {
48 | op: SocketOpcode;
49 | t?: SocketEvents;
50 | d?: SocketData;
51 | };
52 |
53 | /**
54 | * To avoid setting a timeout with no interval, we should
55 | * just fallback to a safe/sensible default (10s)
56 | */
57 | const SAFE_DEFAULT_HEARTBEAT = 10_000;
58 |
59 | export function useLanyard(
60 | snowflake: Types.Snowflake,
61 | options: Partial & {
62 | initialData: NonNullable;
63 | }
64 | ): Types.Presence;
65 |
66 | export function useLanyard(
67 | snowflake: Types.Snowflake,
68 | options?: Partial
69 | ): Types.Presence | null;
70 |
71 | export function useLanyard(
72 | snowflake: Types.Snowflake,
73 | userOptions?: Partial
74 | ): Types.Presence | null {
75 | const options = {
76 | ...DEFAULT_OPTIONS,
77 | ...userOptions
78 | };
79 |
80 | const [data, setData] = useLocalStorage(
81 | 'lanyard',
82 | null
83 | );
84 |
85 | const protocol = options.api.secure ? 'wss' : 'ws';
86 | const url = `${protocol}://${options.api.hostname}/socket`;
87 |
88 | useEffect(() => {
89 | // Don't try to connect on server
90 | if (typeof window === 'undefined') {
91 | return;
92 | }
93 |
94 | if (!('WebSocket' in window)) {
95 | throw new Error(
96 | 'Lanyard failed to connect: The WebSocket API is not supported in this browser.'
97 | );
98 | }
99 |
100 | let heartbeat: ReturnType;
101 |
102 | /**
103 | * The current instance of the WebSocket.
104 | *
105 | * When the socket unexpectedly closes, this variable
106 | * will be reassigned to a new socket instance.
107 | */
108 | let socket: WebSocket;
109 |
110 | function connect(): void {
111 | if (heartbeat) {
112 | clearInterval(heartbeat);
113 | }
114 |
115 | socket = new WebSocket(url);
116 |
117 | socket.addEventListener('close', connect);
118 | socket.addEventListener('message', message);
119 | }
120 |
121 | function message(event: MessageEvent): void {
122 | const message = JSON.parse(event.data) as SocketMessage;
123 |
124 | switch (message.op) {
125 | case SocketOpcode.Hello: {
126 | heartbeat = setInterval(() => {
127 | if (socket.readyState === socket.OPEN) {
128 | socket.send(JSON.stringify({ op: SocketOpcode.Heartbeat }));
129 | }
130 | }, message.d?.heartbeat_interval ?? SAFE_DEFAULT_HEARTBEAT);
131 |
132 | if (socket.readyState === socket.OPEN) {
133 | socket.send(
134 | JSON.stringify({
135 | op: SocketOpcode.Initialize,
136 | d: { subscribe_to_id: snowflake }
137 | })
138 | );
139 | }
140 |
141 | break;
142 | }
143 |
144 | case SocketOpcode.Event: {
145 | switch (message.t) {
146 | case SocketEvents.INIT_STATE:
147 | case SocketEvents.PRESENCE_UPDATE: {
148 | if (message.d) {
149 | setData(message.d);
150 | }
151 |
152 | break;
153 | }
154 |
155 | default: {
156 | break;
157 | }
158 | }
159 |
160 | break;
161 | }
162 |
163 | default: {
164 | break;
165 | }
166 | }
167 | }
168 |
169 | connect();
170 |
171 | return (): void => {
172 | clearInterval(heartbeat);
173 |
174 | socket.removeEventListener('close', connect);
175 | socket.removeEventListener('message', message);
176 |
177 | socket.close();
178 | };
179 | }, [url, snowflake, setData]);
180 |
181 | return data ?? options.initialData ?? null;
182 | }
183 |
--------------------------------------------------------------------------------
/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, type Article } 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 |
22 | type ContentLayoutProps = {
23 | children: ReactElement;
24 | meta: Pick<
25 | Content,
26 | 'title' | 'tags' | 'publishedAt' | 'description' | 'banner'
27 | > &
28 | Pick &
29 | Pick;
30 | };
31 |
32 | export function ContentLayout({
33 | meta,
34 | children
35 | }: ContentLayoutProps): React.JSX.Element {
36 | const [
37 | { title, description, publishedAt, banner, bannerAlt, bannerLink, tags },
38 | { type, slug, readTime, lastUpdatedAt, suggestedContents }
39 | ] = [meta, children.props];
40 |
41 | const contentIsBlog = type === 'blog';
42 |
43 | const githubCommitHistoryUrl = `https://github.com/ccrsxx/portofolio/commits/main/src/pages/${type}/${slug}.mdx`;
44 | const githubContentUrl = `https://github.com/ccrsxx/portofolio/blob/main/src/pages/${type}/${slug}.mdx`;
45 |
46 | const article: Article = {
47 | type,
48 | tags,
49 | banner,
50 | publishedAt,
51 | lastUpdatedAt
52 | };
53 |
54 | return (
55 |
56 |
57 |
64 |
65 | {title}
66 |
67 | Written on {formatDate(publishedAt)} by Risal Amin
68 |
69 | {lastUpdatedAt && (
70 |
71 |
Last updated on {formatDate(lastUpdatedAt)}.
72 |
76 |
77 | View history
78 |
79 |
80 | )}
81 |
82 | {contentIsBlog ? (
83 |
84 | ) : (
85 |
86 | )}
87 |
88 |
89 |
90 |
91 |
92 | {children}
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | Other {contentIsBlog ? 'posts' : type} you might like
101 |
102 |
103 | {contentIsBlog
104 | ? (suggestedContents as Blog[]).map((suggestedContent, index) => (
105 |
106 | ))
107 | : (suggestedContents as Project[]).map(
108 | (suggestedContent, index) => (
109 |
110 | )
111 | )}
112 |
113 |
114 | {/* {contentIsBlog && (
115 |
118 | )} */}
119 |
120 | ← Back to {type}
121 | Edit this on GitHub
122 |
123 |
124 | );
125 | }
126 |
--------------------------------------------------------------------------------
/src/pages/about.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import {
3 | SiFirebase,
4 | SiNextdotjs,
5 | SiTypescript,
6 | SiTailwindcss
7 | } from 'react-icons/si';
8 | import { setTransition } from '@lib/transition';
9 | import { SEO } from '@components/common/seo';
10 | import { Accent } from '@components/ui/accent';
11 | import { Tooltip } from '@components/ui/tooltip';
12 | import { CustomLink } from '@components/link/custom-link';
13 | import type { IconType } from 'react-icons';
14 |
15 | export default function About(): React.JSX.Element {
16 | return (
17 |
18 |
19 |
20 |
24 | About
25 |
26 |
30 | Risal Amin
31 |
32 |
33 |
34 |
38 |
39 | Hi, I'm Risal. I started learning web development in November
40 | 2021, after building my first web app with{' '}
41 | Python and the{' '}
42 | Streamlit {' '}
43 | module. Since then, I've been dedicated to learning as much as
44 | I can about web development.
45 |
46 |
47 | I began my journey by completing the front-end course on{' '}
48 |
49 | FreeCodeCamp
50 | {' '}
51 | and then moved on to{' '}
52 |
53 | The Odin Project
54 | {' '}
55 | to learn fullstack development. I'm always motivated to learn
56 | new technologies and techniques, and I enjoy getting feedback to
57 | help me improve.
58 |
59 |
60 | On this website, I'll be sharing my projects and writing about
61 | what I've learned. I believe that writing helps me better
62 | understand and retain new information, and I'm always happy to
63 | share my knowledge with others. If you have any questions or want to
64 | connect, don't hesitate to reach out!
65 |
66 |
67 |
68 |
69 |
73 | Favorite Tech Stack
74 |
75 |
80 | {favoriteTechStack.map(({ tip, name, href, Icon }) => (
81 |
89 |
90 | {name}
91 |
92 | {', '}
93 | {tip}
94 | >
95 | }
96 | >
97 |
98 |
99 |
100 |
101 | ))}
102 |
103 |
104 |
105 | );
106 | }
107 |
108 | type FavoriteTechStack = {
109 | tip: string;
110 | name: string;
111 | href: string;
112 | Icon: IconType;
113 | };
114 |
115 | const favoriteTechStack: FavoriteTechStack[] = [
116 | {
117 | tip: 'a React framework that makes it easy to build static and server-side rendered applications.',
118 | name: 'Next.js',
119 | href: 'https://nextjs.org',
120 | Icon: SiNextdotjs
121 | },
122 | {
123 | tip: 'a strongly typed language that builds on JavaScript, giving you better tooling at any scale.',
124 | name: 'TypeScript',
125 | href: 'https://www.typescriptlang.org',
126 | Icon: SiTypescript
127 | },
128 | {
129 | tip: 'an app development platform that helps you build and grow apps and games users love.',
130 | name: 'Firebase',
131 | href: 'https://firebase.google.com',
132 | Icon: SiFirebase
133 | },
134 | {
135 | tip: 'a utility-first CSS framework that helps you build custom designs without ever leaving your JSX.',
136 | name: 'Tailwind CSS',
137 | href: 'https://tailwindcss.com',
138 | Icon: SiTailwindcss
139 | }
140 | ];
141 |
--------------------------------------------------------------------------------
/src/components/statistics/table.tsx:
--------------------------------------------------------------------------------
1 | import { useState, type ChangeEvent } from 'react';
2 | import { clsx } from 'clsx';
3 | import {
4 | flexRender,
5 | useReactTable,
6 | getCoreRowModel,
7 | getSortedRowModel,
8 | createColumnHelper,
9 | getFilteredRowModel,
10 | type Row,
11 | type ColumnDef,
12 | type FilterMeta,
13 | type CellContext,
14 | type SortingState,
15 | type SortDirection,
16 | type ColumnFiltersState
17 | } from '@tanstack/react-table';
18 | import { rankItem } from '@tanstack/match-sorter-utils';
19 | import { formatNumber } from '@lib/format';
20 | import { SortIcon } from './sort-icon';
21 | import type { ContentColumn } from '@lib/types/statistics';
22 |
23 | type TableProps = {
24 | data: ContentColumn[];
25 | };
26 |
27 | export function Table({ data }: TableProps): React.JSX.Element {
28 | const [globalFilter, setGlobalFilter] = useState('');
29 | const [sorting, setSorting] = useState([]);
30 |
31 | const [columnFilters, setColumnFilters] = useState([]);
32 |
33 | const { getHeaderGroups, getRowModel } = useReactTable({
34 | data: data,
35 | state: { globalFilter, columnFilters, sorting },
36 | columns: columns,
37 | sortDescFirst: false,
38 | globalFilterFn: fuzzyFilter,
39 | onSortingChange: setSorting,
40 | getCoreRowModel: getCoreRowModel(),
41 | getSortedRowModel: getSortedRowModel(),
42 | getFilteredRowModel: getFilteredRowModel(),
43 | onGlobalFilterChange: setGlobalFilter,
44 | onColumnFiltersChange: setColumnFilters
45 | });
46 |
47 | const handleGlobalFilterChange = ({
48 | target: { value }
49 | }: ChangeEvent): void => setGlobalFilter(value);
50 |
51 | const [totalViews, totalLikes] = getRowModel().rows.reduce(
52 | ([accViews, accLikes], { original: { views, likes } }) => [
53 | accViews + views,
54 | accLikes + likes
55 | ],
56 | [0, 0]
57 | );
58 |
59 | return (
60 |
61 |
68 |
69 |
70 |
71 | {getHeaderGroups().map(({ id, headers }) => (
72 |
73 | {headers.map(
74 | ({
75 | id,
76 | column: {
77 | columnDef,
78 | getCanSort,
79 | getIsSorted,
80 | getToggleSortingHandler
81 | },
82 | getContext
83 | }) => (
84 |
92 |
93 |
94 | {sortDirections.map((sortDirection) => (
95 |
100 | ))}
101 |
102 |
103 | {flexRender(columnDef.header, getContext())}
104 |
105 |
106 |
107 | )
108 | )}
109 |
110 | ))}
111 |
112 |
113 | {getRowModel().rows.map(({ id, getVisibleCells }) => (
114 |
115 | {getVisibleCells().map(({ id, column, getContext }) => (
116 |
117 | {flexRender(column.columnDef.cell, getContext())}
118 |
119 | ))}
120 |
121 | ))}
122 |
123 |
124 |
125 | Total
126 | {formatNumber(totalViews)}
127 | {formatNumber(totalLikes)}
128 |
129 |
130 |
131 |
132 |
133 | );
134 | }
135 |
136 | const { accessor } = createColumnHelper();
137 |
138 | function numericCellFormatter({
139 | getValue
140 | }: CellContext): string {
141 | return formatNumber(getValue());
142 | }
143 |
144 | const columns: ColumnDef[] = [
145 | accessor('slug', { header: 'Post Slug' }),
146 | accessor('views', {
147 | header: 'Views',
148 | cell: numericCellFormatter
149 | }),
150 | accessor('likes', {
151 | header: 'Likes',
152 | cell: numericCellFormatter
153 | })
154 | ];
155 |
156 | function fuzzyFilter(
157 | { getValue }: Row,
158 | columnId: string,
159 | value: string,
160 | addMeta: (meta: FilterMeta) => void
161 | ): boolean {
162 | const itemRank = rankItem(getValue(columnId), value);
163 |
164 | addMeta({ itemRank });
165 |
166 | return itemRank.passed;
167 | }
168 |
169 | const sortDirections: SortDirection[] = ['asc', 'desc'];
170 |
--------------------------------------------------------------------------------
/src/lib/api.ts:
--------------------------------------------------------------------------------
1 | import { createTransport } from 'nodemailer';
2 | import {
3 | doc,
4 | query,
5 | where,
6 | getDoc,
7 | setDoc,
8 | getDocs,
9 | orderBy
10 | } from 'firebase/firestore';
11 | import {
12 | contentsCollection,
13 | guestbookCollection
14 | } from './firebase/collections';
15 | import { backendEnv } from './env-server';
16 | import { getAllContents } from './mdx';
17 | import { getContentFiles } from './mdx-utils';
18 | import { VALID_CONTENT_TYPES } from './helper-server';
19 | import { removeContentExtension } from './helper';
20 | import type { Blog, ContentType } from './types/contents';
21 | import type { CustomSession } from './types/api';
22 | import type { ContentMeta } from './types/meta';
23 | import type {
24 | ContentData,
25 | ContentColumn,
26 | ContentStatistics
27 | } from './types/statistics';
28 | import type { Guestbook } from './types/guestbook';
29 |
30 | /**
31 | * Initialize all blog and projects if not exists in firestore at build time.
32 | */
33 | export async function initializeAllContents(): Promise {
34 | const contentPromises = VALID_CONTENT_TYPES.map((type) =>
35 | initializeContents(type)
36 | );
37 | await Promise.all(contentPromises);
38 | }
39 |
40 | /**
41 | * Initialize contents with selected content type.
42 | */
43 | export async function initializeContents(type: ContentType): Promise {
44 | const contents = await getContentFiles(type);
45 |
46 | const contentPromises = contents.map(async (slug) => {
47 | slug = removeContentExtension(slug);
48 |
49 | const snapshot = await getDoc(doc(contentsCollection, slug));
50 |
51 | if (snapshot.exists()) return;
52 |
53 | const newContent: Omit = {
54 | type,
55 | views: 0,
56 | likes: 0,
57 | likesBy: {}
58 | };
59 |
60 | await setDoc(doc(contentsCollection, slug), newContent);
61 | });
62 |
63 | await Promise.all(contentPromises);
64 | }
65 |
66 | /**
67 | * Returns all the guestbook.
68 | */
69 | export async function getGuestbook(): Promise {
70 | const guestbookSnapshot = await getDocs(
71 | query(guestbookCollection, orderBy('createdAt', 'desc'))
72 | );
73 |
74 | const guestbook = guestbookSnapshot.docs.map((doc) => doc.data());
75 |
76 | const parsedGuestbook = guestbook.map(({ createdAt, ...data }) => ({
77 | ...data,
78 | createdAt: createdAt.toJSON()
79 | })) as Guestbook[];
80 |
81 | return parsedGuestbook;
82 | }
83 |
84 | export type BlogWithViews = Blog & Pick;
85 |
86 | /**
87 | * Returns all the blog posts with the views.
88 | */
89 | export async function getAllBlogWithViews(): Promise {
90 | const posts = await getAllContents('blog');
91 |
92 | const postsPromises = posts.map(async (post) => {
93 | const snapshot = await getDoc(doc(contentsCollection, post.slug));
94 | const { views } = snapshot.data() as ContentMeta;
95 |
96 | return { ...post, views };
97 | });
98 |
99 | const postsWithViews = await Promise.all(postsPromises);
100 |
101 | return postsWithViews;
102 | }
103 |
104 | /**
105 | * Send email to my email address.
106 | */
107 | export async function sendEmail(
108 | text: string,
109 | session: CustomSession
110 | ): Promise {
111 | const client = createTransport({
112 | service: 'Gmail',
113 | auth: {
114 | user: backendEnv.EMAIL_ADDRESS,
115 | pass: backendEnv.EMAIL_PASSWORD
116 | }
117 | });
118 |
119 | const { name, email } = session.user;
120 |
121 | const emailHeader = `New guestbook from ${name} (${email})`;
122 |
123 | await client.sendMail({
124 | from: backendEnv.EMAIL_ADDRESS,
125 | to: backendEnv.EMAIL_TARGET,
126 | subject: emailHeader,
127 | text: text
128 | });
129 | }
130 |
131 | /**
132 | * Returns the contents statistics with selected content type.
133 | */
134 | export async function getContentStatistics(
135 | type: ContentType
136 | ): Promise {
137 | const contentsSnapshot = await getDocs(
138 | query(contentsCollection, where('type', '==', type))
139 | );
140 |
141 | const contents = contentsSnapshot.docs.map((doc) => doc.data());
142 |
143 | const [totalPosts, totalViews, totalLikes] = contents.reduce(
144 | ([accPosts, accViews, accLikes], { views, likes }) => [
145 | accPosts + 1,
146 | accViews + views,
147 | accLikes + likes
148 | ],
149 | [0, 0, 0]
150 | );
151 |
152 | return { type, totalPosts, totalViews, totalLikes };
153 | }
154 |
155 | /**
156 | * Returns all the contents statistics.
157 | */
158 | export async function getAllContentsStatistics(): Promise {
159 | const statisticsPromises = VALID_CONTENT_TYPES.map((type) =>
160 | getContentStatistics(type)
161 | );
162 |
163 | const statistics = await Promise.all(statisticsPromises);
164 |
165 | return statistics;
166 | }
167 |
168 | /**
169 | * Returns the content data with selected content type.
170 | */
171 | export async function getContentData(type: ContentType): Promise {
172 | const contentsSnapshot = await getDocs(
173 | query(contentsCollection, where('type', '==', type))
174 | );
175 |
176 | const contents = contentsSnapshot.docs.map((doc) => doc.data());
177 |
178 | const filteredContents: ContentColumn[] = contents.map(
179 | ({ slug, views, likes }) => ({
180 | slug,
181 | views,
182 | likes
183 | })
184 | );
185 |
186 | const contentData: ContentData = {
187 | type,
188 | data: filteredContents
189 | };
190 |
191 | return contentData;
192 | }
193 |
194 | /**
195 | * Returns all the content data.
196 | */
197 | export async function getAllContentsData(): Promise {
198 | const contentDataPromises = VALID_CONTENT_TYPES.map((type) =>
199 | getContentData(type)
200 | );
201 |
202 | const contentData = await Promise.all(contentDataPromises);
203 |
204 | return contentData;
205 | }
206 |
--------------------------------------------------------------------------------
/src/components/common/currently-playing-card.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { useState, useEffect } from 'react';
3 | import { SiSpotify, SiJellyfin, SiApplemusic } from 'react-icons/si';
4 | import { HiPause, HiPlay } from 'react-icons/hi2';
5 | import { useMounted } from '@lib/hooks/use-mounted';
6 | import { formatMilisecondsToPlayback } from '@lib/format';
7 | import { useCurrentlyPlayingSSE } from '@lib/hooks/use-currently-playing-sse';
8 | import { LazyImage } from '@components/ui/lazy-image';
9 | import { UnstyledLink } from '@components/link/unstyled-link';
10 |
11 | export function SpotifyCard(): React.ReactNode {
12 | const [progressPercentage, setProgressPercentage] = useState(0);
13 | const [currentPlaybackTime, setCurrentPlaybackTime] = useState(0);
14 |
15 | const { currentlyPlaying } = useCurrentlyPlayingSSE();
16 |
17 | const mounted = useMounted();
18 |
19 | const { platform, isPlaying, item } = currentlyPlaying?.data ?? {};
20 |
21 | const { trackUrl, trackName, albumName, artistName, albumImageUrl } =
22 | item ?? {};
23 |
24 | useEffect(() => {
25 | // If there's no song, reset everything to zero and stop.
26 | if (!item) {
27 | setCurrentPlaybackTime(0);
28 | setProgressPercentage(0);
29 | return;
30 | }
31 |
32 | const { progressMs, durationMs } = item;
33 |
34 | let progressIntervalId: NodeJS.Timeout;
35 |
36 | // Correctly handles all cases after the initial render.
37 | if (isPlaying) {
38 | // STATE 1: Song is playing. Start the live-updating interval.
39 | const effectStartTime = Date.now();
40 |
41 | const updateProgress = (): void => {
42 | const timeElapsed = Date.now() - effectStartTime;
43 | const currentProgressMs = (progressMs + timeElapsed) % durationMs;
44 |
45 | setCurrentPlaybackTime(currentProgressMs);
46 | setProgressPercentage((currentProgressMs / durationMs) * 100);
47 | };
48 |
49 | updateProgress();
50 |
51 | progressIntervalId = setInterval(updateProgress, 1000);
52 | } else {
53 | // STATE 2: Song is paused. Set the progress once from the API data.
54 | setCurrentPlaybackTime(progressMs);
55 | setProgressPercentage((progressMs / durationMs) * 100);
56 | }
57 |
58 | return (): void => clearInterval(progressIntervalId);
59 | }, [isPlaying, item]); // Re-run when the song or its playing status changes.
60 |
61 | if (!mounted) {
62 | return null;
63 | }
64 |
65 | const platformIcon =
66 | platform === 'spotify' ? (
67 |
68 | ) : (
69 |
70 | );
71 |
72 | const totalDuration = item?.durationMs ?? 0;
73 |
74 | return (
75 |
81 |
85 | {item ? (
86 |
87 |
88 | {albumImageUrl && (
89 |
90 |
98 |
99 | )}
100 |
101 |
102 |
103 |
107 | {trackName}
108 |
109 | {platformIcon}
110 |
111 |
112 |
116 | by {artistName}
117 |
118 | {isPlaying ? (
119 |
120 | ) : (
121 |
122 | )}
123 |
124 |
128 | on {albumName}
129 |
130 |
131 |
132 |
133 |
134 |
140 |
141 | {formatMilisecondsToPlayback(currentPlaybackTime)}
142 | {formatMilisecondsToPlayback(totalDuration)}
143 |
144 |
145 |
146 | ) : (
147 |
148 |
No song is currently playing
149 |
150 |
151 | )}
152 |
153 |
154 | );
155 | }
156 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import { HiDocumentText } from 'react-icons/hi2';
3 | import { SiGithub, SiLinkedin } from 'react-icons/si';
4 | import { initializeAllContents } from '@lib/api';
5 | import { getAllContents } from '@lib/mdx';
6 | import { setTransition, fadeInWhenVisible } from '@lib/transition';
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 { UnstyledLink } from '@components/link/unstyled-link';
11 | import { SpotifyCard } from '@components/common/currently-playing-card';
12 | import { Accent } from '@components/ui/accent';
13 | import type { GetStaticPropsResult, InferGetStaticPropsType } from 'next';
14 | import type { IconType } from 'react-icons';
15 | import type { Blog, Project } from '@lib/types/contents';
16 |
17 | export default function Home({
18 | featuredBlog,
19 | featuredProjects
20 | }: InferGetStaticPropsType): React.JSX.Element {
21 | return (
22 |
23 |
27 |
28 |
32 | Hi!
33 |
34 |
38 | I'm Risal - Full Stack Developer
39 |
40 |
45 | I'm a self-taught Software Engineer turned Full Stack Developer.
46 | I enjoy working with TypeScript, React, and Node.js. I also love
47 | exploring new technologies and learning new things.
48 |
49 |
50 |
51 |
52 |
56 |
57 | Read my blog
58 |
59 |
60 | Learn more about me
61 |
62 |
63 |
67 | {socialLink.map(({ name, href, Icon }) => (
68 |
74 | {' '}
75 |
76 | {name}
77 |
78 |
79 | ))}
80 |
81 |
82 |
83 |
84 | Featured Posts
85 |
86 |
87 | Check out my featured blog posts.
88 |
89 |
90 | {featuredBlog.map((blog, index) => (
91 |
92 | ))}
93 |
94 |
98 | See more posts
99 |
100 |
101 |
102 |
103 | Featured Project
104 |
105 |
106 | Check out my featured blog posts.
107 |
108 |
109 | {featuredProjects.map((project, index) => (
110 |
111 | ))}
112 |
113 |
117 | See more projects
118 |
119 |
120 |
121 | );
122 | }
123 |
124 | type HomeProps = {
125 | featuredBlog: Blog[];
126 | featuredProjects: Project[];
127 | };
128 |
129 | export async function getStaticProps(): Promise<
130 | GetStaticPropsResult
131 | > {
132 | await initializeAllContents();
133 |
134 | const featuredBlog = await getAllContents('blog');
135 | const featuredProjects = await getAllContents('projects');
136 |
137 | return {
138 | props: {
139 | featuredBlog,
140 | featuredProjects
141 | }
142 | };
143 | }
144 |
145 | type SocialLink = {
146 | name: string;
147 | href: string;
148 | Icon: IconType;
149 | };
150 |
151 | const socialLink: SocialLink[] = [
152 | {
153 | name: 'Resume',
154 | href: 'https://docs.google.com/document/d/1emlC1CdiKDE0sVVqkpoZWj5FSLdXoFUe2kIXAFxF8Kg/edit?usp=sharing',
155 | Icon: HiDocumentText
156 | },
157 | {
158 | name: 'LinkedIn',
159 | href: 'https://linkedin.com/in/risalamin',
160 | Icon: SiLinkedin
161 | },
162 | {
163 | name: 'GitHub',
164 | href: 'https://github.com/ccrsxx',
165 | Icon: SiGithub
166 | }
167 | ];
168 |
--------------------------------------------------------------------------------
/src/pages/blog.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, type ChangeEvent } from 'react';
2 | import { AnimatePresence, motion, type MotionProps } from 'framer-motion';
3 | import { getAllBlogWithViews, type BlogWithViews } from '@lib/api';
4 | import { getTags, textIncludes } from '@lib/helper';
5 | import { useSessionStorage } from '@lib/hooks/use-session-storage';
6 | import { setTransition } from '@lib/transition';
7 | import { SEO } from '@components/common/seo';
8 | import { BlogTag } from '@components/blog/blog-tag';
9 | import {
10 | SortListbox,
11 | sortOptions,
12 | type SortOption
13 | } from '@components/blog/sort-listbox';
14 | import { BlogCard } from '@components/blog/blog-card';
15 | import { Accent } from '@components/ui/accent';
16 | import type { GetStaticPropsResult, InferGetStaticPropsType } from 'next/types';
17 | import type { Blog } from '@lib/types/contents';
18 |
19 | export default function Blog({
20 | tags,
21 | posts
22 | }: InferGetStaticPropsType): React.JSX.Element {
23 | const [sortOrder, setSortOrder] = useSessionStorage(
24 | 'sortOrder',
25 | sortOptions[0]
26 | );
27 |
28 | const [filteredPosts, setFilteredPosts] = useState([]);
29 | const [search, setSearch] = useState('');
30 |
31 | useEffect(() => {
32 | const splittedSearch = search.split(' ');
33 |
34 | const newFilteredPosts = posts.filter(({ title, description, tags }) => {
35 | const isTitleMatch = textIncludes(title, search);
36 | const isDescriptionMatch = textIncludes(description, search);
37 | const isTagsMatch = splittedSearch.every((tag) => tags.includes(tag));
38 |
39 | return isTitleMatch || isDescriptionMatch || isTagsMatch;
40 | });
41 |
42 | if (sortOrder === 'date') newFilteredPosts.sort();
43 | else newFilteredPosts.sort((a, b) => b.views - a.views);
44 |
45 | setFilteredPosts(newFilteredPosts);
46 | }, [posts, search, sortOrder]);
47 |
48 | const handleSearchChange = ({
49 | target: { value }
50 | }: ChangeEvent): void => setSearch(value);
51 |
52 | const handleTagClick = (tag: string) => (): void => {
53 | if (search.includes(tag)) {
54 | const poppedTagSearch = search
55 | .split(' ')
56 | .filter((t) => t !== tag)
57 | .join(' ');
58 |
59 | setSearch(poppedTagSearch);
60 | } else {
61 | const appendedTagSearch = search ? `${search.trim()} ${tag}` : tag;
62 |
63 | setSearch(appendedTagSearch);
64 | }
65 | };
66 |
67 | const filteredTags = getTags(filteredPosts);
68 |
69 | const isTagSelected = (tag: string): boolean => {
70 | const isInFilteredTags = filteredTags.includes(tag);
71 | const isInSearch = search.toLowerCase().split(' ').includes(tag);
72 |
73 | return isInFilteredTags && isInSearch;
74 | };
75 |
76 | return (
77 |
78 |
82 |
83 |
87 | Blog
88 |
89 |
93 | My thoughts on the web, tech, and everything in between.
94 |
95 |
96 |
128 |
132 |
133 | {filteredPosts.length ? (
134 |
135 | {filteredPosts.map((post) => (
136 |
142 |
143 |
144 | ))}
145 |
146 | ) : (
147 |
152 | Sorry, not found :(
153 |
154 | )}
155 |
156 |
157 | {/*
158 |
159 | */}
160 |
161 | );
162 | }
163 |
164 | type BlogProps = {
165 | posts: BlogWithViews[];
166 | tags: string[];
167 | };
168 |
169 | export async function getStaticProps(): Promise<
170 | GetStaticPropsResult
171 | > {
172 | const posts = await getAllBlogWithViews();
173 | const tags = getTags(posts);
174 |
175 | return {
176 | props: {
177 | posts,
178 | tags
179 | },
180 | revalidate: 60
181 | };
182 | }
183 |
184 | const variants: MotionProps = {
185 | initial: {
186 | scale: 0.9,
187 | opacity: 0
188 | },
189 | animate: {
190 | scale: 1,
191 | opacity: 1
192 | },
193 | exit: {
194 | scale: 0.9,
195 | opacity: 0
196 | }
197 | };
198 |
--------------------------------------------------------------------------------
/src/pages/blog/data-fetching-in-nextjs.mdx:
--------------------------------------------------------------------------------
1 | import Banner from '../../../public/assets/blog/data-fetching-in-nextjs/banner.jpg';
2 | import { ContentLayout } from '@components/layout/content-layout';
3 | import { getContentSlug } from '@lib/mdx';
4 |
5 | export const meta = {
6 | title: 'Data Fetching in Next.js',
7 | publishedAt: '2022-12-26',
8 | banner: Banner,
9 | bannerAlt: 'Photo by Irina Ermakova on Unsplash',
10 | bannerLink: 'https://unsplash.com/photos/rIsHXq2Bvho',
11 | description:
12 | 'Learn about the different methods for fetching data in Next.js, including static generation, server-side rendering, and client-side rendering.',
13 | tags: 'nextjs'
14 | };
15 |
16 | export const getStaticProps = getContentSlug('blog', 'data-fetching-in-nextjs');
17 |
18 | export default ({ children }) => (
19 | {children}
20 | );
21 |
22 | {/* content start */}
23 |
24 | Next.js is a popular framework for building server-rendered React applications. One key feature of Next.js is its built-in support for data fetching, which allows you to easily retrieve data from APIs or other sources and pass it to your components.
25 |
26 | ## Static Generation
27 |
28 | One way to fetch data in Next.js is through static generation. This method generates a static HTML file at build time, which means that the data is fetched before the application is deployed. This is useful for pages that don't change often, as the HTML is generated once and can be served quickly to users.
29 |
30 | To fetch data during the build process, you can use the getStaticProps function in your page component. This function takes in a context object and returns an object with props that will be passed to the component. For example:
31 |
32 | ```tsx
33 | import type { GetStaticPropsResult, InferGetStaticPropsType } from 'next';
34 |
35 | type Post = {
36 | id: number;
37 | title: string;
38 | body: string;
39 | };
40 |
41 | export async function getStaticProps(): Promise> {
42 | const res = await fetch('https://my-api.com/posts');
43 | const data = await res.json();
44 |
45 | return {
46 | props: {
47 | posts: data
48 | }
49 | };
50 | }
51 |
52 | export default function Posts({
53 | posts
54 | }): InferGetStaticPropsType {
55 | return (
56 |
57 | {props.posts.map(({ id, title, body }) => (
58 |
59 | {title}
60 | {body}
61 |
62 | ))}
63 |
64 | );
65 | }
66 | ```
67 |
68 | In this example, the getStaticProps function fetches a list of posts from an API and passes them to the Posts component as props. The HTML for this page will be generated at build time with the data included.
69 |
70 | ## Server-Side Rendering
71 |
72 | Another way to fetch data in Next.js is through server-side rendering (SSR). This method generates the HTML for a page on the server on each request, allowing you to fetch data dynamically. This is useful for pages that change frequently or depend on user input.
73 |
74 | To fetch data during the server-side rendering process, you can use the getServerSideProps function in your page component. This function works similarly to getStaticProps, but it runs on the server on each request rather than at build time. For example:
75 |
76 | ```tsx
77 | import type {
78 | GetServerSidePropsResult,
79 | InferGetServerSidePropsType
80 | } from 'next';
81 |
82 | type Post = {
83 | id: number;
84 | title: string;
85 | body: string;
86 | };
87 |
88 | export async function getServerSideProps(): Promise<
89 | getServerSideProps
90 | > {
91 | const res = await fetch('https://my-api.com/posts');
92 | const data = await res.json();
93 |
94 | return {
95 | props: {
96 | posts: data
97 | }
98 | };
99 | }
100 |
101 | export default function Posts({
102 | posts
103 | }): InferGetServerSidePropsType {
104 | return (
105 |
106 | {props.posts.map(({ id, title, body }) => (
107 |
108 | {title}
109 | {body}
110 |
111 | ))}
112 |
113 | );
114 | }
115 | ```
116 |
117 | In this example, the getServerSideProps function fetches a list of posts from an API and passes them to the Posts component as props. The HTML for this page will be generated on the server on each request with the data included.
118 |
119 | ## Client-Side Rendering
120 |
121 | In addition to static generation and server-side rendering, Next.js also supports client-side rendering. This method generates the HTML for a page on the client using JavaScript, allowing you to fetch data asynchronously and update the page in real-time.
122 |
123 | To fetch data on the client, you can use the useEffect hook in your component to make a request to an API. For example:
124 |
125 | ```tsx
126 | type Post = {
127 | id: number;
128 | title: string;
129 | body: string;
130 | };
131 |
132 | export default function Posts(): JSX.Element {
133 | const [posts, setPosts] = useState([]);
134 |
135 | useEffect(() => {
136 | const fetchData = async (): Promise => {
137 | const res = await fetch('https://my-api.com/posts');
138 | const data = await res.json();
139 |
140 | setPosts(data);
141 | };
142 |
143 | void fetchData();
144 | }, []);
145 |
146 | return (
147 |
148 | {posts.map(({ id, title, body }) => (
149 |
150 | {title}
151 | {body}
152 |
153 | ))}
154 |
155 | );
156 | }
157 | ```
158 |
159 | In this example, the useEffect hook fetches a list of posts from an API and updates the posts state variable with the data. The component will then re-render with the updated data.
160 |
161 | ## Choosing a Data Fetching Method
162 |
163 | When deciding which method to use for data fetching in your Next.js application, consider the following factors:
164 |
165 | - **Static vs. dynamic data**: If your data is static and doesn't change often, static generation may be a good choice. If your data is dynamic and changes frequently, server-side rendering or client-side rendering may be more suitable.
166 | - **Performance**: Static generation is the most performant option, as the HTML is generated at build time and can be served quickly to users. Server-side rendering is also fast, but it requires generating the HTML on the server on each request. Client-side rendering is the least performant option, as it requires fetching and rendering the data on the client.
167 | - **Developer experience**: Static generation and server-side rendering require rebuilding the application to update the data, which can be time-consuming. Client-side rendering allows you to update the data in real-time, but it can be more complex to implement.
168 |
169 | In summary, Next.js provides several options for data fetching, each with its own benefits and trade-offs. Choose the method that best fits your application's needs and use case.
170 |
--------------------------------------------------------------------------------