├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── .nvmrc ├── .prettierignore ├── README.md ├── components.json ├── content-collections.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.cjs ├── public ├── dog.glb └── images │ ├── placeholder.svg │ └── screenshot │ └── landing-page-screenshot.png ├── src ├── app │ ├── (home) │ │ ├── about │ │ │ └── page.tsx │ │ ├── blog │ │ │ ├── [slug] │ │ │ │ ├── loading.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── projects │ │ │ └── page.tsx │ ├── api │ │ └── og │ │ │ └── route.tsx │ ├── icon.tsx │ ├── layout.tsx │ ├── not-found.tsx │ ├── opengraph-image.png │ ├── robots.ts │ └── sitemap.ts ├── components │ ├── analytics.tsx │ ├── cards │ │ ├── post-card.tsx │ │ └── project-card.tsx │ ├── category-buttons.tsx │ ├── experience.tsx │ ├── framer │ │ └── index.tsx │ ├── hero.tsx │ ├── icons.tsx │ ├── layout │ │ ├── main-nav.tsx │ │ ├── mobile-nav.tsx │ │ ├── site-footer.tsx │ │ ├── site-header.tsx │ │ └── theme-toggle.tsx │ ├── mdx │ │ ├── mdx-component.tsx │ │ └── mdx-pager.ts │ ├── my-resumen.tsx │ ├── page-header.tsx │ ├── projects.tsx │ ├── scroll.tsx │ ├── theme-provider.tsx │ ├── toc.tsx │ └── ui │ │ ├── aspect-ratio.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── pagination-buttons.tsx │ │ ├── sheet.tsx │ │ └── skeleton.tsx ├── config │ ├── experience.ts │ ├── fonts.ts │ └── site.ts ├── constants │ └── index.ts ├── content │ └── posts │ │ └── toolfolio-a-collection-of-tools-for-designers.mdx ├── fonts │ └── satoshi.ttf ├── helpers │ └── formaters.ts ├── hooks │ └── use-media-query.tsx ├── lib │ ├── querys.ts │ ├── sanity.ts │ └── utils.ts ├── scenes │ ├── Model.tsx │ └── home-scene.tsx ├── styles │ ├── globals.css │ └── mdx.css └── types │ ├── sanity.ts │ └── site.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_APP_URL=https:// 2 | 3 | SANITY_PROJECT_ID='2131zv3qw' 4 | SANITY_DATASET='production' 5 | SANITY_API_VERSION='2022-03-07' 6 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | project: ['./tsconfig.json'], 6 | ecmaFeatures: { 7 | jsx: true, 8 | }, 9 | tsconfigRootDir: __dirname, 10 | ecmaVersion: 2022, 11 | sourceType: 'module', 12 | }, 13 | plugins: ['@typescript-eslint', 'tailwindcss'], 14 | extends: [ 15 | 'next/core-web-vitals', 16 | 'plugin:deprecation/recommended', 17 | 'plugin:jsx-a11y/recommended', 18 | 'plugin:@typescript-eslint/recommended-type-checked', 19 | 'prettier', 20 | 'plugin:tailwindcss/recommended', 21 | ], 22 | rules: { 23 | '@typescript-eslint/consistent-type-imports': [ 24 | 'warn', 25 | { 26 | prefer: 'type-imports', 27 | fixStyle: 'inline-type-imports', 28 | }, 29 | ], 30 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 31 | 'jsx-a11y/heading-has-content': 'off', 32 | }, 33 | settings: { 34 | tailwindcss: { 35 | callees: ['cn', 'cva'], 36 | config: './tailwind.config.ts', 37 | classRegex: '^(class(Name)?|tw)$', 38 | }, 39 | next: { 40 | rootDir: ['./'], 41 | }, 42 | }, 43 | } 44 | 45 | module.exports = config 46 | -------------------------------------------------------------------------------- /.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 | #unlighthouse 9 | /.unlighthouse 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | 40 | 41 | # content collections 42 | .content-collections -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.11.1 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_module 3 | .next 4 | .content-collections 5 | build 6 | components.json 7 | postcss.config.js 8 | pnpm-lock.yaml 9 | tsconfig.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jorge Assaf Portfolio 2 | 3 | [![Jorge Assaf](./public/images/screenshot/landing-page-screenshot.png)](https://jorgeassaf.vercel.app/) 4 | 5 | ## Tech Stack 6 | 7 | - **Framework:** [Next.js](https://nextjs.org) 8 | - **Styling:** [Tailwind CSS](https://tailwindcss.com) 9 | - **Animation:** [Framer Motion](https://www.framer.com/motion/) 10 | - **3D:** [Three.js](https://threejs.org) and [React Three Fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) 11 | - **UI Components:** [shadcn/ui](https://ui.shadcn.com) 12 | - **CMS:** [Sanity](https://sanity.io) 13 | 14 | ## Features implemented 15 | 16 | - [x] Next.js app directory 17 | - [x] Write in TypeScript 18 | - [x] Implement 3D with Three.js and React Three Fiber 19 | - [x] Use 75ch width in blog and about page 20 | - [x] Query params for blog categories 21 | - [x] Use Sanity for blog and projects 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js and the technologies used in this project, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | - [TailwindCSS](https://tailwindcss.com/docs) - learn about TailwindCSS features and API. 30 | - [FramerMotion](https://www.framer.com/motion/) - learn about 31 | FramerMotion features and API. 32 | - [Sanity](https://www.sanity.io/docs) - learn about Sanity features and API. 33 | - [Shadcn](https://ui.shadcn.com/) - learn about Shadcn features and API. 34 | 35 | ## Deploy on Vercel 36 | 37 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 38 | 39 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 40 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/styles/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /content-collections.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection, defineConfig } from '@content-collections/core' 2 | import { compileMDX } from '@content-collections/mdx' 3 | import rehypeShiki from '@shikijs/rehype' 4 | import rehypeAutolinkHeadings from 'rehype-autolink-headings' 5 | import rehypeSlug from 'rehype-slug' 6 | import remarkToc from 'remark-toc' 7 | 8 | import { siteConfig } from '@/config/site' 9 | 10 | const posts = defineCollection({ 11 | name: 'posts', 12 | directory: 'src/content/posts', 13 | include: '*.mdx', 14 | 15 | schema: (z) => ({ 16 | title: z 17 | .string({ 18 | description: 'The title of the post', 19 | }) 20 | .max(100, { 21 | message: 'Title is too long', 22 | }) 23 | .min(10, { 24 | message: 'Title is too short', 25 | }), 26 | summary: z 27 | .string({ 28 | description: 'A brief summary of the post', 29 | }) 30 | .min(20, { 31 | message: 'Summary is too short', 32 | }) 33 | .max(250, { 34 | message: 'Summary is too long', 35 | }), 36 | originalUrl: z.string().max(125).optional(), 37 | mainImage: z.string().optional().default('/images/placeholder.svg'), 38 | date: z.string(), 39 | author: z.object({ 40 | name: z.string().optional().default('Anonymous'), 41 | username: z.string().optional(), 42 | role: z.string(), 43 | links: z.array( 44 | z.object({ 45 | name: z.string(), 46 | url: z.string(), 47 | }), 48 | ), 49 | image: z.string().url(), 50 | }), 51 | categories: z.array( 52 | z 53 | .string({ 54 | description: 'The categories this post belongs to', 55 | }) 56 | .refine( 57 | (val) => siteConfig.blogCategories.map((c) => c.title).includes(val), 58 | { 59 | message: 'Invalid category', 60 | path: ['categories'], 61 | }, 62 | ), 63 | ), 64 | }), 65 | transform: async (document, context) => { 66 | const mdx = await compileMDX(context, document, { 67 | remarkPlugins: [remarkToc], 68 | rehypePlugins: [ 69 | [ 70 | rehypeShiki, 71 | { 72 | themes: { 73 | dark: 'catppuccin-frappe', 74 | light: 'catppuccin-latte', 75 | }, 76 | 77 | grid: false, 78 | defaultTheme: 'dark', 79 | }, 80 | ], 81 | rehypeSlug, 82 | [ 83 | rehypeAutolinkHeadings, 84 | { 85 | behavior: 'append', 86 | properties: { 87 | ariaHidden: true, 88 | tabIndex: -1, 89 | }, 90 | }, 91 | ], 92 | ], 93 | }) 94 | 95 | const toc = () => { 96 | const regulrExp = new RegExp(/(?#+)\s(?.*)/, 'g') 97 | const headings = Array.from(document.content.matchAll(regulrExp)).map( 98 | ({ groups }) => { 99 | const flag = groups?.flag 100 | const content = groups?.content 101 | 102 | return { 103 | level: 104 | flag?.length == 1 105 | ? 'one' 106 | : flag?.length == 2 107 | ? 'two' 108 | : flag?.length == 3 109 | ? 'three' 110 | : 'four', 111 | text: content, 112 | slug: content ? content : undefined, 113 | } 114 | }, 115 | ) 116 | 117 | return headings 118 | } 119 | 120 | return { 121 | ...document, 122 | mdx, 123 | _id: document._meta.filePath, 124 | slug: document._meta.filePath.replace(/\.mdx$/, ''), 125 | slugAsParams: document._meta.filePath 126 | .replace(/\.mdx$/, '') 127 | .replace(/\//g, '-'), 128 | toc: toc(), 129 | } 130 | }, 131 | }) 132 | 133 | export default defineConfig({ 134 | collections: [posts], 135 | }) 136 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next' 2 | import { withContentCollections } from '@content-collections/next' 3 | 4 | const nextConfig = { 5 | images: { 6 | formats: ['image/webp'], 7 | remotePatterns: [ 8 | { 9 | protocol: 'https', 10 | hostname: 'cdn.sanity.io', 11 | }, 12 | { 13 | protocol: 'https', 14 | hostname: 'plus.unsplash.com', 15 | }, 16 | { 17 | protocol: 'https', 18 | hostname: 'pub-f17d057acab94eff8169231fde6b2dd0.r2.dev', 19 | }, 20 | { 21 | protocol: 'https', 22 | hostname: 'avatars.githubusercontent.com', 23 | }, 24 | ], 25 | }, 26 | transpilePackages: ['three', '@react-three/drei', '@react-three/fiber'], 27 | } satisfies NextConfig 28 | 29 | export default withContentCollections(nextConfig) 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "portfolio", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "ts:check": "tsc --noEmit", 11 | "format": "prettier --write .", 12 | "clean": "rm -rf .next && rm -rf node_modules .content-collections .unlighthouse", 13 | "unlighthouse": "pnpx unlighthouse --site https://jorgeassaf.vercel.app --desktop --no-cache" 14 | }, 15 | "dependencies": { 16 | "@radix-ui/react-aspect-ratio": "1.1.1", 17 | "@radix-ui/react-dialog": "1.1.5", 18 | "@radix-ui/react-slot": "1.1.1", 19 | "@react-three/drei": "9.121.3", 20 | "@react-three/fiber": "9.0.0-rc.5", 21 | "@vercel/analytics": "1.4.1", 22 | "@vercel/speed-insights": "1.1.0", 23 | "class-variance-authority": "0.7.1", 24 | "clsx": "2.1.1", 25 | "date-fns": "4.1.0", 26 | "framer-motion": "12.0.3", 27 | "lucide-react": "0.474.0", 28 | "next-sanity": "9.8.42", 29 | "next-themes": "0.4.4", 30 | "next": "15.1.6", 31 | "react-dom": "19.0.0", 32 | "react": "19.0.0", 33 | "rehype-autolink-headings": "7.1.0", 34 | "rehype-slug": "6.0.0", 35 | "remark-toc": "9.0.0", 36 | "tailwind-merge": "2.6.0", 37 | "three": "0.172.0" 38 | }, 39 | "devDependencies": { 40 | "@content-collections/core": "0.8.0", 41 | "@content-collections/mdx": "0.2.0", 42 | "@content-collections/next": "0.2.4", 43 | "@ianvs/prettier-plugin-sort-imports": "4.4.1", 44 | "@shikijs/rehype": "2.1.0", 45 | "@types/eslint": "9.6.1", 46 | "@types/node": "22.10.9", 47 | "@types/react": "19.1.0", 48 | "@types/react-dom": "19.1.2", 49 | "@types/three": "0.172.0", 50 | "@typescript-eslint/eslint-plugin": "8.21.0", 51 | "@typescript-eslint/parser": "8.21.0", 52 | "autoprefixer": "10.4.20", 53 | "eslint": "8.57.1", 54 | "eslint-config-next": "15.1.6", 55 | "eslint-config-prettier": "10.0.1", 56 | "eslint-plugin-deprecation": "3.0.0", 57 | "eslint-plugin-jsx-a11y": "6.10.2", 58 | "eslint-plugin-tailwindcss": "3.18.0", 59 | "postcss": "8.5.1", 60 | "prettier": "3.4.2", 61 | "prettier-plugin-tailwindcss": "0.6.10", 62 | "standard": "17.1.2", 63 | "tailwindcss": "3.4.17", 64 | "tailwindcss-animate": "1.0.7", 65 | "typescript": "5.7.3" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | module.exports = { 3 | endOfLine: 'crlf', 4 | semi: false, 5 | singleQuote: true, 6 | tabWidth: 2, 7 | trailingComma: 'all', 8 | jsxSingleQuote: true, 9 | importOrder: [ 10 | '^(react/(.*)$)|^(react$)', 11 | '^(next/(.*)$)|^(next$)', 12 | '', 13 | '', 14 | '^types$', 15 | '^@/types/(.*)$', 16 | '^@/config/(.*)$', 17 | '^@/lib/(.*)$', 18 | '^@/hooks/(.*)$', 19 | '^@/components/ui/(.*)$', 20 | '^@/components/(.*)$', 21 | '^@/styles/(.*)$', 22 | '^@/app/(.*)$', 23 | '', 24 | '^[./]', 25 | ], 26 | importOrderSeparation: false, 27 | importOrderSortSpecifiers: true, 28 | importOrderBuiltinModulesToTop: true, 29 | importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'], 30 | importOrderMergeDuplicateImports: true, 31 | importOrderCombineTypeAndValueImports: true, 32 | tailwindAttributes: ['tw'], 33 | tailwindFunctions: ['cva'], 34 | plugins: [ 35 | '@ianvs/prettier-plugin-sort-imports', 36 | 'prettier-plugin-tailwindcss', 37 | ], 38 | } 39 | -------------------------------------------------------------------------------- /public/dog.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JorgeAssaf/Jorgeassaf-portfolio/64cad70951cd57a410eac8b57ed207d7073d79c9/public/dog.glb -------------------------------------------------------------------------------- /public/images/placeholder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 9 | 10 | 12 | 13 | 14 | 15 | 17 | 18 | 21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /public/images/screenshot/landing-page-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JorgeAssaf/Jorgeassaf-portfolio/64cad70951cd57a410eac8b57ed207d7073d79c9/public/images/screenshot/landing-page-screenshot.png -------------------------------------------------------------------------------- /src/app/(home)/about/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { FADE_DOWN_ANIMATION_VARIANTS } from '@/constants' 3 | 4 | import { JOB_EXPERIENCE } from '@/config/experience' 5 | import { Experience } from '@/components/experience' 6 | import { FramerDiv, FramerSection } from '@/components/framer' 7 | import { Next, Prisma, React, Tailwind, Typescript } from '@/components/icons' 8 | import { MyResumen } from '@/components/my-resumen' 9 | import { PageHeader } from '@/components/page-header' 10 | 11 | export const metadata: Metadata = { 12 | title: 'About me', 13 | description: 14 | 'I am a front-end web developer with experience in JavaScript, React, Next.js and Astro. My goal is to become a FullStack programmer. I enjoy creating beautiful and easy to use web applications that connect with users. I am always looking for new opportunities to grow and collaborate on exciting projects.', 15 | } 16 | 17 | export default function AboutPage() { 18 | return ( 19 | <> 20 | 21 | 35 | 36 |

