├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── actions ├── github.ts └── search.ts ├── app ├── (marketing) │ ├── about │ │ └── page.tsx │ ├── contact │ │ └── page.tsx │ ├── projects │ │ └── page.tsx │ └── shoutouts │ │ └── page.tsx ├── api │ ├── contact │ │ └── route.ts │ └── search │ │ └── route.ts ├── blog │ ├── page.tsx │ └── post │ │ └── [slug] │ │ └── page.tsx ├── docs │ ├── page.tsx │ └── posts │ │ └── [[...slug]] │ │ └── page.tsx ├── error.tsx ├── layout.config.tsx ├── layout.tsx ├── not-found.tsx ├── page.tsx ├── robots.ts ├── rss.xml │ └── route.ts └── sitemap.ts ├── components.json ├── components ├── blog │ ├── detail-blog-post │ │ ├── heading │ │ │ ├── back-button.tsx │ │ │ ├── blog-post-heading.tsx │ │ │ └── info-bar │ │ │ │ ├── info-bar-desktop.tsx │ │ │ │ └── info-bar-mobile.tsx │ │ ├── main.tsx │ │ └── sidebar │ │ │ ├── active-section-observer.tsx │ │ │ ├── banner.tsx │ │ │ ├── table-of-contents.tsx │ │ │ ├── toc-items-empty.tsx │ │ │ └── toc-thumb.tsx │ └── single-blog-post │ │ ├── loading.tsx │ │ └── main.tsx ├── contact │ ├── contact-form.tsx │ └── social-account-button.tsx ├── footer │ ├── copyright │ │ ├── copyright-text.tsx │ │ ├── desktop-footer-links.tsx │ │ ├── main.tsx │ │ ├── mobile-footer-links.tsx │ │ └── tech-stacks.tsx │ ├── main.tsx │ ├── navigation-links.tsx │ └── social-media-accounts.tsx ├── fuma │ ├── fuma-layout.tsx │ ├── fuma-page-client.tsx │ ├── fuma-page.tsx │ └── fuma-toc.tsx ├── header │ ├── desktop │ │ ├── desktop-header.tsx │ │ ├── logo.tsx │ │ └── navigations │ │ │ ├── about │ │ │ ├── card-item.tsx │ │ │ ├── navigation-about.tsx │ │ │ └── social-link.tsx │ │ │ ├── animated-arrow.tsx │ │ │ ├── blog │ │ │ ├── category-link.tsx │ │ │ ├── more-category-link.tsx │ │ │ └── navigation-blog.tsx │ │ │ └── projects │ │ │ ├── more-project-item.tsx │ │ │ ├── navigation-projects.tsx │ │ │ └── project-item.tsx │ ├── main.tsx │ ├── mobile │ │ ├── menu-button.tsx │ │ ├── mobile-header.tsx │ │ ├── more-menu-button.tsx │ │ └── sub-navigation-item.tsx │ ├── progress-bar.tsx │ └── shared │ │ ├── search.tsx │ │ └── theme-switcher.tsx ├── heading │ └── main.tsx ├── home │ ├── intro.tsx │ └── profile.tsx ├── mdx │ └── mdx-components.tsx ├── project │ ├── browser.tsx │ ├── category.tsx │ ├── link-buttons.tsx │ └── main.tsx ├── providers │ ├── tanstack-query-provider.tsx │ └── theme-provider.tsx ├── shoutouts │ └── tweet-error-fallback.tsx └── ui │ ├── _dialog.tsx │ ├── accordion.tsx │ ├── animations │ ├── fade-up.tsx │ ├── github-stars-button.tsx │ ├── infinite-slider.tsx │ ├── motion-effect.tsx │ ├── progressive-blur.tsx │ ├── sliding-number.tsx │ └── word-reveal.tsx │ ├── avatar.tsx │ ├── big-button.tsx │ ├── button.tsx │ ├── card.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── link-button.tsx │ ├── main-title.tsx │ ├── navigation-menu.tsx │ ├── scroll-area.tsx │ ├── scroll-to-top-button.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── sonner.tsx │ ├── tabs.tsx │ ├── tailwind-indicator.tsx │ ├── textarea.tsx │ └── tooltip.tsx ├── config ├── category.ts ├── navigation-links.ts ├── projects.ts ├── seo │ ├── author.ts │ ├── favicons.ts │ ├── head.ts │ ├── index.ts │ ├── keywords.ts │ └── open-graph.ts └── social.ts ├── content-collections.ts ├── content ├── docs │ ├── how-to-implement-next-mdx-remote-with-nextjs.mdx │ ├── index.mdx │ └── update-tailwindcss-v4.mdx ├── posts │ ├── how-to-implement-next-mdx-remote-with-nextjs.mdx │ └── update-tailwindcss-v4.mdx └── projects │ ├── blog-app.mdx │ ├── energy-project.mdx │ ├── first-porfolio.mdx │ ├── mongol-food-app.mdx │ ├── portfolio-app-1.mdx │ └── portfolio-app-2.mdx ├── hooks ├── useDebounce.ts ├── useTableOfCotents.ts └── userReadingProgress.ts ├── icons ├── android-icon.tsx ├── calendar-icon.tsx ├── clock-icon.tsx ├── folder-icon.tsx └── html-icon.tsx ├── lib ├── search.tsx ├── seo.ts ├── source.ts └── utils.ts ├── mdx-components.tsx ├── next.config.mjs ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── favicons │ ├── android-icon-192x192.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── apple-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ └── favicon.ico ├── files │ └── resume.pdf ├── images │ ├── about │ │ ├── about_me_01.jpg │ │ ├── about_me_02.jpg │ │ └── about_me_03.jpg │ ├── avatar.jpg │ ├── cover-photo.jpg │ ├── cover.jpg │ ├── logo.png │ ├── navigation │ │ ├── all-projects.jpg │ │ └── featured-project-01.jpg │ ├── opengraph-image.png │ ├── placeholder.jpg │ ├── posts │ │ ├── how-to-implement-next-mdx-remote-with-nextjs │ │ │ └── cover.jpg │ │ └── update-tailwindcss-v4 │ │ │ └── cover.jpg │ ├── profile.jpg │ ├── projects │ │ ├── blog-app.jpg │ │ ├── energy-project.jpg │ │ ├── first-portfolio.jpg │ │ ├── mobile │ │ │ ├── blog-app.jpg │ │ │ ├── energy-project.jpg │ │ │ ├── first-portfolio.jpg │ │ │ ├── mongol-food-app.jpg │ │ │ ├── portfolio-app-1.jpg │ │ │ └── portfolio-app-2.jpg │ │ ├── mongol-food-app.jpg │ │ ├── portfolio-app-1.jpg │ │ └── portfolio-app-2.jpg │ └── twitter-image.png └── rss-feed.xml ├── shiki-rehype.mjs ├── styles └── tailwind.css ├── tsconfig.json ├── types └── index.d.ts └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # content-collections 4 | .content-collections 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | .yarn/install-state.gz 11 | 12 | # testing 13 | /coverage 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | 19 | # production 20 | /build 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@ianvs/prettier-plugin-sort-imports", 4 | "prettier-plugin-tailwindcss" 5 | ], 6 | "pluginSearchDirs": false 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Modern Portfolio Website 2 | 3 | ![Portfolio-App-Product-Hunt](https://github.com/user-attachments/assets/e344041d-564d-4439-af63-c926a97b14d8) 4 | ![Banner-02](https://github.com/user-attachments/assets/ebb36170-fdf3-4b47-bbc2-cdaa8f282b5a) 5 | ![Banner-03](https://github.com/user-attachments/assets/a1c6a2e5-2c6c-4b86-8ef7-8f8e3d84b2df) 6 | ![Banner-04](https://github.com/user-attachments/assets/3b5f415a-3351-49cb-97e7-094b19b774d5) 7 | 8 | A modern, blazing-fast portfolio website built with cutting-edge web technologies. Features include SEO optimization, full responsiveness, and a powerful MDX blog system. 9 | 10 | ## ✨ Features 11 | 12 | - ⚡️ **Performance**: Built with Next.js 15 for optimal speed and SEO 13 | - 🎨 **Design**: Modern UI with Tailwind CSS v4 and ShadCN UI components 14 | - 📝 **Blog**: MDX-powered blog system using Fumadocs & Content Collection 15 | - 🔄 **State Management**: Efficient state handling with Zustand and TanStack Query v5 16 | - 📧 **Contact**: Integrated email functionality via Resend 17 | 18 | ## 🚀 Live Demo 19 | 20 | Visit [hire-tim.com](https://hire-tim.com) to see the portfolio in action! 21 | 22 | ## 🛠️ Tech Stack 23 | 24 | - **Framework**: Next.js 15 25 | - **Language**: TypeScript 5 26 | - **Styling**: Tailwind CSS v4, ShadCN UI 27 | - **State Management**: Zustand, TanStack Query v5 28 | - **Content**: MDX, Fumadocs, Content Collection 29 | - **Email**: Resend 30 | 31 | ## 📋 Prerequisites 32 | 33 | - Node.js 18.0 or later 34 | - npm, yarn, or pnpm package manager 35 | 36 | ## 🚀 Getting Started 37 | 38 | 1. **Clone the repository** 39 | 40 | ```bash 41 | git clone https://github.com/yourusername/portfolio-template.git 42 | cd portfolio-template 43 | ``` 44 | 45 | 2. **Install dependencies** 46 | 47 | ```bash 48 | npm install 49 | # or 50 | yarn install 51 | # or 52 | pnpm install 53 | ``` 54 | 55 | 3. **Start the development server** 56 | ```bash 57 | npm run dev 58 | # or 59 | yarn dev 60 | # or 61 | pnpm dev 62 | ``` 63 | 64 | Visit `http://localhost:3000` to see your portfolio in action! 65 | 66 | ## ⚙️ Configuration 67 | 68 | The project uses several configuration files: 69 | 70 | - `next.config.mjs` - Next.js configuration 71 | - `tailwind.config.ts` - Tailwind CSS configuration 72 | - `tsconfig.json` - TypeScript configuration 73 | - `contentlayer.config.ts` - Content management configuration 74 | 75 | ## 🔐 Environment Variables 76 | 77 | Create a `.env.local` file in the root directory with the following variables: 78 | 79 | ```env 80 | NEXT_PUBLIC_APP_URL=http://localhost:3000 81 | NEXT_PUBLIC_WEB_URL=your_production_domain_name 82 | GITHUB_TOKEN=your_github_token 83 | NEXT_PUBLIC_SUPABASE_URL=your_supabase_url 84 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_key 85 | RESEND_API_KEY=your_resend_api_key 86 | ``` 87 | 88 | ## 📄 License 89 | 90 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 91 | 92 | ## 📞 Contact 93 | 94 | For questions, feedback, or collaboration: 95 | 96 | - 📧 Email: timtb.dev@gmail.com 97 | - 🐦 X (Twitter): [@timtbdev](https://x.com/timtbdev) 98 | 99 | --- 100 | 101 | Made with ❤️ by [Tim](https://hire-tim.com) 102 | -------------------------------------------------------------------------------- /actions/github.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cache } from "react"; 4 | 5 | interface GitHubRepoResponse { 6 | stargazers_count: number; 7 | } 8 | 9 | // One day in seconds: 24 hours * 60 minutes * 60 seconds 10 | const TWO_HOURS = 2 * 60 * 60; 11 | 12 | const fetchGitHubStars = cache(async (repo: string): Promise => { 13 | const url = `https://api.github.com/repos/timtbdev/${repo}`; 14 | 15 | const headers = { 16 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 17 | Accept: "application/vnd.github.v3+json", 18 | }; 19 | 20 | const response = await fetch(url, { 21 | headers, 22 | next: { revalidate: TWO_HOURS }, 23 | }); 24 | 25 | if (!response.ok) { 26 | const errorText = await response.text(); 27 | console.error("GitHub API Error:", errorText); 28 | throw new Error("GitHub API request failed"); 29 | } 30 | 31 | const data: GitHubRepoResponse = await response.json(); 32 | return data.stargazers_count; 33 | }); 34 | 35 | export async function getGitHubStars(repo: string) { 36 | if (!repo) { 37 | throw new Error("Repository is required"); 38 | } 39 | 40 | try { 41 | const stars = await fetchGitHubStars(repo); 42 | return { stars }; 43 | } catch (error) { 44 | const errorMessage = 45 | error instanceof Error ? error.message : "Unknown error"; 46 | throw new Error(errorMessage); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /actions/search.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getPostsBySearchQuery } from "@/lib/search"; 4 | import { PostType } from "@/types"; 5 | 6 | export async function searchPosts(query: string): Promise { 7 | if (!query || typeof query !== "string") { 8 | throw new Error("Query parameter is required"); 9 | } 10 | 11 | const searchResults = await getPostsBySearchQuery(query); 12 | return searchResults; 13 | } 14 | -------------------------------------------------------------------------------- /app/(marketing)/contact/page.tsx: -------------------------------------------------------------------------------- 1 | import { ContactForm } from "@/components/contact/contact-form"; 2 | import Footer from "@/components/footer/main"; 3 | import Header from "@/components/header/main"; 4 | import Heading from "@/components/heading/main"; 5 | import { FadeUp } from "@/components/ui/animations/fade-up"; 6 | import { MotionEffect } from "@/components/ui/animations/motion-effect"; 7 | import Card from "@/components/ui/card"; 8 | import MainTitle from "@/components/ui/main-title"; 9 | import { HEAD } from "@/config/seo"; 10 | import { getBaseUrl } from "@/lib/utils"; 11 | import { HeadType } from "@/types"; 12 | import { Metadata } from "next"; 13 | import { Fragment } from "react"; 14 | 15 | // Validate SEO configuration to ensure all required fields are present 16 | // This helps catch missing or incomplete SEO setup early 17 | if (!HEAD || HEAD.length === 0) { 18 | console.error("⚠️ HEAD configuration is missing or empty"); 19 | } 20 | 21 | // Define the current page for SEO configuration 22 | const PAGE = "Contact"; 23 | 24 | // Get SEO configuration for the current page from the HEAD array 25 | const page = HEAD.find((page: HeadType) => page.page === PAGE) as HeadType; 26 | 27 | // Configure comprehensive metadata for SEO and social sharing 28 | // This includes all necessary meta tags for search engines and social media platforms 29 | export const metadata: Metadata = { 30 | // Basic metadata 31 | title: page.title, 32 | applicationName: page.title, 33 | description: page.description, 34 | 35 | // URL configurations for canonical links and RSS feed 36 | metadataBase: new URL(getBaseUrl(page.slug)), 37 | alternates: { 38 | canonical: getBaseUrl(page.slug), 39 | }, 40 | }; 41 | 42 | export default async function ContactPage() { 43 | const title = "Contact"; 44 | const description = "Please feel free to reach out to me."; 45 | const imageUrl = "/images/logo.png"; 46 | const imageAlt = "Avatar"; 47 | const initials = "TB"; 48 | return ( 49 |
50 |
51 | 52 | 61 | 66 | 67 | 68 |
69 |
70 | 71 | 72 |
73 | 83 | 84 | 85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /app/(marketing)/projects/page.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/components/footer/main"; 2 | import Header from "@/components/header/main"; 3 | import Heading from "@/components/heading/main"; 4 | import ProjectItem from "@/components/project/main"; 5 | import { MotionEffect } from "@/components/ui/animations/motion-effect"; 6 | import MainTitle from "@/components/ui/main-title"; 7 | import ScrollToTopButton from "@/components/ui/scroll-to-top-button"; 8 | import { HEAD } from "@/config/seo"; 9 | import { getBaseUrl } from "@/lib/utils"; 10 | import { HeadType, ProjectType } from "@/types"; 11 | import { allProjects } from "content-collections"; 12 | import { Metadata } from "next"; 13 | import { Fragment } from "react"; 14 | 15 | // Validate SEO configuration to ensure all required fields are present 16 | // This helps catch missing or incomplete SEO setup early 17 | if (!HEAD || HEAD.length === 0) { 18 | console.error("⚠️ HEAD configuration is missing or empty"); 19 | } 20 | 21 | // Define the current page for SEO configuration 22 | const PAGE = "Projects"; 23 | 24 | // Get SEO configuration for the current page from the HEAD array 25 | const page = HEAD.find((page: HeadType) => page.page === PAGE) as HeadType; 26 | 27 | // Configure comprehensive metadata for SEO and social sharing 28 | // This includes all necessary meta tags for search engines and social media platforms 29 | export const metadata: Metadata = { 30 | // Basic metadata 31 | title: page.title, 32 | applicationName: page.title, 33 | description: page.description, 34 | 35 | // URL configurations for canonical links and RSS feed 36 | metadataBase: new URL(getBaseUrl(page.slug)), 37 | alternates: { 38 | canonical: getBaseUrl(page.slug), 39 | }, 40 | }; 41 | 42 | export default async function ProjectPage() { 43 | const projects: ProjectType[] = allProjects.sort((a, b) => a.order - b.order); 44 | 45 | return ( 46 | 47 |
48 | 49 | 58 | 63 | 64 | 65 |
66 |
67 | {projects.map((project, index) => ( 68 | 69 | ))} 70 |
71 |
72 |