37 | I'm a front-end web developer with experience in JavaScript, 38 | React, Next.js and Astro. My goal is to become a FullStack 39 | programmer. I enjoy creating beautiful and easy to use web 40 | applications that connect with users. I am always looking for new 41 | opportunities to grow and collaborate on exciting projects. 42 |

43 |
44 | 45 | 49 | 50 | 51 | 52 | 56 |

Experience

57 |
58 | 59 |
60 |
61 | 65 |

My stack

66 |
67 |
68 | 69 | React 70 |
71 |
72 | 73 | Next 74 |
75 |
76 | 77 | Tailwind 78 |
79 |
80 | 81 | Typescript 82 |
83 |
84 | 85 | Prisma 86 |
87 |
88 |
89 |
90 | 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/app/(home)/blog/[slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from '@/components/ui/skeleton' 2 | 3 | export default function PostLoading() { 4 | return ( 5 |
6 |
7 |
8 | 9 | 10 |
11 |
12 | 13 |
14 | 15 | {/*

16 | {post.title} 17 |

*/} 18 |
19 |
20 | 21 | 22 |
23 | 24 | 25 |
26 |
27 |
28 | 29 | 30 | 31 |
32 | 33 |
34 |
35 |
36 |
37 | 38 | 39 |
40 |
41 | 42 |
43 | 44 | 45 | 46 |
47 | 48 |
49 | 50 | 51 |
52 |
53 |
54 |

Table of Contents

55 |
    56 | 57 |
58 |
59 |
60 |
61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /src/app/(home)/blog/[slug]/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFoundPage() { 2 | return ( 3 |
4 |
5 |

6 | 40 7 | 4 8 |

9 |

Post not found

10 |
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/app/(home)/blog/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import Link from 'next/link' 3 | import { notFound } from 'next/navigation' 4 | import { Formaters } from '@/helpers/formaters' 5 | import { allPosts } from 'content-collections' 6 | import { 7 | ChevronLeft, 8 | ChevronLeftIcon, 9 | ChevronRightIcon, 10 | Link2Icon, 11 | TagIcon, 12 | } from 'lucide-react' 13 | 14 | import { cn, slugify } from '@/lib/utils' 15 | import { Badge } from '@/components/ui/badge' 16 | import { buttonVariants } from '@/components/ui/button' 17 | import { MdxComponent } from '@/components/mdx/mdx-component' 18 | import { getPager } from '@/components/mdx/mdx-pager' 19 | 20 | import '@/styles/mdx.css' 21 | 22 | import Image from 'next/image' 23 | 24 | import icons from '@/components/icons' 25 | import { Toc } from '@/components/toc' 26 | 27 | interface PostPageProps { 28 | params: Promise<{ slug: string }> 29 | } 30 | 31 | export type TocItem = { 32 | level: string 33 | text: string 34 | slug: string 35 | } 36 | 37 | // eslint-disable-next-line @typescript-eslint/require-await 38 | export async function generateStaticParams() { 39 | return allPosts.map((post) => ({ 40 | slug: post.slug, 41 | })) 42 | } 43 | 44 | async function getPostFromParams(params: PostPageProps['params']) { 45 | const slug = (await params).slug 46 | 47 | const post = allPosts.find((post) => post.slugAsParams === slugify(slug)) 48 | if (!post) { 49 | return notFound() 50 | } 51 | 52 | return post 53 | } 54 | 55 | export async function generateMetadata({ 56 | params, 57 | }: PostPageProps): Promise { 58 | const post = await getPostFromParams(params) 59 | if (!post) { 60 | return { 61 | metadataBase: new URL( 62 | process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000', 63 | ), 64 | title: 'Post not found', 65 | } 66 | } 67 | const url = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000' 68 | 69 | // const ogUrl = new URL(`${url}/api/og`) 70 | // ogUrl.searchParams.set('title', post.title) 71 | // ogUrl.searchParams.set('type', 'Blog Post') 72 | // ogUrl.searchParams.set('mode', 'dark') 73 | 74 | return { 75 | metadataBase: new URL(url), 76 | title: post.title, 77 | authors: { 78 | name: post.author.name, 79 | }, 80 | openGraph: { 81 | title: post.title, 82 | description: post.summary, 83 | type: 'article', 84 | url: url + post.slug, 85 | images: [ 86 | { 87 | url: post.mainImage, 88 | width: 1200, 89 | height: 630, 90 | alt: post.title, 91 | }, 92 | ], 93 | }, 94 | twitter: { 95 | card: 'summary_large_image', 96 | title: post.title, 97 | images: [post.mainImage], 98 | }, 99 | } 100 | } 101 | 102 | export default async function PostPage({ params }: PostPageProps) { 103 | const post = await getPostFromParams(params) 104 | if (!post) notFound() 105 | const pager = getPager(post, allPosts) 106 | return ( 107 |
108 |
109 |
110 | 141 | 142 |
143 |
144 |
145 | {`${post.title} 153 |
154 |
155 |

156 | {post.title} 157 |

158 |
159 |
160 | 166 |
167 |
168 |
169 | {post.author.image && ( 170 | // eslint-disable-next-line @next/next/no-img-element 171 | {post.author.name} 179 | )} 180 |
181 |

{post.author.name}

182 | {post.author.username && ( 183 | 184 | @{post.author.username} 185 | 186 | )} 187 |
188 |
189 |
190 | {post.author.links.map((link) => { 191 | const LucideIcon = icons[link.name as keyof typeof icons] 192 | return ( 193 | 204 |
230 |
231 |
232 | {post.categories.map((category) => ( 233 | 234 | 237 | ))} 238 |
239 |
240 | 241 |
242 | 243 |
244 | 245 | 276 |
277 | 280 |
281 |
282 | ) 283 | } 284 | -------------------------------------------------------------------------------- /src/app/(home)/blog/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from '@/components/ui/skeleton' 2 | 3 | export default function BlogLoading() { 4 | return ( 5 |
6 |
7 | 8 | 9 |
10 | 11 |
12 |
13 |
14 | {Array.from({ length: 6 }).map((_, i) => ( 15 | 16 | ))} 17 |
18 |
19 | {Array.from({ length: 2 }).map((_, i) => ( 20 |
24 | 25 | 26 |
27 |
28 | 29 | 30 |
31 |
32 | 33 |
34 | 35 | 36 |
37 |
38 | {Array(3) 39 | .fill(0) 40 | .map((_, i) => ( 41 | 42 | ))} 43 |
44 |
45 | 46 | 47 |
48 |
49 |
50 |
51 | ))} 52 |
53 |
{' '} 54 |
55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/app/(home)/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { FADE_DOWN_ANIMATION_VARIANTS } from '@/constants' 3 | import { allPosts } from 'content-collections' 4 | import { FileWarningIcon } from 'lucide-react' 5 | 6 | import { siteConfig } from '@/config/site' 7 | import { slugify } from '@/lib/utils' 8 | import { PaginationButtons } from '@/components/ui/pagination-buttons' 9 | import { PostCard } from '@/components/cards/post-card' 10 | import CategoryButtons from '@/components/category-buttons' 11 | import { FramerDiv } from '@/components/framer' 12 | import { PageHeader } from '@/components/page-header' 13 | 14 | export const metadata: Metadata = { 15 | title: 'Blog', 16 | description: 17 | 'Here my last resources about web development, mobile development, ui/ux design, devops, blogs etc', 18 | keywords: [ 19 | 'web development', 20 | 'mobile development', 21 | 'ui/ux design', 22 | 'devops', 23 | 'blogs', 24 | 'resources', 25 | ], 26 | } 27 | 28 | export default async function BlogPage({ 29 | searchParams, 30 | }: { 31 | searchParams: Promise<{ [key: string]: string | string[] | undefined }> 32 | }) { 33 | const { category, page: activePage } = await searchParams 34 | const page = Number.parseInt(activePage as string) || 1 35 | const postsPerPage = 6 36 | const startIndex = (page - 1) * postsPerPage 37 | const endIndex = startIndex + postsPerPage 38 | 39 | return ( 40 | <> 41 | 46 | 47 | 54 |
55 | 66 |
67 | {category ? ( 68 | allPosts.filter((post) => 69 | post.categories 70 | .map((category) => slugify(category)) 71 | .includes( 72 | Array.isArray(category) ? category.join('') : category, 73 | ), 74 | ).length > 0 ? ( 75 | allPosts 76 | .slice(startIndex, endIndex) 77 | .map((post, i) => ( 78 | 79 | )) 80 | ) : ( 81 | 82 | ) 83 | ) : allPosts.length === 0 ? ( 84 | 85 | ) : ( 86 | allPosts 87 | .slice(startIndex, endIndex) 88 | .map((post, i) => ) 89 | )} 90 |
91 |
92 |
93 | 98 | 99 | ) 100 | } 101 | 102 | const NotFoundPosts = () => ( 103 |
104 | 105 | 106 |

107 | No posts found 😢 108 |

109 |

110 | Try changing the filters or reloading the page 111 |

112 |
113 | ) 114 | 115 | //
116 | // 123 | // 137 | // 141 | // {siteConfig.blogCategories.find( 142 | // (category) => slugify(category.title) === searchParams.category, 143 | // )?.title ?? 'All posts'} 144 | // 145 | 146 | // {allPosts.length > 0 ? ( 147 | // 151 | // {allPosts.map((post) => ( 152 | // 153 | // ))} 154 | // {allPosts.map((post) => ( 155 | // 156 | // ))} 157 | // {allPosts.map((post) => ( 158 | // 159 | // ))} 160 | // 161 | // ) : ( 162 | //
163 | // 164 | 165 | //

166 | // No posts found 167 | //

168 | //

169 | // Try changing the filters or reloading the page 170 | //

171 | //
172 | // )} 173 | //
174 | //
175 | 176 | // export default PaginationButtons 177 | // import type { Metadata } from 'next' 178 | // import Link from 'next/link' 179 | // import { notFound } from 'next/navigation' 180 | // import { Formaters } from '@/helpers/formaters' 181 | // import { allPosts } from "content-collections"; 182 | // import { ChevronLeft, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react' 183 | 184 | // import { cn, slugify } from '@/lib/utils' 185 | // import { Badge } from '@/components/ui/badge' 186 | // import { buttonVariants } from '@/components/ui/button' 187 | // import { MdxComponent } from '@/components/mdx/mdx-component' 188 | // import { getPager } from '@/components/mdx/mdx-pager' 189 | 190 | // import '@/styles/mdx.css' 191 | 192 | // import Image from 'next/image' 193 | 194 | // import icons, { LinkedIn, Placeholder } from '@/components/icons' 195 | 196 | // interface PostPageProps { 197 | // params: { 198 | // slug: string[] 199 | // } 200 | // } 201 | 202 | // // eslint-disable-next-line @typescript-eslint/require-await 203 | // export async function generateStaticParams() { 204 | // return allPosts.map((post) => ({ 205 | // slug: post.slug.split('/'), 206 | // })) 207 | // } 208 | 209 | // function getPostFromParams(params: PostPageProps['params']) { 210 | // const slug = params.slug.join('/') 211 | 212 | // const post = allPosts.find((post) => post.slugAsParams === slug) 213 | // if (!post) { 214 | // return notFound() 215 | // } 216 | 217 | // return post 218 | // } 219 | 220 | // export function generateMetadata({ params }: PostPageProps): Metadata { 221 | // const post = getPostFromParams(params) 222 | // if (!post) { 223 | // return { 224 | // metadataBase: new URL( 225 | // process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000', 226 | // ), 227 | // title: 'Post not found', 228 | // } 229 | // } 230 | // const url = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000' 231 | 232 | // const ogUrl = new URL(`${url}/api/og`) 233 | // ogUrl.searchParams.set('title', post.title) 234 | // ogUrl.searchParams.set('type', 'Blog Post') 235 | // ogUrl.searchParams.set('mode', 'dark') 236 | 237 | // return { 238 | // metadataBase: new URL(url), 239 | // title: post.title, 240 | // authors: { 241 | // name: post.author.name, 242 | // }, 243 | // openGraph: { 244 | // title: post.title, 245 | // type: 'article', 246 | // url: url + post.slug, 247 | // images: [ 248 | // { 249 | // url: ogUrl.toString(), 250 | // width: 1200, 251 | // height: 630, 252 | // alt: post.title, 253 | // }, 254 | // ], 255 | // }, 256 | // twitter: { 257 | // card: 'summary_large_image', 258 | // title: post.title, 259 | 260 | // images: [ogUrl.toString()], 261 | // }, 262 | // } 263 | // } 264 | 265 | // export default function PostPage({ params }: PostPageProps) { 266 | // const post = getPostFromParams(params) 267 | // if (!post) notFound() 268 | // const pager = getPager(post, allPosts) 269 | // return ( 270 | //
271 | //
272 | // Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorem totam 273 | // odit modi rerum, dolor ullam assumenda veniam quaerat aspernatur 274 | // Praesentium quidem autem harum nemo eligendi mollitia accusantium facere 275 | // eius sunt accusamus! 276 | //
277 | //
278 | // 279 | // 286 | // 287 | // Blog 288 | // 289 | // {post.categories.map((category, i) => ( 290 | // 291 | // {category} 292 | // {i < post.categories.length - 1 && ' / '} 293 | // 294 | // ))} 295 | // 296 | // 297 | 298 | //
299 | // 300 | // Published on {Formaters.formatDate(post.date)} 301 | // 302 | //

303 | // {post.title} 304 | //

305 | //
306 | //
307 | // {post.author.image && ( 308 | // // eslint-disable-next-line @next/next/no-img-element 309 | // {post.author.name} 317 | // )} 318 | //
319 | //

{post.author.name}

320 | // 321 | // @{post.author.name.split(' ').join('').toLowerCase()} 322 | // 323 | //
324 | //
325 | //
326 | // {post.author.links.map((link) => { 327 | // const LucideIcon = icons[link.name as keyof typeof icons] 328 | // return ( 329 | // 338 | //
347 | //
348 | //
349 | // {post.mainImage ? ( 350 | // {`${post.title} 356 | // ) : ( 357 | // Placeholder 363 | // )} 364 | //
365 | //
366 | 367 | //
368 | // 369 | //
370 | 371 | //
372 | // {pager?.previousPost ? ( 373 | // 378 | //
393 | //
394 | //
395 | // ) 396 | // } 397 | -------------------------------------------------------------------------------- /src/app/(home)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { FramerWrapper } from '@/components/framer' 2 | import SiteFooter from '@/components/layout/site-footer' 3 | import SiteHeader from '@/components/layout/site-header' 4 | 5 | export default function HomeLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode 9 | }) { 10 | return ( 11 | <> 12 | 13 |
14 | {children} 15 |
16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(home)/page.tsx: -------------------------------------------------------------------------------- 1 | import { allPosts } from 'content-collections' 2 | 3 | import { getLatestProjectsQuery } from '@/lib/querys' 4 | import { client } from '@/lib/sanity' 5 | import { PostCard } from '@/components/cards/post-card' 6 | import Hero from '@/components/hero' 7 | import { PageHeader } from '@/components/page-header' 8 | import Projects from '@/components/projects' 9 | import Scroll from '@/components/scroll' 10 | 11 | import type { ProjectsEntity } from '../../types/sanity' 12 | 13 | export default async function Home() { 14 | const projects = await client.fetch(getLatestProjectsQuery) 15 | 16 | return ( 17 |
18 | 19 | 20 | 24 | 25 | 26 | 27 | 31 | {allPosts.slice(0, 3).map((post, i) => ( 32 | 33 | ))} 34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/app/(home)/projects/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { unstable_noStore } from 'next/cache' 3 | import { FADE_DOWN_ANIMATION_VARIANTS } from '@/constants' 4 | import { Formaters } from '@/helpers/formaters' 5 | 6 | import type { ProjectsEntity } from '@/types/sanity' 7 | import { GETPROJECTSQUERY } from '@/lib/querys' 8 | import { client } from '@/lib/sanity' 9 | import CategoryButtons from '@/components/category-buttons' 10 | import { FramerDiv } from '@/components/framer' 11 | import { PageHeader } from '@/components/page-header' 12 | import Projects from '@/components/projects' 13 | 14 | export const dynamic = 'force-dynamic' 15 | 16 | export const metadata: Metadata = { 17 | title: 'Projects', 18 | description: 19 | 'Here you can see my projects and what technologies they are made with and the links to the repositories and the live version', 20 | keywords: [ 21 | 'projects', 22 | 'portfolio', 23 | 'frontend', 24 | 'backend', 25 | 'fullstack', 26 | 'mobile', 27 | ], 28 | robots: 'index, follow', 29 | } 30 | 31 | const categories = [ 32 | { title: 'Frontend' }, 33 | { title: 'Backend' }, 34 | { title: 'Mobile' }, 35 | { title: 'Fullstack' }, 36 | ] 37 | 38 | export default async function ProjectsPage({ 39 | searchParams, 40 | }: { 41 | searchParams: Promise<{ [key: string]: string | string[] | undefined }> 42 | }) { 43 | const { category } = await searchParams 44 | unstable_noStore() 45 | const projects = await client.fetch(GETPROJECTSQUERY, { 46 | category: 47 | category !== undefined ? Formaters.capitalizeFirstLetter(category) : '', 48 | }) 49 | return ( 50 |
51 | 56 |
57 | 61 | 68 | 69 | 70 |
71 |
72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /src/app/api/og/route.tsx: -------------------------------------------------------------------------------- 1 | import type { ServerRuntime } from 'next' 2 | import { ImageResponse } from 'next/og' 3 | 4 | import { cn } from '@/lib/utils' 5 | 6 | export const runtime: ServerRuntime = 'edge' 7 | 8 | export function GET(req: Request) { 9 | try { 10 | const url = new URL(req.url) 11 | const parsedValues = Object.fromEntries(url.searchParams) 12 | 13 | const { mode, title, description, type } = parsedValues 14 | return new ImageResponse( 15 | ( 16 |
24 |
30 | {type ? ( 31 |
32 | {type} 33 |
34 | ) : null} 35 |

41 | {title} 42 |

43 | {description ? ( 44 |

50 | {description} 51 |

52 | ) : null} 53 |
54 |
55 | ), 56 | { 57 | width: 1200, 58 | height: 630, 59 | }, 60 | ) 61 | } catch (error) { 62 | if (error instanceof Error) { 63 | console.error(error) 64 | } 65 | 66 | return new Response('Failed to generate image', { status: 500 }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/app/icon.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from 'next/og' 2 | 3 | // Route segment config 4 | export const runtime = 'edge' 5 | 6 | // Image metadata 7 | export const size = { 8 | width: 32, 9 | height: 32, 10 | } 11 | export const contentType = 'image/png' 12 | 13 | // Image generation 14 | export default function Icon() { 15 | return new ImageResponse( 16 | ( 17 | // ImageResponse JSX element 18 |
19 | A 20 |
21 | ), 22 | // ImageResponse options 23 | { 24 | // For convenience, we can re-use the exported icons size metadata 25 | // config to also set the ImageResponse's width and height. 26 | ...size, 27 | }, 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css' 2 | 3 | import type { Metadata, Viewport } from 'next' 4 | import { SpeedInsights } from '@vercel/speed-insights/next' 5 | 6 | import { fontmono, fontsans } from '@/config/fonts' 7 | import { siteConfig } from '@/config/site' 8 | import { cn } from '@/lib/utils' 9 | import { Analytics } from '@/components/analytics' 10 | import { ThemeProvider } from '@/components/theme-provider' 11 | 12 | export const metadata: Metadata = { 13 | metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL!), 14 | title: { 15 | default: siteConfig.name, 16 | template: `${siteConfig.name} - %s`, 17 | }, 18 | description: siteConfig.description, 19 | keywords: [ 20 | 'Next.js', 21 | 'React', 22 | 'Tailwind CSS', 23 | 'TypeScript', 24 | 'Shadcn', 25 | 'Jorge Assaf', 26 | 'Portfolio', 27 | 'Blog', 28 | 'Sanity', 29 | 'Software Development', 30 | 'Web Development', 31 | 'Frontend Development', 32 | 'Fullstack Development', 33 | ], 34 | authors: [ 35 | { 36 | name: 'Jorge Assaf', 37 | url: 'https://github.com/JorgeAssaf', 38 | }, 39 | ], 40 | creator: 'Jorge Assaf', 41 | openGraph: { 42 | type: 'website', 43 | locale: 'en_US', 44 | url: siteConfig.url, 45 | title: siteConfig.name, 46 | description: siteConfig.description, 47 | siteName: siteConfig.name, 48 | }, 49 | twitter: { 50 | card: 'summary_large_image', 51 | title: siteConfig.name, 52 | description: siteConfig.description, 53 | images: [`${siteConfig.url}/opengraph-image.png`], 54 | creator: '@AssafEnrique', 55 | }, 56 | } 57 | 58 | export const viewport: Viewport = { 59 | themeColor: [ 60 | { media: '(prefers-color-scheme: light)', color: 'white' }, 61 | { media: '(prefers-color-scheme: dark)', color: 'black' }, 62 | ], 63 | } 64 | 65 | export default function RootLayout({ children }: React.PropsWithChildren) { 66 | return ( 67 | 68 | 75 | 76 |
77 | {children} 78 | 79 | 80 | 81 | 82 | 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import SiteFooter from '@/components/layout/site-footer' 2 | import SiteHeader from '@/components/layout/site-header' 3 | 4 | export default function NotFoundPage() { 5 | return ( 6 | <> 7 | 8 |
9 |
10 |

11 | 40 12 | 4 13 |

14 |

Page not found

15 |
16 |
17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JorgeAssaf/Jorgeassaf-portfolio/64cad70951cd57a410eac8b57ed207d7073d79c9/src/app/opengraph-image.png -------------------------------------------------------------------------------- /src/app/robots.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next' 2 | 3 | import { siteConfig } from '@/config/site' 4 | 5 | export default function robots(): MetadataRoute.Robots { 6 | return { 7 | rules: { 8 | userAgent: '*', 9 | allow: '/', 10 | }, 11 | sitemap: `${siteConfig.url}/sitemap.xml`, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next' 2 | import { allPosts } from 'content-collections' 3 | 4 | import { siteConfig } from '@/config/site' 5 | 6 | export default function sitemap(): MetadataRoute.Sitemap { 7 | const posts = allPosts.map((post) => ({ 8 | url: `${siteConfig.url}/blog/${post.slug}`, 9 | lastModified: post.date, 10 | changeFrequency: 'monthly', 11 | })) satisfies MetadataRoute.Sitemap 12 | const routes = ['', '/about', '/blog', '/projects'].map((route) => ({ 13 | url: `${siteConfig.url}${route}`, 14 | lastModified: new Date().toISOString(), 15 | changeFrequency: 'monthly', 16 | })) satisfies MetadataRoute.Sitemap 17 | return [...routes, ...posts] 18 | } 19 | -------------------------------------------------------------------------------- /src/components/analytics.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Analytics as VercelAnalytics } from '@vercel/analytics/react' 4 | 5 | export function Analytics() { 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /src/components/cards/post-card.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Image from 'next/image' 4 | import Link from 'next/link' 5 | import { Formaters } from '@/helpers/formaters' 6 | import type { Post } from 'content-collections' 7 | 8 | import { Badge } from '../ui/badge' 9 | 10 | export const PostCard = ({ post, i }: { post: Post; i: number }) => { 11 | return ( 12 |
13 | 19 |
20 | {post.title} 28 |
29 | 30 | 31 |
32 | 35 | {post.categories.map((category) => ( 36 | 37 | {category} 38 | 39 | ))} 40 |
41 | 42 |
43 |

44 | 50 | {post.title} 51 | 52 |

53 |

{post.summary}

54 |
55 | 56 |
57 | {`${post.author.name} 65 | 66 |
67 |

68 | 69 | {post.author.name} 70 | 71 |

72 |

{post.author.role}

73 |
74 |
75 |
76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /src/components/cards/project-card.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import Link from 'next/link' 3 | import { Formaters } from '@/helpers/formaters' 4 | import { CalendarCheck, CodeIcon } from 'lucide-react' 5 | 6 | import type { ProjectsEntity } from '@/types/sanity' 7 | import { cn } from '@/lib/utils' 8 | import { buttonVariants } from '@/components/ui/button' 9 | import { Card, CardContent } from '@/components/ui/card' 10 | 11 | import { Badge } from '../ui/badge' 12 | 13 | interface ProjectsCardProps { 14 | project: ProjectsEntity 15 | order?: number 16 | } 17 | 18 | export const ProjectCard = ({ project }: ProjectsCardProps) => { 19 | return ( 20 | 21 |
22 | {`${project.image.alt 30 |
31 | 32 |
33 |
34 | 38 | 39 | {project.category} 40 | 41 |
42 | {project.createdAt && ( 43 | <> 44 | 45 |

46 | {Formaters.formatDate(project.createdAt, { 47 | pattern: 'MMMM dd, yyyy', 48 | })} 49 |

50 | 51 | )} 52 |
53 |
54 |

{project.name}

55 |

56 | {project.description} 57 |

58 |
59 | {project.technologies.map((tech) => ( 60 | 68 | {/* eslint-disable-next-line @next/next/no-img-element */} 69 | {tech.name} 77 | {tech.name} 78 | 79 | ))} 80 |
81 |
82 |
83 | {project.repo && ( 84 | 96 | View Repository 97 | 98 | )} 99 | {project.link && ( 100 | 112 | Live Site 113 | 114 | )} 115 |
116 |
117 |
118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /src/components/category-buttons.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useCallback, useTransition } from 'react' 4 | import { usePathname, useRouter, useSearchParams } from 'next/navigation' 5 | import { FADE_LEFT_ANIMATION_VARIANTS } from '@/constants' 6 | import { m } from 'framer-motion' 7 | import { Home, type LucideIcon } from 'lucide-react' 8 | 9 | import { cn, slugify } from '@/lib/utils' 10 | 11 | import icons from './icons' 12 | import { Button, type ButtonProps } from './ui/button' 13 | 14 | const CategoryButtons = ({ 15 | activeCategory, 16 | buttonClassName, 17 | buttonSize = 'sm', 18 | buttonVariant = 'outline', 19 | categories, 20 | className, 21 | iconStyle, 22 | withAll = false, 23 | withIcons = false, 24 | }: { 25 | activeCategory?: string 26 | buttonClassName?: string 27 | buttonSize?: ButtonProps['size'] 28 | buttonVariant?: ButtonProps['variant'] 29 | categories: { title: string; icon?: keyof typeof icons }[] 30 | className?: string 31 | iconStyle?: string 32 | withAll?: boolean 33 | withIcons?: boolean 34 | }) => { 35 | const router = useRouter() 36 | const pathname = usePathname() 37 | const searchParams = useSearchParams() 38 | 39 | const [isPending, startTransition] = useTransition() 40 | 41 | const categoryParam = searchParams.get('category') 42 | 43 | const createQueryString = useCallback( 44 | (params: Record) => { 45 | const newSearchParams = new URLSearchParams(searchParams?.toString()) 46 | 47 | for (const [key, value] of Object.entries(params)) { 48 | if (value === null) { 49 | newSearchParams.delete(key) 50 | } else { 51 | newSearchParams.set(key, String(value)) 52 | } 53 | } 54 | 55 | return newSearchParams.toString() 56 | }, 57 | [searchParams], 58 | ) 59 | 60 | return ( 61 | 75 | 79 | {withAll && ( 80 | 103 | )} 104 | 105 | {categories.map((category) => { 106 | const Icon = icons[category.icon as keyof typeof icons] as LucideIcon 107 | return ( 108 | 132 | ) 133 | })} 134 | 135 | 136 | ) 137 | } 138 | 139 | export default CategoryButtons 140 | -------------------------------------------------------------------------------- /src/components/experience.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { Formaters } from '@/helpers/formaters' 3 | 4 | import type { JobExperience } from '@/config/experience' 5 | import { cn } from '@/lib/utils' 6 | 7 | interface ExperienceProps extends React.ComponentPropsWithoutRef<'ol'> { 8 | experience: JobExperience[] 9 | } 10 | 11 | export const Experience = ({ 12 | experience, 13 | className, 14 | ...props 15 | }: ExperienceProps) => { 16 | return ( 17 |
    18 | {experience.map((job) => ( 19 |
  1. 20 |
    21 | 29 | 30 |

    31 | {job.ocupation} at{' '} 32 | 40 | {job.company} 41 | 42 |

    43 |

    44 | {job.location} 45 |

    46 |

    47 | {job.description} 48 |

    49 |
  2. 50 | ))} 51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/components/framer/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { domAnimation, LazyMotion, m } from 'framer-motion' 4 | 5 | export const FramerDiv = m.div 6 | export const FramerSection = m.section 7 | export const FramerH2 = m.h2 8 | 9 | export const FramerWrapper = ({ children }: React.PropsWithChildren) => { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /src/components/hero.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import dynamic from 'next/dynamic' 4 | import Link from 'next/link' 5 | import { FADE_DOWN_ANIMATION_VARIANTS } from '@/constants' 6 | import { m } from 'framer-motion' 7 | 8 | import { cn } from '@/lib/utils' 9 | import { GitHub, LinkedIn } from '@/components/icons' 10 | 11 | import { buttonVariants } from './ui/button' 12 | 13 | const HomeScene = dynamic(() => import('@/scenes/home-scene'), { 14 | ssr: false, 15 | }) 16 | 17 | const Hero = () => { 18 | return ( 19 | 33 |
34 |
35 | 36 | 37 | Hi, I’m Jorge Assaf. 38 | 39 | 40 | Front-end Developer. 41 | 42 | 43 | 47 | I am a passionate software engineer with a strong background in 48 | full-stack web development. I love building innovative and 49 | user-friendly applications. 50 | 51 | 52 | 56 | 64 | 89 |
90 | 94 | 95 | 96 |
97 |
98 | ) 99 | } 100 | 101 | export default Hero 102 | -------------------------------------------------------------------------------- /src/components/icons.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable deprecation/deprecation */ 2 | import { 3 | Book, 4 | Braces, 5 | FlaskConical, 6 | PanelsTopLeft, 7 | PocketKnife, 8 | X, 9 | type LucideIcon, 10 | type LucideProps, 11 | } from 'lucide-react' 12 | 13 | type Icon = LucideIcon 14 | 15 | const GitHub = ({ ...props }: LucideProps) => ( 16 | 24 | github 25 | 26 | 27 | ) 28 | const LinkedIn = ({ ...props }: LucideProps) => ( 29 | 36 | linkedin 37 | 41 | 42 | ) 43 | const Prisma = ({ ...props }: LucideProps) => ( 44 | 51 | prisma 52 | 56 | 57 | ) 58 | const Next = ({ ...props }: LucideProps) => ( 59 | 60 | nextjs 61 | 70 | 71 | 72 | 73 | 81 | 85 | 92 | 93 | 94 | 102 | 103 | 104 | 105 | 113 | 114 | 115 | 116 | 117 | 118 | ) 119 | const React = ({ ...props }: LucideProps) => ( 120 | 127 | react 128 | 132 | 133 | ) 134 | const Tailwind = ({ ...props }: LucideProps) => ( 135 | 142 | tailwindcss 143 | 144 | 151 | 152 | 153 | 154 | 155 | 159 | 160 | ) 161 | const Typescript = ({ ...props }: LucideProps) => ( 162 | 169 | typescript 170 | 174 | 178 | 179 | ) 180 | const Fiverr = ({ ...props }: LucideProps) => ( 181 | 182 | fiverr 183 | 184 | 185 | 186 | {' '} 187 | 188 | 189 | ) 190 | const Twitter = ({ ...props }: LucideProps) => ( 191 | 199 | twitter 200 | 204 | 205 | ) 206 | 207 | const Placeholder = ({ ...props }: LucideProps) => ( 208 | 209 | placeholder 210 | 211 | 212 | 213 | 217 | 222 | 223 | 229 | 230 | 231 | 236 | 240 | 241 | 247 | 248 | 253 | 254 | 255 | 263 | 264 | 265 | 266 | 267 | 268 | 276 | 277 | 278 | 279 | 280 | 281 | 289 | 290 | 291 | 292 | 293 | 294 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | ) 313 | 314 | export { 315 | Fiverr, 316 | GitHub, 317 | LinkedIn, 318 | Next, 319 | Prisma, 320 | React, 321 | Tailwind, 322 | Twitter, 323 | Typescript, 324 | X, 325 | Placeholder, 326 | type Icon, 327 | } 328 | 329 | const icons = { 330 | braces: Braces, 331 | // code: Code, 332 | flaskconical: FlaskConical, 333 | pocketknife: PocketKnife, 334 | book: Book, 335 | panelstopleft: PanelsTopLeft, 336 | Twitter, 337 | GitHub, 338 | } 339 | export default icons 340 | 341 | // function CalendarDaysIcon(props: React.SVGProps) { 342 | // return ( 343 | // 355 | // 356 | // 357 | // 358 | // 359 | // 360 | // 361 | // 362 | // 363 | // 364 | // 365 | // 366 | // ) 367 | // } 368 | 369 | // function CodeIcon(props: React.SVGProps) { 370 | // return ( 371 | // 383 | // 384 | // 385 | // 386 | // ) 387 | // } 388 | 389 | // function ComponentIcon(props: React.SVGProps) { 390 | // return ( 391 | // 403 | // 404 | // 405 | // 406 | // 407 | // 408 | // ) 409 | // } 410 | 411 | // function NavigationIcon(props: React.SVGProps) { 412 | // return ( 413 | // 425 | // 426 | // 427 | // ) 428 | // } 429 | 430 | // function TriangleIcon(props: React.SVGProps) { 431 | // return ( 432 | // 444 | // 445 | // 446 | // ) 447 | // } 448 | 449 | // function WindIcon(props: React.SVGProps) { 450 | // return ( 451 | // 463 | // 464 | // 465 | // 466 | // 467 | // ) 468 | // } 469 | -------------------------------------------------------------------------------- /src/components/layout/main-nav.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { FC } from 'react' 4 | import Link from 'next/link' 5 | import { usePathname } from 'next/navigation' 6 | 7 | import type { MainNavItem } from '@/types/site' 8 | import { cn } from '@/lib/utils' 9 | 10 | import { ThemeToggle } from './theme-toggle' 11 | 12 | interface MainNavProps { 13 | items?: MainNavItem[] 14 | } 15 | const MainNav: FC = ({ items }) => { 16 | const pathname = usePathname() 17 | return ( 18 | <> 19 |
20 | 26 |

27 | JA. 28 |

29 | 30 | 31 |
32 | {items?.map((item) => ( 33 | 42 | {item.title} 43 | 44 | ))} 45 | 46 |
47 |
48 | 49 | ) 50 | } 51 | 52 | export default MainNav 53 | interface MenuLinkProps extends React.ComponentPropsWithRef { 54 | disabled?: boolean 55 | pathname: string 56 | } 57 | 58 | function MenuLink({ 59 | children, 60 | className, 61 | disabled, 62 | href, 63 | pathname, 64 | ...props 65 | }: MenuLinkProps) { 66 | return ( 67 | 77 | {children} 78 | 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /src/components/layout/mobile-nav.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, type FC } from 'react' 4 | import Link from 'next/link' 5 | import { usePathname } from 'next/navigation' 6 | import { Menu } from 'lucide-react' 7 | 8 | import type { MainNavItem } from '@/types/site' 9 | import { cn } from '@/lib/utils' 10 | 11 | import { Button } from '../ui/button' 12 | import { 13 | Sheet, 14 | SheetContent, 15 | SheetDescription, 16 | SheetHeader, 17 | SheetTrigger, 18 | } from '../ui/sheet' 19 | import { ThemeToggle } from './theme-toggle' 20 | 21 | interface MobileNavProps { 22 | items: MainNavItem[] 23 | } 24 | 25 | const MobileNav: FC = ({ items }) => { 26 | const pathname = usePathname() 27 | const [isOpen, setIsOpen] = useState(false) 28 | return ( 29 |
30 |
31 | 32 |

33 | JA. 34 |

35 | 36 |
37 | 38 | 39 | 40 | 47 | 48 | 49 | 50 |
51 | setIsOpen(false)} 57 | > 58 |

59 | JA. 60 |

61 | 62 |
63 |
64 | 65 |
66 | {items.map((item) => ( 67 | 76 | {item.title} 77 | 78 | ))} 79 |
80 |
81 |
82 |
83 |
84 | ) 85 | } 86 | 87 | export default MobileNav 88 | 89 | interface MobileLinkProps extends React.ComponentPropsWithRef { 90 | disabled?: boolean 91 | pathname: string 92 | setIsOpen: React.Dispatch> 93 | } 94 | 95 | function MobileLink({ 96 | children, 97 | className, 98 | disabled, 99 | href, 100 | pathname, 101 | setIsOpen, 102 | ...props 103 | }: MobileLinkProps) { 104 | return ( 105 | setIsOpen(false)} 114 | {...props} 115 | > 116 | {children} 117 | 118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /src/components/layout/site-footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | import { cn } from '@/lib/utils' 4 | import { GitHub, LinkedIn, Twitter } from '@/components/icons' 5 | 6 | import { buttonVariants } from '../ui/button' 7 | 8 | const SiteFooter = () => { 9 | return ( 10 |
11 |
12 |
13 |
14 |

15 | Let's talk 16 |

17 |

18 | Send me message on LinkedIn or in X {'(Twitter)'} 19 |

20 |
21 | 22 |
23 | 33 |
63 |
64 |

Built by Jorge Assaf.

65 |

66 | Model by{' '} 67 | 79 | Takuya Matsuyama. 80 | 81 |

82 |
83 |
84 | ) 85 | } 86 | 87 | export default SiteFooter 88 | -------------------------------------------------------------------------------- /src/components/layout/site-header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { FADE_DOWN_ANIMATION_VARIANTS } from '@/constants' 4 | import { domAnimation, LazyMotion, m } from 'framer-motion' 5 | 6 | import { siteConfig } from '@/config/site' 7 | 8 | import MainNav from './main-nav' 9 | import MobileNav from './mobile-nav' 10 | 11 | const SiteHeader = () => { 12 | return ( 13 | 14 | 28 | 32 | 33 | 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | export default SiteHeader 41 | -------------------------------------------------------------------------------- /src/components/layout/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Moon, Sun } from 'lucide-react' 4 | import { useTheme } from 'next-themes' 5 | 6 | import { Button } from '@/components/ui/button' 7 | 8 | export function ThemeToggle() { 9 | const { setTheme, theme } = useTheme() 10 | 11 | return ( 12 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/mdx/mdx-component.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { MDXContent } from '@content-collections/mdx/react' 3 | import type { Post } from 'content-collections' 4 | 5 | import { cn } from '@/lib/utils' 6 | import { Button, buttonVariants } from '@/components/ui/button' 7 | 8 | export const MdxComponent = ({ post }: { post: Post }) => { 9 | return ( 10 |