├── .nvmrc ├── public ├── .nojekyll ├── CNAME ├── robots.txt └── images │ ├── me.jpg │ ├── favicon │ ├── favicon.ico │ ├── apple-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── ms-icon-70x70.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ ├── apple-icon-precomposed.png │ ├── browserconfig.xml │ └── manifest.json │ └── projects │ ├── harvest.jpg │ ├── catdetector.jpg │ ├── spacepotato.jpg │ └── nearestdollar.jpg ├── src ├── types │ └── jest-dom.d.ts ├── static │ └── css │ │ ├── pages │ │ ├── _stats.scss │ │ ├── _notFound.scss │ │ ├── _contact.scss │ │ ├── _skills.scss │ │ └── _resume.scss │ │ ├── components │ │ ├── _markdown.scss │ │ ├── _box.scss │ │ ├── _author.scss │ │ ├── _image.scss │ │ ├── _blurb.scss │ │ ├── _icon.scss │ │ ├── _section.scss │ │ ├── _hamburger.scss │ │ ├── _table.scss │ │ ├── _button.scss │ │ ├── _list.scss │ │ ├── _mini-post.scss │ │ ├── _form.scss │ │ └── _post.scss │ │ ├── layout │ │ ├── _main.scss │ │ ├── _footer.scss │ │ ├── _sidebar.scss │ │ ├── _wrapper.scss │ │ ├── _intro.scss │ │ ├── _menu.scss │ │ └── _header.scss │ │ ├── base │ │ ├── _page.scss │ │ └── _typography.scss │ │ ├── libs │ │ ├── _functions.scss │ │ ├── _vars.scss │ │ ├── _mixins.scss │ │ └── _skel.scss │ │ └── main.scss ├── components │ ├── Stats │ │ ├── Personal.tsx │ │ ├── Table.tsx │ │ ├── types.ts │ │ ├── TableRow.tsx │ │ └── Site.tsx │ ├── Template │ │ ├── GoogleAnalytics.tsx │ │ ├── Navigation.tsx │ │ ├── Hamburger.tsx │ │ └── SideBar.tsx │ ├── Resume │ │ ├── References.tsx │ │ ├── Education │ │ │ └── Degree.tsx │ │ ├── Skills │ │ │ ├── CategoryButton.tsx │ │ │ └── SkillBar.tsx │ │ ├── Experience.tsx │ │ ├── Education.tsx │ │ ├── Courses │ │ │ └── Course.tsx │ │ ├── Courses.tsx │ │ ├── Experience │ │ │ └── Job.tsx │ │ └── Skills.tsx │ ├── Contact │ │ ├── ContactIcons.tsx │ │ └── EmailLink.tsx │ ├── Projects │ │ └── Cell.tsx │ └── __tests__ │ │ ├── ContactIcons.test.tsx │ │ └── Projects │ │ └── Cell.test.tsx └── data │ ├── resume │ ├── degrees.ts │ ├── courses.ts │ ├── skills.ts │ └── work.ts │ ├── routes.ts │ ├── stats │ ├── personal.tsx │ └── site.ts │ ├── contact.ts │ ├── projects.ts │ └── about.ts ├── app ├── favicon.ico ├── types │ └── markdown.d.ts ├── about │ ├── layout.tsx │ └── page.tsx ├── not-found.tsx ├── components │ └── PageWrapper.tsx ├── stats │ └── page.tsx ├── projects │ └── page.tsx ├── contact │ └── page.tsx ├── sitemap.ts ├── page.tsx ├── resume │ └── page.tsx └── layout.tsx ├── docs ├── images │ └── gh-pages.png ├── contributing.md ├── design-goals.md ├── roadmap.md └── adapting-guide.md ├── postcss.config.js ├── .prettierrc.json ├── .env.example ├── .github ├── dependabot.yml └── workflows │ ├── github-pages.yml │ └── node.js.yml ├── .gitattributes ├── .prettierignore ├── sample.env ├── tsconfig.json ├── .gitignore ├── LICENSE ├── next.config.ts ├── jest.setup.ts ├── jest.config.ts ├── CLAUDE.md ├── package.json ├── biome.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /public/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/CNAME: -------------------------------------------------------------------------------- 1 | mldangelo.com 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /api 3 | -------------------------------------------------------------------------------- /src/types/jest-dom.d.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/images/me.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/me.jpg -------------------------------------------------------------------------------- /docs/images/gh-pages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/docs/images/gh-pages.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-normalize': {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /app/types/markdown.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.md' { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "endOfLine": "lf" 6 | } 7 | -------------------------------------------------------------------------------- /public/images/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/favicon.ico -------------------------------------------------------------------------------- /public/images/projects/harvest.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/projects/harvest.jpg -------------------------------------------------------------------------------- /src/static/css/pages/_stats.scss: -------------------------------------------------------------------------------- 1 | // About page (/about) 2 | 3 | #stats { 4 | table { 5 | width: 100%; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /public/images/favicon/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/apple-icon.png -------------------------------------------------------------------------------- /public/images/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/images/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/images/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /public/images/favicon/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/ms-icon-70x70.png -------------------------------------------------------------------------------- /public/images/projects/catdetector.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/projects/catdetector.jpg -------------------------------------------------------------------------------- /public/images/projects/spacepotato.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/projects/spacepotato.jpg -------------------------------------------------------------------------------- /src/static/css/pages/_notFound.scss: -------------------------------------------------------------------------------- 1 | // Not found page (/*) 2 | 3 | .not-found { 4 | text-align: center; 5 | margin: 5em; 6 | } 7 | -------------------------------------------------------------------------------- /public/images/favicon/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/apple-icon-57x57.png -------------------------------------------------------------------------------- /public/images/favicon/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/apple-icon-60x60.png -------------------------------------------------------------------------------- /public/images/favicon/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/apple-icon-72x72.png -------------------------------------------------------------------------------- /public/images/favicon/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/apple-icon-76x76.png -------------------------------------------------------------------------------- /public/images/favicon/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/images/favicon/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/ms-icon-150x150.png -------------------------------------------------------------------------------- /public/images/favicon/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/ms-icon-310x310.png -------------------------------------------------------------------------------- /public/images/projects/nearestdollar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/projects/nearestdollar.jpg -------------------------------------------------------------------------------- /public/images/favicon/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/android-icon-36x36.png -------------------------------------------------------------------------------- /public/images/favicon/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/android-icon-48x48.png -------------------------------------------------------------------------------- /public/images/favicon/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/android-icon-72x72.png -------------------------------------------------------------------------------- /public/images/favicon/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/android-icon-96x96.png -------------------------------------------------------------------------------- /public/images/favicon/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/apple-icon-114x114.png -------------------------------------------------------------------------------- /public/images/favicon/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/images/favicon/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/apple-icon-144x144.png -------------------------------------------------------------------------------- /public/images/favicon/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/images/favicon/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/images/favicon/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/android-icon-144x144.png -------------------------------------------------------------------------------- /public/images/favicon/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/android-icon-192x192.png -------------------------------------------------------------------------------- /public/images/favicon/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mldangelo/personal-site/HEAD/public/images/favicon/apple-icon-precomposed.png -------------------------------------------------------------------------------- /src/static/css/components/_markdown.scss: -------------------------------------------------------------------------------- 1 | // About page (/about) 2 | 3 | .markdown { 4 | p { 5 | margin: auto; 6 | } 7 | 8 | h1 { 9 | font-size: 0.8em; 10 | margin-top: 3em; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Google Analytics 4 Measurement ID 2 | # Get this from Google Analytics > Admin > Data Streams > Your Stream > Measurement ID 3 | # Format: G-XXXXXXXXXX (not UA-XXXXXXXXX-X which was for Universal Analytics) 4 | NEXT_PUBLIC_GA_TRACKING_ID=G-XXXXXXXXXX -------------------------------------------------------------------------------- /src/static/css/components/_box.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Box */ 8 | 9 | // Note: .box class removed - unused in current implementation 10 | -------------------------------------------------------------------------------- /src/static/css/components/_author.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Author */ 8 | 9 | // Note: .author class removed - unused in current implementation 10 | -------------------------------------------------------------------------------- /src/static/css/layout/_main.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Main */ 8 | 9 | #main { 10 | @include vendor('flex-grow', '1'); 11 | width: 100%; 12 | } 13 | -------------------------------------------------------------------------------- /public/images/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /app/about/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | 3 | export const metadata: Metadata = { 4 | title: 'About', 5 | description: "Learn about Michael D'Angelo", 6 | }; 7 | 8 | export default function AboutLayout({ 9 | children, 10 | }: { 11 | children: React.ReactNode; 12 | }) { 13 | return children; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Stats/Personal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | 5 | import data from '../../data/stats/personal'; 6 | import Table from './Table'; 7 | 8 | const PersonalStats: React.FC = () => ( 9 | <> 10 |

Some stats about me

11 | 12 | 13 | ); 14 | 15 | export default PersonalStats; 16 | -------------------------------------------------------------------------------- /src/static/css/components/_image.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Image */ 8 | 9 | .image { 10 | border: 0; 11 | display: inline-block; 12 | position: relative; 13 | 14 | img { 15 | display: block; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Template/GoogleAnalytics.tsx: -------------------------------------------------------------------------------- 1 | import { GoogleAnalytics as NextGoogleAnalytics } from '@next/third-parties/google'; 2 | 3 | const GoogleAnalytics: React.FC = () => { 4 | const gaId = process.env.NEXT_PUBLIC_GA_TRACKING_ID; 5 | 6 | if (!gaId) { 7 | return null; 8 | } 9 | 10 | return ; 11 | }; 12 | 13 | export default GoogleAnalytics; 14 | -------------------------------------------------------------------------------- /src/components/Resume/References.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React from 'react'; 3 | 4 | const References: React.FC = () => ( 5 |
6 |
7 |
8 | 9 |

References are available upon request

10 | 11 |
12 |
13 | ); 14 | 15 | export default References; 16 | -------------------------------------------------------------------------------- /src/static/css/components/_blurb.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Blurb */ 8 | 9 | .blurb { 10 | h2 { 11 | font-size: 0.8em; 12 | margin: 0 0 (_size(element-margin) * 0.75) 0; 13 | } 14 | 15 | h3 { 16 | font-size: 0.7em; 17 | } 18 | 19 | p { 20 | font-size: 0.9em; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "npm" 7 | directory: "/" # Location of package manifest 8 | schedule: 9 | interval: "weekly" 10 | groups: 11 | react: 12 | patterns: 13 | - "react" 14 | - "react-dom" 15 | -------------------------------------------------------------------------------- /src/static/css/components/_icon.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Icon */ 8 | 9 | .icon { 10 | @include icon; 11 | border-bottom: none; 12 | position: relative; 13 | 14 | > .label { 15 | display: none; 16 | } 17 | 18 | &.suffix { 19 | &:before { 20 | float: right; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/Contact/ContactIcons.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import React from 'react'; 3 | 4 | import data from '@/data/contact'; 5 | 6 | const ContactIcons: React.FC = () => ( 7 | 16 | ); 17 | 18 | export default ContactIcons; 19 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto eol=lf 3 | 4 | # Force batch scripts to always use CRLF line endings 5 | *.bat text eol=crlf 6 | *.cmd text eol=crlf 7 | 8 | # Force bash scripts to always use LF line endings 9 | *.sh text eol=lf 10 | 11 | # Denote all files that are truly binary and should not be modified 12 | *.png binary 13 | *.jpg binary 14 | *.jpeg binary 15 | *.gif binary 16 | *.ico binary 17 | *.pdf binary 18 | *.woff binary 19 | *.woff2 binary 20 | *.ttf binary 21 | *.eot binary -------------------------------------------------------------------------------- /src/static/css/components/_section.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Section/Article */ 8 | 9 | header { 10 | p { 11 | font-family: _font(family-heading); 12 | font-size: 0.7em; 13 | font-weight: _font(weight-heading); 14 | letter-spacing: _font(kerning-heading); 15 | line-height: 2.5; 16 | margin-top: -1em; 17 | text-transform: uppercase; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # next.js 10 | /.next/ 11 | /out/ 12 | 13 | # production 14 | /build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # local env files 26 | .env*.local 27 | 28 | # vercel 29 | .vercel 30 | 31 | # typescript 32 | *.tsbuildinfo 33 | next-env.d.ts 34 | 35 | # generated files 36 | package-lock.json 37 | pnpm-lock.yaml 38 | yarn.lock 39 | 40 | # markdown 41 | *.md -------------------------------------------------------------------------------- /src/components/Stats/Table.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import TableRow from './TableRow'; 4 | import { TableProps } from './types'; 5 | 6 | const Table: React.FC = ({ data }) => ( 7 |
8 | 9 | {data.map((pair) => ( 10 | 17 | ))} 18 | 19 |
20 | ); 21 | 22 | export default Table; 23 | -------------------------------------------------------------------------------- /src/components/Resume/Education/Degree.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { Degree as DegreeType } from '@/data/resume/degrees'; 4 | 5 | interface DegreeProps { 6 | data: DegreeType; 7 | } 8 | 9 | const Degree: React.FC = ({ data }) => ( 10 |
11 |
12 |

{data.degree}

13 |

14 | {data.school}, {data.year} 15 |

16 |
17 |
18 | ); 19 | 20 | export default Degree; 21 | -------------------------------------------------------------------------------- /src/components/Stats/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react'; 2 | 3 | export interface StatData { 4 | key?: string; 5 | label: string; 6 | value?: ReactElement | number | string | boolean; 7 | link?: string; 8 | format?: (value: unknown) => string | ReactElement; 9 | } 10 | 11 | export interface TableRowProps { 12 | label: string; 13 | link?: string | null; 14 | value?: ReactElement | number | string | boolean | null; 15 | format?: (value: unknown) => string | ReactElement; 16 | } 17 | 18 | export interface TableProps { 19 | data: StatData[]; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Resume/Skills/CategoryButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface CategoryButtonProps { 4 | label: string; 5 | handleClick: (label: string) => void; 6 | active: Record; 7 | } 8 | 9 | const CategoryButton: React.FC = ({ 10 | handleClick, 11 | active, 12 | label, 13 | }) => ( 14 | 21 | ); 22 | 23 | export default CategoryButton; 24 | -------------------------------------------------------------------------------- /src/data/resume/degrees.ts: -------------------------------------------------------------------------------- 1 | export interface Degree { 2 | school: string; 3 | degree: string; 4 | link: string; 5 | year: number; 6 | } 7 | 8 | const degrees: Degree[] = [ 9 | { 10 | school: 'Stanford University', 11 | degree: 'M.S. Computational and Mathematical Engineering (ICME)', 12 | link: 'https://stanford.edu', 13 | year: 2016, 14 | }, 15 | { 16 | school: 'University at Buffalo', 17 | degree: 'B.S. Electrical Engineering, Computer Engineering', 18 | link: 'https://buffalo.edu', 19 | year: 2012, 20 | }, 21 | ]; 22 | 23 | export default degrees; 24 | -------------------------------------------------------------------------------- /src/static/css/pages/_contact.scss: -------------------------------------------------------------------------------- 1 | // Contact page (/contact) 2 | 3 | #contact { 4 | .email-at { 5 | margin: 3em 0; 6 | 7 | p { 8 | margin: 0; 9 | } 10 | 11 | .inline-container { 12 | width: 100%; 13 | 14 | span:focus { 15 | display: inline-block; 16 | outline: none; 17 | border: 0; 18 | text-align: right; 19 | } 20 | } 21 | } 22 | } 23 | 24 | .inline { 25 | min-width: 150px; 26 | display: inline-block; 27 | margin: 0; 28 | padding: 0; 29 | font-size: 15; 30 | outline: 0; 31 | border: 0; 32 | text-align: right; 33 | } 34 | -------------------------------------------------------------------------------- /src/data/routes.ts: -------------------------------------------------------------------------------- 1 | export interface Route { 2 | label: string; 3 | path: string; 4 | index?: boolean; 5 | } 6 | 7 | const routes: Route[] = [ 8 | { 9 | index: true, 10 | label: "Michael D'Angelo", 11 | path: '/', 12 | }, 13 | { 14 | label: 'About', 15 | path: '/about', 16 | }, 17 | { 18 | label: 'Resume', 19 | path: '/resume', 20 | }, 21 | { 22 | label: 'Projects', 23 | path: '/projects', 24 | }, 25 | { 26 | label: 'Stats', 27 | path: '/stats', 28 | }, 29 | { 30 | label: 'Contact', 31 | path: '/contact', 32 | }, 33 | ]; 34 | 35 | export default routes; 36 | -------------------------------------------------------------------------------- /src/components/Resume/Experience.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { Position } from '@/data/resume/work'; 4 | 5 | import Job from './Experience/Job'; 6 | 7 | interface ExperienceProps { 8 | data: Position[]; 9 | } 10 | 11 | const Experience: React.FC = ({ data }) => ( 12 |
13 |
14 |
15 |

Experience

16 |
17 | {data.map((job) => ( 18 | 19 | ))} 20 |
21 | ); 22 | 23 | export default Experience; 24 | -------------------------------------------------------------------------------- /src/components/Resume/Education.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { Degree as DegreeType } from '@/data/resume/degrees'; 4 | 5 | import Degree from './Education/Degree'; 6 | 7 | interface EducationProps { 8 | data: DegreeType[]; 9 | } 10 | 11 | const Education: React.FC = ({ data }) => ( 12 |
13 |
14 |
15 |

Education

16 |
17 | {data.map((degree) => ( 18 | 19 | ))} 20 |
21 | ); 22 | 23 | export default Education; 24 | -------------------------------------------------------------------------------- /src/static/css/layout/_footer.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Footer */ 8 | 9 | #footer { 10 | .icons { 11 | color: _palette(fg-light); 12 | } 13 | 14 | .copyright { 15 | color: _palette(fg-light); 16 | font-family: _font(family-heading); 17 | font-size: 0.5em; 18 | font-weight: _font(weight-heading); 19 | letter-spacing: _font(kerning-heading); 20 | text-transform: uppercase; 21 | } 22 | 23 | body.single & { 24 | text-align: center; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Stats/TableRow.tsx: -------------------------------------------------------------------------------- 1 | import React, { isValidElement } from 'react'; 2 | 3 | import { TableRowProps } from './types'; 4 | 5 | const TableRow: React.FC = ({ 6 | label, 7 | link = null, 8 | value = null, 9 | format, 10 | }) => { 11 | // If value is a React element, render it directly 12 | const displayValue = isValidElement(value) 13 | ? value 14 | : format 15 | ? format(value) 16 | : String(value); 17 | 18 | return ( 19 | 20 | {label} 21 | {link ? {displayValue} : displayValue} 22 | 23 | ); 24 | }; 25 | 26 | export default TableRow; 27 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | 5 | export const metadata: Metadata = { 6 | title: 'Page Not Found', 7 | description: 'Page not found', 8 | }; 9 | 10 | export default function NotFound() { 11 | return ( 12 |
13 |
14 |
15 |
16 |
17 |

Page Not Found

18 |
19 |
20 | Return Home 21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/static/css/components/_hamburger.scss: -------------------------------------------------------------------------------- 1 | // Not found page 2 | 3 | .bm-item-list { 4 | margin-top: 0; 5 | } 6 | 7 | .menu-hover { 8 | padding: 0 1em; 9 | } 10 | 11 | .menu-hover:hover { 12 | opacity: 0.5; 13 | } 14 | 15 | .hamburger-ul { 16 | display: block; 17 | 18 | li a h3 { 19 | border: 0; 20 | border-top: dotted 1px _palette(border); 21 | margin: 1.5em 0 0 0; 22 | padding: 1.5em 0 0 0; 23 | } 24 | 25 | li a h3:hover { 26 | color: _palette(accent); 27 | } 28 | 29 | li { 30 | display: block !important; 31 | } 32 | 33 | h3 { 34 | font-size: 0.7em; 35 | } 36 | 37 | .index-li { 38 | border-top: none; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/Resume/Courses/Course.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { Course as CourseType } from '@/data/resume/courses'; 4 | 5 | interface CourseProps { 6 | data: CourseType; 7 | last?: boolean; 8 | } 9 | 10 | const Course: React.FC = ({ data, last = false }) => ( 11 |
  • 12 | 13 |

    {data.number}:

    14 |

    {data.title}

    15 |
    16 | {!last && ( 17 |
    18 |

    19 |
    20 | )} 21 |
  • 22 | ); 23 | 24 | export default Course; 25 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | # See https://create-react-app.dev/docs/adding-custom-environment-variables/ 2 | # for information on defining new variables 3 | 4 | # In most instances, this does not need to be set. Set this if you plan on 5 | # hosting with a CDN at a different path from your homepage in package.json 6 | # By default, PUBLIC_URL is set to the path specified as your homepage. If you 7 | # host at a subpath, you will still need to prefix PUBLIC_URL to static assets. 8 | # PUBLIC_URL=www.[CDN].com/personal-site 9 | 10 | # Google analytics ID 11 | # NEXT_PUBLIC_GA_TRACKING_ID=UA-XXXXXXXXX-X 12 | 13 | # Set to `production` to enable analytics and create production optimized builds 14 | NODE_ENV=development 15 | -------------------------------------------------------------------------------- /app/components/PageWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { usePathname } from 'next/navigation'; 4 | import React, { useEffect } from 'react'; 5 | 6 | import SideBar from '@/components/Template/SideBar'; 7 | 8 | interface PageWrapperProps { 9 | children: React.ReactNode; 10 | fullPage?: boolean; 11 | } 12 | 13 | export default function PageWrapper({ 14 | children, 15 | fullPage = false, 16 | }: PageWrapperProps) { 17 | const pathname = usePathname(); 18 | 19 | // Scroll to top on route change 20 | useEffect(() => { 21 | window.scrollTo(0, 0); 22 | }, [pathname]); 23 | 24 | return ( 25 | <> 26 |
    {children}
    27 | {!fullPage && } 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/static/css/base/_page.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Basic */ 8 | 9 | // Ensures page width is always >=320px. 10 | @include breakpoint(xsmall) { 11 | html, 12 | body { 13 | min-width: 320px; 14 | } 15 | } 16 | 17 | body { 18 | background: _palette(bg-alt); 19 | 20 | // Prevents animation/transition "flicker" on page load. 21 | // Automatically added/removed by js/main.js. 22 | &.is-loading { 23 | *, 24 | *:before, 25 | *:after { 26 | @include vendor('animation', 'none !important'); 27 | @include vendor('transition', 'none !important'); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/static/css/layout/_sidebar.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Sidebar */ 8 | 9 | #sidebar { 10 | margin-right: _size(section-spacing); 11 | min-width: 22em; 12 | width: 22em; 13 | 14 | > * { 15 | border-top: solid 1px _palette(border); 16 | margin: _size(section-spacing) 0 0 0; 17 | padding: _size(section-spacing) 0 0 0; 18 | } 19 | 20 | > :first-child { 21 | border-top: 0; 22 | margin-top: 0; 23 | padding-top: 0; 24 | } 25 | 26 | @include breakpoint(large) { 27 | border-top: solid 1px _palette(border); 28 | margin: _size(section-spacing) 0 0 0; 29 | min-width: 0; 30 | padding: _size(section-spacing) 0 0 0; 31 | width: 100%; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 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": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "react-jsx", 16 | "incremental": true, 17 | "paths": { 18 | "@/*": ["./src/*"] 19 | }, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ] 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | "**/*.ts", 29 | "**/*.tsx", 30 | ".next/types/**/*.ts", 31 | "jest.setup.ts", 32 | ".next/dev/types/**/*.ts" 33 | ], 34 | "exclude": ["node_modules"] 35 | } 36 | -------------------------------------------------------------------------------- /app/about/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Markdown from 'markdown-to-jsx'; 4 | 5 | import Link from 'next/link'; 6 | import React from 'react'; 7 | 8 | import { aboutMarkdown } from '@/data/about'; 9 | 10 | import PageWrapper from '../components/PageWrapper'; 11 | 12 | const count = (str: string) => 13 | str.split(/\s+/).filter((word) => word !== '').length; 14 | 15 | export default function AboutPage() { 16 | return ( 17 | 18 |
    19 |
    20 |
    21 |

    22 | About Me 23 |

    24 |

    (in about {count(aboutMarkdown)} words)

    25 |
    26 |
    27 | {aboutMarkdown} 28 |
    29 |
    30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/stats/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | 5 | import Personal from '@/components/Stats/Personal'; 6 | import Site from '@/components/Stats/Site'; 7 | 8 | import PageWrapper from '../components/PageWrapper'; 9 | 10 | export const metadata: Metadata = { 11 | title: 'Stats', 12 | description: "Some statistics about Michael D'Angelo and mldangelo.com", 13 | }; 14 | 15 | export default function StatsPage() { 16 | return ( 17 | 18 |
    19 |
    20 |
    21 |

    22 | Stats 23 |

    24 |
    25 |
    26 | 27 | 28 |
    29 |
    30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Projects/Cell.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | import Image from 'next/image'; 4 | import React from 'react'; 5 | 6 | import type { Project } from '@/data/projects'; 7 | 8 | interface CellProps { 9 | data: Project; 10 | } 11 | 12 | const Cell: React.FC = ({ data }) => ( 13 |
    14 |
    15 |
    16 |

    17 | {data.title} 18 |

    19 | 22 |
    23 | 24 | {data.title} 25 | 26 |
    27 |

    {data.desc}

    28 |
    29 |
    30 |
    31 | ); 32 | 33 | export default Cell; 34 | -------------------------------------------------------------------------------- /src/static/css/libs/_functions.scss: -------------------------------------------------------------------------------- 1 | /// Gets a duration value. 2 | /// @param {string} $keys Key(s). 3 | /// @return {string} Value. 4 | @function _duration($keys...) { 5 | @return val($duration, $keys...); 6 | } 7 | 8 | /// Gets a font value. 9 | /// @param {string} $keys Key(s). 10 | /// @return {string} Value. 11 | @function _font($keys...) { 12 | @return val($font, $keys...); 13 | } 14 | 15 | /// Gets a misc value. 16 | /// @param {string} $keys Key(s). 17 | /// @return {string} Value. 18 | @function _misc($keys...) { 19 | @return val($misc, $keys...); 20 | } 21 | 22 | /// Gets a palette value. 23 | /// @param {string} $keys Key(s). 24 | /// @return {string} Value. 25 | @function _palette($keys...) { 26 | @return val($palette, $keys...); 27 | } 28 | 29 | /// Gets a size value. 30 | /// @param {string} $keys Key(s). 31 | /// @return {string} Value. 32 | @function _size($keys...) { 33 | @return val($size, $keys...); 34 | } 35 | -------------------------------------------------------------------------------- /src/static/css/layout/_wrapper.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Wrapper */ 8 | 9 | #wrapper { 10 | @include vendor('display', 'flex'); 11 | @include vendor('flex-direction', 'row-reverse'); 12 | @include vendor('transition', 'opacity #{_duration(menu)} ease'); 13 | margin: 0 auto; 14 | max-width: 100%; 15 | opacity: 1; 16 | padding: (_size(section-spacing) * 1.5); 17 | width: 90em; 18 | 19 | body.is-menu-visible & { 20 | opacity: 0.15; 21 | } 22 | 23 | @include breakpoint(xlarge) { 24 | padding: _size(section-spacing); 25 | } 26 | 27 | @include breakpoint(large) { 28 | display: block; 29 | } 30 | 31 | @include breakpoint(small) { 32 | padding: _size(section-spacing-small); 33 | } 34 | 35 | body.single & { 36 | display: block; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/static/css/components/_table.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Table */ 8 | 9 | table { 10 | margin: 0 0 _size(element-margin) 0; 11 | width: 100%; 12 | 13 | tbody { 14 | tr { 15 | border: solid 1px _palette(border); 16 | border-left: 0; 17 | border-right: 0; 18 | 19 | &:nth-child(2n + 1) { 20 | background-color: _palette(border-bg); 21 | } 22 | } 23 | } 24 | 25 | td { 26 | padding: 0.75em 0.75em; 27 | } 28 | 29 | th { 30 | color: _palette(fg-bold); 31 | font-size: 0.9em; 32 | font-weight: _font(weight-bold); 33 | padding: 0 0.75em 0.75em 0.75em; 34 | text-align: left; 35 | } 36 | 37 | thead { 38 | border-bottom: solid 2px _palette(border); 39 | } 40 | 41 | tfoot { 42 | border-top: solid 2px _palette(border); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/components/Template/Navigation.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import React from 'react'; 5 | 6 | import routes from '../../data/routes'; 7 | import Hamburger from './Hamburger'; 8 | 9 | // Websites Navbar, displays routes defined in 'src/data/routes' 10 | const Navigation: React.FC = () => ( 11 | 34 | ); 35 | 36 | export default Navigation; 37 | -------------------------------------------------------------------------------- /app/projects/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | 5 | import Cell from '@/components/Projects/Cell'; 6 | import data from '@/data/projects'; 7 | 8 | import PageWrapper from '../components/PageWrapper'; 9 | 10 | export const metadata: Metadata = { 11 | title: 'Projects', 12 | description: "Learn about Michael D'Angelo's projects.", 13 | }; 14 | 15 | export default function ProjectsPage() { 16 | return ( 17 | 18 |
    19 |
    20 |
    21 |

    22 | Projects 23 |

    24 |

    A selection of projects that I'm not too ashamed of

    25 |
    26 |
    27 | {data.map((project) => ( 28 | 29 | ))} 30 |
    31 |
    32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/contact/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | 5 | import ContactIcons from '@/components/Contact/ContactIcons'; 6 | import EmailLink from '@/components/Contact/EmailLink'; 7 | 8 | import PageWrapper from '../components/PageWrapper'; 9 | 10 | export const metadata: Metadata = { 11 | title: 'Contact', 12 | description: "Contact Michael D'Angelo via email @ hi@mldangelo.com", 13 | }; 14 | 15 | export default function ContactPage() { 16 | return ( 17 | 18 |
    19 |
    20 |
    21 |

    22 | Contact 23 |

    24 |
    25 |
    26 |
    27 |

    Feel free to get in touch. You can email me at:

    28 | 29 |
    30 | 31 |
    32 |
    33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # prefer npm over yarn 2 | yarn.lock 3 | 4 | # eslint 5 | .eslintcache 6 | 7 | # Logs 8 | logs 9 | *.log 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | 16 | # Enviromental 17 | .env 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directory 32 | # Commenting this out is preferred by some people, see 33 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 34 | node_modules 35 | 36 | # Users Environment Variables 37 | .lock-wscript 38 | 39 | # Webpack related 40 | public/dist/ 41 | dist/ 42 | tmp/ 43 | build/ 44 | 45 | # Next.js 46 | .next/ 47 | out/ 48 | .swc/ 49 | next-env.d.ts 50 | tsconfig.tsbuildinfo 51 | 52 | # OSX 53 | .DS_Store 54 | 55 | # Nohup 56 | nohup.out 57 | -------------------------------------------------------------------------------- /src/components/Resume/Skills/SkillBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { Category, Skill } from '@/data/resume/skills'; 4 | 5 | interface SkillBarProps { 6 | data: Skill; 7 | categories: Category[]; 8 | } 9 | 10 | const SkillBar: React.FC = ({ data, categories }) => { 11 | const { category, competency, title } = data; 12 | 13 | // TODO: Consider averaging colors 14 | const titleStyle = { 15 | background: categories 16 | .filter((cat) => category.includes(cat.name)) 17 | .map((cat) => cat.color)[0], 18 | }; 19 | 20 | const barStyle = { 21 | ...titleStyle, 22 | width: `${String(Math.min(100, Math.max((competency / 5.0) * 100.0, 0)))}%`, 23 | }; 24 | 25 | return ( 26 |
    27 |
    28 | {title} 29 |
    30 |
    31 |
    {competency} / 5
    32 |
    33 | ); 34 | }; 35 | 36 | export default SkillBar; 37 | -------------------------------------------------------------------------------- /public/images/favicon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mldangelo.com", 3 | "icons": [ 4 | { 5 | "src": "/images/favicon/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "/images/favicon/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "/images/favicon/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "/images/favicon/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "/images/favicon/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "/images/favicon/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/components/__tests__/ContactIcons.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import React from 'react'; 3 | 4 | import ContactIcons from '../Contact/ContactIcons'; 5 | 6 | describe('ContactIcons', () => { 7 | it('renders contact icons', () => { 8 | render(); 9 | 10 | // Check if GitHub link is present 11 | const githubLink = screen.getByRole('link', { name: /github/i }); 12 | expect(githubLink).toBeInTheDocument(); 13 | expect(githubLink).toHaveAttribute( 14 | 'href', 15 | expect.stringContaining('github.com'), 16 | ); 17 | 18 | // Check if email link is present 19 | const emailLink = screen.getByRole('link', { name: /email/i }); 20 | expect(emailLink).toBeInTheDocument(); 21 | expect(emailLink).toHaveAttribute( 22 | 'href', 23 | expect.stringContaining('mailto:'), 24 | ); 25 | }); 26 | 27 | it('has correct number of contact links', () => { 28 | render(); 29 | const links = screen.getAllByRole('link'); 30 | expect(links.length).toBeGreaterThan(0); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/Resume/Courses.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { Course as CourseType } from '@/data/resume/courses'; 4 | 5 | import Course from './Courses/Course'; 6 | 7 | interface CoursesProps { 8 | data: CourseType[]; 9 | } 10 | 11 | const getRows = (courses: CourseType[]) => 12 | courses 13 | .sort((a, b) => { 14 | let ret = 0; 15 | if (a.university > b.university) ret = -1; 16 | else if (a.university < b.university) ret = 1; 17 | else if (a.number > b.number) ret = 1; 18 | else if (a.number < b.number) ret = -1; 19 | return ret; 20 | }) 21 | .map((course, idx) => ( 22 | 27 | )); 28 | 29 | const Courses: React.FC = ({ data }) => ( 30 |
    31 |
    32 |
    33 |

    Selected Courses

    34 |
    35 |
      {getRows(data)}
    36 |
    37 | ); 38 | 39 | export default Courses; 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Michael D'Angelo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/data/stats/personal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useState } from 'react'; 4 | 5 | import { StatData } from '../../components/Stats/types'; 6 | 7 | const Age: React.FC = () => { 8 | const [age, setAge] = useState(''); 9 | 10 | const tick = () => { 11 | const divisor = 1000 * 60 * 60 * 24 * 365.2421897; // ms in an average year 12 | const birthTime = new Date('1990-02-05T09:24:00'); 13 | setAge(((Date.now() - birthTime.getTime()) / divisor).toFixed(11)); 14 | }; 15 | 16 | useEffect(() => { 17 | tick(); // Initial tick 18 | const timer = setInterval(() => tick(), 25); 19 | return () => { 20 | clearInterval(timer); 21 | }; 22 | }, []); 23 | 24 | return <>{age}; 25 | }; 26 | 27 | const data: StatData[] = [ 28 | { 29 | key: 'age', 30 | label: 'Current age', 31 | value: , 32 | }, 33 | { 34 | key: 'countries', 35 | label: 'Countries visited', 36 | value: 53, 37 | link: 'https://www.google.com/maps/d/embed?mid=1iBBTscqateQ93pWFVfHCUZXoDu8&z=2', 38 | }, 39 | { 40 | key: 'location', 41 | label: 'Current city', 42 | value: 'New York, NY', 43 | }, 44 | ]; 45 | 46 | export default data; 47 | -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from 'next'; 2 | 3 | export const dynamic = 'force-static'; 4 | 5 | export default function sitemap(): MetadataRoute.Sitemap { 6 | const baseUrl = 'https://mldangelo.com'; 7 | const currentDate = new Date(); 8 | 9 | return [ 10 | { 11 | url: baseUrl, 12 | lastModified: currentDate, 13 | changeFrequency: 'monthly', 14 | priority: 1, 15 | }, 16 | { 17 | url: `${baseUrl}/about`, 18 | lastModified: currentDate, 19 | changeFrequency: 'monthly', 20 | priority: 0.8, 21 | }, 22 | { 23 | url: `${baseUrl}/resume`, 24 | lastModified: currentDate, 25 | changeFrequency: 'monthly', 26 | priority: 0.8, 27 | }, 28 | { 29 | url: `${baseUrl}/projects`, 30 | lastModified: currentDate, 31 | changeFrequency: 'monthly', 32 | priority: 0.8, 33 | }, 34 | { 35 | url: `${baseUrl}/stats`, 36 | lastModified: currentDate, 37 | changeFrequency: 'weekly', 38 | priority: 0.5, 39 | }, 40 | { 41 | url: `${baseUrl}/contact`, 42 | lastModified: currentDate, 43 | changeFrequency: 'yearly', 44 | priority: 0.5, 45 | }, 46 | ]; 47 | } 48 | -------------------------------------------------------------------------------- /src/static/css/libs/_vars.scss: -------------------------------------------------------------------------------- 1 | // Misc. 2 | $misc: ( 3 | z-index-base: 10000, 4 | ); 5 | 6 | // Duration. 7 | $duration: ( 8 | menu: 0.5s, 9 | transition: 0.2s, 10 | ); 11 | 12 | // Size. 13 | $size: ( 14 | element-height: 2.75em, 15 | element-margin: 2em, 16 | section-spacing: 3em, 17 | section-spacing-small: 1.5em, 18 | menu: 25em, 19 | letter-spacing-alt: 0.25em, 20 | ); 21 | 22 | // Font. 23 | $font: ( 24 | family: ( 25 | var(--font-source-sans), 26 | 'Source Sans Pro', 27 | Helvetica, 28 | sans-serif, 29 | ), 30 | family-fixed: ( 31 | 'Courier New', 32 | monospace, 33 | ), 34 | family-heading: ( 35 | var(--font-raleway), 36 | 'Raleway', 37 | Helvetica, 38 | sans-serif, 39 | ), 40 | weight: 400, 41 | weight-bold: 700, 42 | weight-heading: 400, 43 | weight-heading-bold: 800, 44 | weight-heading-extrabold: 900, 45 | kerning-heading: 0.25em, 46 | ); 47 | 48 | // Palette. 49 | $palette: ( 50 | bg: #ffffff, 51 | bg-alt: #f4f4f4, 52 | fg: #646464, 53 | fg-bold: #3c3b3b, 54 | fg-light: #aaaaaa, 55 | border: rgba(160, 160, 160, 0.3), 56 | border-bg: rgba(160, 160, 160, 0.075), 57 | border-alt: rgba(160, 160, 160, 0.65), 58 | accent: #2e59ba, 59 | ); 60 | -------------------------------------------------------------------------------- /src/static/css/libs/_mixins.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:list'; 2 | @use 'sass:math'; 3 | 4 | /// Makes an element's :before pseudoelement a FontAwesome icon. 5 | /// @param {string} $content Optional content value to use. 6 | /// @param {string} $where Optional pseudoelement to target (before or after). 7 | @mixin icon($content: false, $where: before) { 8 | text-decoration: none; 9 | 10 | &:#{$where} { 11 | @if $content { 12 | content: $content; 13 | } 14 | 15 | -moz-osx-font-smoothing: grayscale; 16 | -webkit-font-smoothing: antialiased; 17 | font-family: FontAwesome; 18 | font-style: normal; 19 | font-weight: normal; 20 | text-transform: none !important; 21 | } 22 | } 23 | 24 | /// Applies padding to an element, taking the current element-margin value into account. 25 | /// @param {mixed} $tb Top/bottom padding. 26 | /// @param {mixed} $lr Left/right padding. 27 | /// @param {list} $pad Optional extra padding (in the following order top, right, bottom, left) 28 | /// @param {bool} $important If true, adds !important. 29 | @mixin padding($tb, $lr, $pad: (0, 0, 0, 0), $important: null) { 30 | @if $important { 31 | $important: '!important'; 32 | } 33 | 34 | padding: ($tb + list.nth($pad, 1)) ($lr + list.nth($pad, 2)) 35 | math.max(0.1em, $tb - _size(element-margin) + list.nth($pad, 3)) ($lr + list.nth($pad, 4)) 36 | #{$important}; 37 | } 38 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next'; 2 | 3 | const nextConfig: NextConfig = { 4 | output: 'export', 5 | images: { 6 | unoptimized: true, 7 | }, 8 | sassOptions: { 9 | includePaths: ['./src/static/css'], 10 | silenceDeprecations: ['import'], // Silence @import deprecation warnings 11 | }, 12 | basePath: process.env.NODE_ENV === 'production' ? '' : '', 13 | assetPrefix: process.env.NODE_ENV === 'production' ? '' : '', 14 | trailingSlash: true, 15 | 16 | // Turbopack configuration 17 | turbopack: { 18 | // Define module resolution rules 19 | rules: {}, 20 | // Module resolution extensions 21 | resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], 22 | }, 23 | 24 | // Experimental features 25 | experimental: { 26 | // Enable optimizations for packages 27 | optimizePackageImports: [ 28 | '@fortawesome/react-fontawesome', 29 | '@fortawesome/fontawesome-svg-core', 30 | ], 31 | }, 32 | }; 33 | 34 | // Only apply bundle analyzer when not using Turbopack 35 | // This prevents the warning about Webpack being configured while Turbopack is not 36 | if (process.env.TURBOPACK !== '1') { 37 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 38 | enabled: process.env.ANALYZE === 'true', 39 | }); 40 | 41 | module.exports = withBundleAnalyzer(nextConfig); 42 | } else { 43 | module.exports = nextConfig; 44 | } 45 | 46 | export default nextConfig; 47 | -------------------------------------------------------------------------------- /src/components/__tests__/Projects/Cell.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import React from 'react'; 3 | 4 | import Cell from '../../Projects/Cell'; 5 | 6 | describe('Cell', () => { 7 | const mockProject = { 8 | title: 'Test Project', 9 | subtitle: 'A test subtitle', 10 | image: '/images/test.jpg', 11 | date: '2023-01-01', 12 | desc: 'This is a test project description', 13 | link: 'https://example.com', 14 | }; 15 | 16 | it('renders project title with link', () => { 17 | render(); 18 | const titleLinks = screen.getAllByRole('link', { name: mockProject.title }); 19 | expect(titleLinks).toHaveLength(2); // Title link and image link 20 | expect(titleLinks[0]).toHaveAttribute('href', mockProject.link); 21 | }); 22 | 23 | it('renders project description', () => { 24 | render(); 25 | expect(screen.getByText(mockProject.desc)).toBeInTheDocument(); 26 | }); 27 | 28 | it('renders project date in correct format', () => { 29 | render(); 30 | expect(screen.getByText('January, 2023')).toBeInTheDocument(); 31 | }); 32 | 33 | it('renders project image with alt text', () => { 34 | render(); 35 | const image = screen.getByAltText(mockProject.title); 36 | expect(image).toBeInTheDocument(); 37 | expect(image).toHaveAttribute('src', expect.stringContaining('test.jpg')); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | Contributions are encouraged. Please feel free to get in touch (I will happily pair program with you), take a look at the [roadmap](./roadmap.md), or open a PR. 4 | 5 | ## Guidelines 6 | 7 | Here are a few recommendations to land PRs quickly. 8 | 9 | - Small PRs are better than large PRs. If you do two unrelated things, split your changes into two PRs. 10 | - Review the [design goals](./design-goals.md). 11 | - Respect the [Contributor Covenant](https://www.contributor-covenant.org/). 12 | 13 | ## Preparing a Pull Request 14 | 15 | 1. Write a good summary in your PR description. 16 | - Concisely explain your changes. 17 | - Justify why your changes are important. 18 | - Explain how to test your change (if not obvious). 19 | 1. Make sure everything runs. 20 | 1. Write tests (if appropriate). 21 | 1. Self review your branch. 22 | - Lint your code. 23 | - Add comments where appropriate and if things are unclear. Comments should help ensure others not familiar with the problem you're solving will understand your code months or years from now. 24 | - Minimize perceptual changes in files that are not relevant to your feature. Remove any extra white-spaces in other files that were added by mistake. 25 | - Remove anything unnecessary. Remove extra print statements, commented out blocks of code, unused variables, etc. 26 | - Check your spelling. 27 | 28 | ## References 29 | 30 | - https://github.com/google/eng-practices (Recommended Reading) 31 | -------------------------------------------------------------------------------- /src/components/Stats/Site.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useState } from 'react'; 4 | 5 | import initialData from '../../data/stats/site'; 6 | import Table from './Table'; 7 | import { StatData } from './types'; 8 | 9 | interface GitHubRepoData { 10 | [key: string]: string | number | boolean | null; 11 | } 12 | 13 | const Stats: React.FC = () => { 14 | const [data, setResponseData] = useState(initialData); 15 | 16 | // React 19: Simplified data fetching without unnecessary useCallback 17 | useEffect(() => { 18 | const fetchData = async () => { 19 | try { 20 | const res = await fetch( 21 | 'https://api.github.com/repos/mldangelo/personal-site', 22 | ); 23 | const resData: GitHubRepoData = await res.json(); 24 | 25 | setResponseData( 26 | initialData.map((field) => ({ 27 | ...field, 28 | // update value if value was returned by call to github 29 | value: 30 | field.key && Object.keys(resData).includes(field.key) 31 | ? (resData[field.key] ?? field.value) 32 | : field.value, 33 | })), 34 | ); 35 | } catch (error) { 36 | console.error('Failed to fetch GitHub data:', error); 37 | } 38 | }; 39 | 40 | fetchData(); 41 | }, []); 42 | 43 | return ( 44 |
    45 |

    Some stats about this site

    46 | 47 | 48 | ); 49 | }; 50 | 51 | export default Stats; 52 | -------------------------------------------------------------------------------- /src/static/css/layout/_intro.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Intro */ 8 | 9 | #intro { 10 | .logo { 11 | border-bottom: 0; 12 | display: inline-block; 13 | margin: 0 0 (_size(element-margin) * 0.5) 0; 14 | overflow: hidden; 15 | position: relative; 16 | 17 | img { 18 | width: 10em; 19 | height: 10em; 20 | object-fit: cover; 21 | border-radius: 50%; 22 | display: block; 23 | } 24 | } 25 | 26 | header { 27 | h2 { 28 | font-size: 1.5em; 29 | font-weight: _font(weight-heading-extrabold); 30 | } 31 | 32 | p { 33 | font-size: 0.8em; 34 | } 35 | } 36 | 37 | @include breakpoint(large) { 38 | margin: 0 0 _size(section-spacing) 0; 39 | text-align: center; 40 | 41 | header { 42 | h2 { 43 | font-size: 2em; 44 | } 45 | 46 | p { 47 | font-size: 0.7em; 48 | } 49 | } 50 | } 51 | 52 | @include breakpoint(small) { 53 | margin: 0 0 _size(section-spacing-small) 0; 54 | padding: 1.25em 0; 55 | 56 | > :last-child { 57 | margin-bottom: 0; 58 | } 59 | 60 | .logo { 61 | margin: 0 0 (_size(element-margin) * 0.25) 0; 62 | } 63 | 64 | header { 65 | h2 { 66 | font-size: 1.25em; 67 | } 68 | 69 | > :last-child { 70 | margin-bottom: 0; 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | 5 | import PageWrapper from './components/PageWrapper'; 6 | 7 | export const metadata: Metadata = { 8 | description: 9 | 'Co-founder & CTO building LLM security tools. Previously VP Engineering, YC alum, Stanford ICME.', 10 | }; 11 | 12 | export default function HomePage() { 13 | return ( 14 | 15 |
    16 |
    17 |
    18 |

    19 | About this site 20 |

    21 |

    22 | A beautiful, responsive, statically-generated, react application 23 | written with modern TypeScript. 24 |

    25 |
    26 |
    27 |

    28 | {' '} 29 | Welcome to my website. Please feel free to read more{' '} 30 | about me, or you can check out my{' '} 31 | resume,{' '} 32 | projects, view{' '} 33 | site statistics, or{' '} 34 | contact me. 35 |

    36 |

    37 | {' '} 38 | Source available{' '} 39 | here. 40 |

    41 |
    42 |
    43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/components/Resume/Experience/Job.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import Markdown from 'markdown-to-jsx'; 3 | import React from 'react'; 4 | 5 | import type { Position } from '@/data/resume/work'; 6 | 7 | interface JobProps { 8 | data: Position; 9 | } 10 | 11 | const Job: React.FC = ({ data }) => { 12 | const { name, position, url, startDate, endDate, summary, highlights } = data; 13 | 14 | return ( 15 |
    16 |
    17 |

    18 | {name} - {position} 19 |

    20 |

    21 | {' '} 22 | {dayjs(startDate).format('MMMM YYYY')} -{' '} 23 | {endDate ? dayjs(endDate).format('MMMM YYYY') : 'PRESENT'} 24 |

    25 |
    26 | {summary ? ( 27 | <>{children}, 37 | }, 38 | pre: { 39 | component: ({ children }) => <>{children}, 40 | }, 41 | }, 42 | }} 43 | > 44 | {summary} 45 | 46 | ) : null} 47 | {highlights ? ( 48 |
      49 | {highlights.map((highlight) => ( 50 |
    • {highlight}
    • 51 | ))} 52 |
    53 | ) : null} 54 |
    55 | ); 56 | }; 57 | 58 | export default Job; 59 | -------------------------------------------------------------------------------- /src/data/contact.ts: -------------------------------------------------------------------------------- 1 | import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; 2 | import { faAngellist } from '@fortawesome/free-brands-svg-icons/faAngellist'; 3 | import { faFacebookF } from '@fortawesome/free-brands-svg-icons/faFacebookF'; 4 | import { faGithub } from '@fortawesome/free-brands-svg-icons/faGithub'; 5 | import { faInstagram } from '@fortawesome/free-brands-svg-icons/faInstagram'; 6 | import { faLinkedinIn } from '@fortawesome/free-brands-svg-icons/faLinkedinIn'; 7 | import { faTwitter } from '@fortawesome/free-brands-svg-icons/faTwitter'; 8 | import { faEnvelope } from '@fortawesome/free-regular-svg-icons/faEnvelope'; 9 | 10 | export interface ContactItem { 11 | link: string; 12 | label: string; 13 | icon: IconDefinition; 14 | } 15 | 16 | const data: ContactItem[] = [ 17 | { 18 | link: 'https://github.com/mldangelo', 19 | label: 'Github', 20 | icon: faGithub, 21 | }, 22 | { 23 | link: 'https://facebook.com/d', 24 | label: 'Facebook', 25 | icon: faFacebookF, 26 | }, 27 | { 28 | link: 'https://www.instagram.com/dangelosaurus/', 29 | label: 'Instagram', 30 | icon: faInstagram, 31 | }, 32 | { 33 | link: 'https://www.linkedin.com/in/michaelldangelo', 34 | label: 'LinkedIn', 35 | icon: faLinkedinIn, 36 | }, 37 | { 38 | link: 'https://angel.co/michael-d-angelo', 39 | label: 'Angel List', 40 | icon: faAngellist, 41 | }, 42 | { 43 | link: 'https://x.com/dangelosaurus', 44 | label: 'X', 45 | icon: faTwitter, 46 | }, 47 | { 48 | link: 'mailto:michael.l.dangelo@gmail.com', 49 | label: 'Email', 50 | icon: faEnvelope, 51 | }, 52 | ]; 53 | 54 | export default data; 55 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | // Optional: configure or set up a testing framework before each test. 2 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.ts` 3 | // Learn more: https://github.com/testing-library/jest-dom 4 | import '@testing-library/jest-dom'; 5 | 6 | // Mock Next.js Image component for tests 7 | jest.mock('next/image', () => ({ 8 | __esModule: true, 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | default: (props: any) => { 11 | const React = require('react'); 12 | // eslint-disable-next-line jsx-a11y/alt-text, @next/next/no-img-element 13 | return React.createElement('img', props); 14 | }, 15 | })); 16 | 17 | // Mock Next.js router 18 | jest.mock('next/navigation', () => ({ 19 | useRouter() { 20 | return { 21 | push: jest.fn(), 22 | pathname: '/', 23 | query: {}, 24 | asPath: '/', 25 | prefetch: jest.fn(), 26 | }; 27 | }, 28 | usePathname() { 29 | return '/'; 30 | }, 31 | useSearchParams() { 32 | return new URLSearchParams(); 33 | }, 34 | })); 35 | 36 | // Global test configuration 37 | global.ResizeObserver = jest.fn().mockImplementation(() => ({ 38 | observe: jest.fn(), 39 | unobserve: jest.fn(), 40 | disconnect: jest.fn(), 41 | })); 42 | 43 | // Suppress console errors in tests unless needed 44 | const originalError = console.error; 45 | beforeAll(() => { 46 | console.error = (...args: any[]) => { 47 | if ( 48 | typeof args[0] === 'string' && 49 | args[0].includes('Warning: ReactDOM.render') 50 | ) { 51 | return; 52 | } 53 | originalError.call(console, ...args); 54 | }; 55 | }); 56 | 57 | afterAll(() => { 58 | console.error = originalError; 59 | }); 60 | -------------------------------------------------------------------------------- /src/components/Template/Hamburger.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import React, { lazy, Suspense, useState } from 'react'; 5 | 6 | import routes from '../../data/routes'; 7 | 8 | // @ts-expect-error - react-burger-menu doesn't have proper TypeScript definitions for lazy loading 9 | const Menu = lazy(() => import('react-burger-menu/lib/menus/slide')); 10 | 11 | const Hamburger: React.FC = () => { 12 | const [open, setOpen] = useState(false); 13 | 14 | return ( 15 |
    16 | 33 | }> 34 | 35 |
      36 | {routes.map((l) => ( 37 |
    • 38 | setOpen(!open)}> 39 |

      40 | {l.label} 41 |

      42 | 43 |
    • 44 | ))} 45 |
    46 |
    47 |
    48 |
    49 | ); 50 | }; 51 | 52 | export default Hamburger; 53 | -------------------------------------------------------------------------------- /src/data/stats/site.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | import { StatData } from '../../components/Stats/types'; 4 | 5 | /* Keys match keys returned by the github api. Fields without keys are 6 | * mostly jokes. To see everything returned by the github api, run: 7 | curl https://api.github.com/repos/mldangelo/personal-site 8 | */ 9 | const data: StatData[] = [ 10 | { 11 | label: 'Stars this repository has on github', 12 | key: 'stargazers_count', 13 | link: 'https://github.com/mldangelo/personal-site/stargazers', 14 | }, 15 | { 16 | label: 'Number of people watching this repository', 17 | key: 'subscribers_count', 18 | link: 'https://github.com/mldangelo/personal-site/stargazers', 19 | }, 20 | { 21 | label: 'Number of forks', 22 | key: 'forks', 23 | link: 'https://github.com/mldangelo/personal-site/network', 24 | }, 25 | { 26 | label: 'Number of spoons', 27 | value: '0', 28 | }, 29 | { 30 | label: 'Number of linter warnings', 31 | value: '0', // enforced via github workflow 32 | }, 33 | { 34 | label: 'Open github issues', 35 | key: 'open_issues_count', 36 | link: 'https://github.com/mldangelo/personal-site/issues', 37 | }, 38 | { 39 | label: 'Last updated at', 40 | key: 'pushed_at', 41 | link: 'https://github.com/mldangelo/personal-site/commits', 42 | format: (x: unknown) => dayjs(x as string).format('MMMM DD, YYYY'), 43 | }, 44 | { 45 | // TODO update this with a pre-commit hook 46 | /* find . | grep ".js" | grep -vE ".min.js|node_modules|.git|.json" | 47 | xargs -I file cat file | wc -l */ 48 | label: 'Lines of TypeScript powering this website', 49 | value: '2279', 50 | link: 'https://github.com/mldangelo/personal-site/graphs/contributors', 51 | }, 52 | ]; 53 | 54 | export default data; 55 | -------------------------------------------------------------------------------- /src/static/css/main.scss: -------------------------------------------------------------------------------- 1 | @import-normalize; 2 | 3 | @import 'libs/vars'; 4 | @import 'libs/functions'; 5 | @import 'libs/mixins'; 6 | @import 'libs/skel'; 7 | 8 | // NOTE: This import was moved into index.html template 9 | // @import url('//fonts.googleapis.com/css?family=Source+Sans+Pro:400,700|Raleway:400,800,900'); 10 | 11 | /* 12 | Future Imperfect by HTML5 UP 13 | html5up.net | @ajlkn 14 | Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 15 | */ 16 | 17 | @include skel-breakpoints( 18 | ( 19 | xlarge: '(max-width: 1680px)', 20 | large: '(max-width: 1280px)', 21 | medium: '(max-width: 980px)', 22 | small: '(max-width: 736px)', 23 | xsmall: '(max-width: 480px)', 24 | ) 25 | ); 26 | 27 | @include skel-layout( 28 | ( 29 | reset: 'full', 30 | boxModel: 'border', 31 | grid: ( 32 | gutters: 1em, 33 | ), 34 | ) 35 | ); 36 | 37 | // Base. 38 | 39 | @import 'base/page'; 40 | @import 'base/typography'; 41 | 42 | // Component. 43 | 44 | @import 'components/author'; 45 | @import 'components/blurb'; 46 | @import 'components/box'; 47 | @import 'components/button'; 48 | @import 'components/form'; 49 | @import 'components/icon'; 50 | @import 'components/image'; 51 | @import 'components/list'; 52 | @import 'components/mini-post'; 53 | @import 'components/post'; 54 | @import 'components/section'; 55 | @import 'components/table'; 56 | @import 'components/hamburger'; 57 | @import 'components/markdown'; 58 | 59 | // Layout. 60 | 61 | @import 'layout/header'; 62 | @import 'layout/wrapper'; 63 | @import 'layout/main'; 64 | @import 'layout/sidebar'; 65 | @import 'layout/intro'; 66 | @import 'layout/footer'; 67 | @import 'layout/menu'; 68 | 69 | @import 'pages/contact'; 70 | @import 'pages/notFound'; 71 | @import 'pages/resume'; 72 | @import 'pages/skills'; 73 | @import 'pages/stats'; 74 | -------------------------------------------------------------------------------- /src/static/css/components/_button.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Button */ 8 | 9 | input[type='submit'], 10 | input[type='reset'], 11 | input[type='button'], 12 | button, 13 | .button { 14 | @include vendor('appearance', 'none'); 15 | @include vendor( 16 | 'transition', 17 | ( 18 | 'background-color #{_duration(transition)} ease', 19 | 'box-shadow #{_duration(transition)} ease', 20 | 'color #{_duration(transition)} ease' 21 | ) 22 | ); 23 | background-color: transparent; 24 | border: 0; 25 | box-shadow: inset 0 0 0 1px _palette(border); 26 | color: _palette(fg-bold) !important; 27 | cursor: pointer; 28 | display: inline-block; 29 | font-family: _font(family-heading); 30 | font-size: 0.6em; 31 | font-weight: _font(weight-heading-bold); 32 | height: _size(element-height) * 1.75; 33 | letter-spacing: _font(kerning-heading); 34 | line-height: _size(element-height) * 1.75; 35 | padding: 0 2.5em; 36 | text-align: center; 37 | text-decoration: none; 38 | text-transform: uppercase; 39 | white-space: nowrap; 40 | 41 | &:hover { 42 | box-shadow: inset 0 0 0 1px _palette(accent); 43 | color: _palette(accent) !important; 44 | 45 | &:active { 46 | background-color: rgba(_palette(accent), 0.05); 47 | } 48 | } 49 | 50 | &:before, 51 | &:after { 52 | color: _palette(fg-light); 53 | position: relative; 54 | } 55 | 56 | &:before { 57 | left: -1em; 58 | padding: 0 0 0 0.75em; 59 | } 60 | 61 | &:after { 62 | left: 1em; 63 | padding: 0 0.75em 0 0; 64 | } 65 | 66 | &.disabled, 67 | &:disabled { 68 | @include vendor('pointer-events', 'none'); 69 | color: _palette(border) !important; 70 | 71 | &:before { 72 | color: _palette(border) !important; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/data/projects.ts: -------------------------------------------------------------------------------- 1 | export interface Project { 2 | title: string; 3 | subtitle?: string; 4 | link?: string; 5 | image: string; 6 | date: string; 7 | desc: string; 8 | } 9 | 10 | const data: Project[] = [ 11 | { 12 | title: 'Nearest Dollar', 13 | subtitle: '2015 BVP Hackathon', 14 | image: '/images/projects/nearestdollar.jpg', 15 | date: '2015-11-20', 16 | desc: 17 | 'Built for a social impact hackathon. ' + 18 | 'NearestDollar connected to your bank accounts, credit cards, ' + 19 | 'or debit cards and rounded up your purchases to donate the balance to ' + 20 | 'the charity of your choice.', 21 | }, 22 | { 23 | title: 'Harvest', 24 | subtitle: 'Won 3rd. place in 2015 Techcrunch Disrupt SF Hackathon', 25 | link: 'https://devpost.com/software/harvest', 26 | image: '/images/projects/harvest.jpg', 27 | date: '2015-09-20', 28 | desc: 29 | 'Won ~ $7000 in prizes for an advanced, low cost monitoring solution ' + 30 | 'for crops. Harvest was designed to catch irrigation leaks, overwatering, ' + 31 | 'and nutrient deficiencies at an affordable price for the developing world.', 32 | }, 33 | { 34 | title: 'Space Potato', 35 | subtitle: 'A kickstarter funded potato powered weather balloon.', 36 | link: 'http://www.spacepotato.org', 37 | image: '/images/projects/spacepotato.jpg', 38 | date: '2015-06-28', 39 | desc: 40 | 'Launched a potato battery powered weather balloon with two cameras ' + 41 | 'and gps transponder. Resulting photos were published in a coffee table book. ' + 42 | 'You can email me for a copy.', 43 | }, 44 | { 45 | title: 'Cat Detector', 46 | subtitle: 'A convolutional neural network to classify cats! (and dogs)', 47 | image: '/images/projects/catdetector.jpg', 48 | date: '2015-05-15', 49 | desc: 50 | 'Trained a convolutional neural network to classify between ~ 80 cats breeds. ' + 51 | 'Over 60,000 cats were classified before server bills made the project too expensive ' + 52 | 'to continue hosting.', 53 | }, 54 | ]; 55 | 56 | export default data; 57 | -------------------------------------------------------------------------------- /src/components/Template/SideBar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image from 'next/image'; 4 | import Link from 'next/link'; 5 | import { usePathname } from 'next/navigation'; 6 | import React from 'react'; 7 | 8 | import ContactIcons from '../Contact/ContactIcons'; 9 | 10 | const SideBar: React.FC = () => { 11 | const pathname = usePathname(); 12 | 13 | return ( 14 | 66 | ); 67 | }; 68 | 69 | export default SideBar; 70 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | import nextJest from 'next/jest.js'; 3 | 4 | const createJestConfig = nextJest({ 5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 6 | dir: './', 7 | }); 8 | 9 | // Add any custom config to be passed to Jest 10 | const config: Config.InitialProjectOptions = { 11 | // Use SWC for transformations 12 | transform: { 13 | '^.+\\.(t|j)sx?$': [ 14 | '@swc/jest', 15 | { 16 | jsc: { 17 | parser: { 18 | syntax: 'typescript', 19 | tsx: true, 20 | decorators: true, 21 | }, 22 | transform: { 23 | react: { 24 | runtime: 'automatic', 25 | }, 26 | }, 27 | target: 'es2021', 28 | }, 29 | }, 30 | ] as [string, any], 31 | }, 32 | 33 | // Add more setup options before each test is run 34 | setupFilesAfterEnv: ['/jest.setup.ts'], 35 | 36 | // Test environment 37 | testEnvironment: 'jest-environment-jsdom', 38 | 39 | // Module name mapper for path aliases 40 | moduleNameMapper: { 41 | '^@/(.*)$': '/src/$1', 42 | '\\.(css|less|scss|sass)$': 'identity-obj-proxy', 43 | }, 44 | 45 | // Coverage configuration 46 | collectCoverageFrom: [ 47 | 'src/**/*.{ts,tsx}', 48 | 'app/**/*.{ts,tsx}', 49 | '!src/**/*.d.ts', 50 | '!src/**/__tests__/**', 51 | '!src/**/*.test.{ts,tsx}', 52 | '!src/**/*.spec.{ts,tsx}', 53 | ], 54 | 55 | // Test path ignore patterns 56 | testPathIgnorePatterns: [ 57 | '/.next/', 58 | '/node_modules/', 59 | '/build/', 60 | '/out/', 61 | ], 62 | 63 | // Module file extensions 64 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], 65 | 66 | // Transform ignore patterns - empty array for ESM support 67 | transformIgnorePatterns: [], 68 | 69 | // Roots 70 | roots: [''], 71 | 72 | // Test match patterns 73 | testMatch: ['**/__tests__/**/*.(ts|tsx|js)', '**/*.(test|spec).(ts|tsx|js)'], 74 | 75 | // Verbose output (not available in InitialProjectOptions, use CLI flag instead) 76 | }; 77 | 78 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 79 | export default createJestConfig(config); 80 | -------------------------------------------------------------------------------- /app/resume/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import React from 'react'; 3 | 4 | import Courses from '@/components/Resume/Courses'; 5 | import Education from '@/components/Resume/Education'; 6 | import Experience from '@/components/Resume/Experience'; 7 | import References from '@/components/Resume/References'; 8 | import Skills from '@/components/Resume/Skills'; 9 | import courses from '@/data/resume/courses'; 10 | import degrees from '@/data/resume/degrees'; 11 | import { categories, skills } from '@/data/resume/skills'; 12 | import work from '@/data/resume/work'; 13 | 14 | export const metadata: Metadata = { 15 | title: 'Resume', 16 | description: 17 | "Michael D'Angelo's Resume. Promptfoo, Smile ID, Arthena, Matroid, Stanford ICME, YC alum.", 18 | }; 19 | 20 | const sections = [ 21 | { name: 'Education', id: 'education' }, 22 | { name: 'Experience', id: 'experience' }, 23 | { name: 'Skills', id: 'skills' }, 24 | { name: 'Courses', id: 'courses' }, 25 | { name: 'References', id: 'references' }, 26 | ]; 27 | 28 | export default function ResumePage() { 29 | return ( 30 |
    31 |
    32 |
    33 |

    Resume

    34 |
    35 | {sections.map((section) => ( 36 |

    37 | {section.name} 38 |

    39 | ))} 40 |
    41 |
    42 |
    43 | 44 |
    45 |
    46 | 47 |
    48 | 49 |
    50 |
    51 | 52 |
    53 | 54 |
    55 |
    56 | 57 |
    58 | 59 |
    60 |
    61 | 62 |
    63 | 64 |
    65 |
    66 | 67 |
    68 |
    69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Raleway, Source_Sans_3 } from 'next/font/google'; 3 | import React from 'react'; 4 | 5 | import GoogleAnalytics from '@/components/Template/GoogleAnalytics'; 6 | import Navigation from '@/components/Template/Navigation'; 7 | import '@/static/css/main.scss'; 8 | 9 | const sourceSans = Source_Sans_3({ 10 | weight: ['400', '700'], 11 | subsets: ['latin'], 12 | variable: '--font-source-sans', 13 | display: 'swap', 14 | }); 15 | 16 | const raleway = Raleway({ 17 | weight: ['400', '800', '900'], 18 | subsets: ['latin'], 19 | variable: '--font-raleway', 20 | display: 'swap', 21 | }); 22 | 23 | export const metadata: Metadata = { 24 | title: { 25 | default: "Michael D'Angelo", 26 | template: "%s | Michael D'Angelo", 27 | }, 28 | description: 29 | 'Co-founder & CTO building LLM security tools. Previously VP Engineering, YC alum, Stanford ICME.', 30 | keywords: [ 31 | "Michael D'Angelo", 32 | 'LLM security', 33 | 'machine learning', 34 | 'CTO', 35 | 'startup founder', 36 | 'YC', 37 | ], 38 | authors: [{ name: "Michael D'Angelo" }], 39 | creator: "Michael D'Angelo", 40 | metadataBase: new URL('https://mldangelo.com'), 41 | openGraph: { 42 | type: 'website', 43 | locale: 'en_US', 44 | url: 'https://mldangelo.com', 45 | siteName: "Michael D'Angelo", 46 | title: "Michael D'Angelo", 47 | description: 48 | 'Co-founder & CTO building LLM security tools. Previously VP Engineering, YC alum, Stanford ICME.', 49 | images: [ 50 | { 51 | url: '/images/me.jpg', 52 | width: 1200, 53 | height: 630, 54 | alt: "Michael D'Angelo", 55 | }, 56 | ], 57 | }, 58 | robots: { 59 | index: true, 60 | follow: true, 61 | googleBot: { 62 | index: true, 63 | follow: true, 64 | 'max-video-preview': -1, 65 | 'max-image-preview': 'large', 66 | 'max-snippet': -1, 67 | }, 68 | }, 69 | }; 70 | 71 | export default function RootLayout({ 72 | children, 73 | }: { 74 | children: React.ReactNode; 75 | }) { 76 | return ( 77 | 78 | 79 |
    80 | 81 | {children} 82 |
    83 | 84 | 85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/static/css/layout/_menu.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Menu */ 8 | 9 | #menu { 10 | @include vendor('transform', 'translateX(#{_size(menu)})'); 11 | @include vendor( 12 | 'transition', 13 | ('transform #{_duration(menu)} ease', 'visibility #{_duration(menu)}') 14 | ); 15 | -webkit-overflow-scrolling: touch; 16 | background: _palette(bg); 17 | border-left: solid 1px _palette(border); 18 | box-shadow: none; 19 | height: 100%; 20 | max-width: 80%; 21 | overflow-y: auto; 22 | position: fixed; 23 | right: 0; 24 | top: 0; 25 | visibility: hidden; 26 | width: _size(menu); 27 | z-index: _misc(z-index-base) + 2; 28 | 29 | > * { 30 | border-top: solid 1px _palette(border); 31 | padding: _size(section-spacing); 32 | 33 | > :last-child { 34 | margin-bottom: 0; 35 | } 36 | } 37 | 38 | > :first-child { 39 | border-top: 0; 40 | } 41 | 42 | .links { 43 | list-style: none; 44 | padding: 0; 45 | 46 | > li { 47 | border: 0; 48 | border-top: dotted 1px _palette(border); 49 | margin: 1.5em 0 0 0; 50 | padding: 1.5em 0 0 0; 51 | 52 | a { 53 | display: block; 54 | border-bottom: 0; 55 | 56 | h3 { 57 | @include vendor('transition', 'color #{_duration(transition)} ease'); 58 | font-size: 0.7em; 59 | } 60 | 61 | p { 62 | font-family: _font(family-heading); 63 | font-size: 0.6em; 64 | font-weight: _font(weight-heading); 65 | letter-spacing: _font(kerning-heading); 66 | letter-spacing: _size(letter-spacing-alt); 67 | margin-bottom: 0; 68 | text-decoration: none; 69 | text-transform: uppercase; 70 | } 71 | 72 | &:hover { 73 | h3 { 74 | color: _palette(accent); 75 | } 76 | } 77 | } 78 | 79 | &:first-child { 80 | border-top: 0; 81 | margin-top: 0; 82 | padding-top: 0; 83 | } 84 | } 85 | } 86 | 87 | body.is-menu-visible & { 88 | @include vendor('transform', 'translateX(0)'); 89 | visibility: visible; 90 | } 91 | 92 | @include breakpoint(small) { 93 | > * { 94 | padding: _size(section-spacing-small); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/static/css/components/_list.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* List */ 8 | 9 | ol { 10 | list-style: decimal; 11 | margin: 0 0 _size(element-margin) 0; 12 | padding-left: 1.25em; 13 | 14 | li { 15 | padding-left: 0.25em; 16 | } 17 | } 18 | 19 | ul { 20 | list-style: disc; 21 | margin: 0 0 _size(element-margin) 0; 22 | padding-left: 1em; 23 | 24 | li { 25 | padding-left: 0.5em; 26 | } 27 | 28 | &.icons { 29 | cursor: default; 30 | list-style: none; 31 | padding-left: 0; 32 | 33 | li { 34 | display: inline-block; 35 | padding: 0 1em 0 0; 36 | 37 | &:last-child { 38 | padding-right: 0; 39 | } 40 | 41 | > * { 42 | @include icon; 43 | border: 0; 44 | 45 | .label { 46 | display: none; 47 | } 48 | } 49 | } 50 | } 51 | 52 | &.actions { 53 | cursor: default; 54 | list-style: none; 55 | padding-left: 0; 56 | 57 | li { 58 | display: inline-block; 59 | padding: 0 (_size(element-margin) * 0.75) 0 0; 60 | vertical-align: middle; 61 | 62 | &:last-child { 63 | padding-right: 0; 64 | } 65 | } 66 | 67 | @include breakpoint(xsmall) { 68 | margin: 0 0 _size(element-margin) 0; 69 | 70 | li { 71 | padding: (_size(element-margin) * 0.5) 0 0 0; 72 | display: block; 73 | text-align: center; 74 | width: 100%; 75 | 76 | &:first-child { 77 | padding-top: 0; 78 | } 79 | 80 | > * { 81 | width: 100%; 82 | margin: 0 !important; 83 | 84 | &.icon { 85 | &:before { 86 | } 87 | } 88 | } 89 | } 90 | 91 | &.small { 92 | li { 93 | padding: (_size(element-margin) * 0.25) 0 0 0; 94 | 95 | &:first-child { 96 | padding-top: 0; 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | 104 | dl { 105 | margin: 0 0 _size(element-margin) 0; 106 | 107 | dt { 108 | display: block; 109 | font-weight: _font(weight-bold); 110 | margin: 0 0 (_size(element-margin) * 0.5) 0; 111 | } 112 | 113 | dd { 114 | margin-left: _size(element-margin); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Development Commands 6 | 7 | ```bash 8 | # Install dependencies 9 | npm install 10 | 11 | # Start development server with Turbopack (http://localhost:3000) 12 | npm run dev 13 | 14 | # Run linting 15 | npm run lint 16 | 17 | # Run type checking 18 | npm run type-check 19 | 20 | # Run tests 21 | npm test 22 | 23 | # Build for production 24 | npm run build 25 | 26 | # Build and export (for deployment) 27 | npm run predeploy # This runs 'npm run build' which includes static export 28 | 29 | # Analyze bundle size 30 | npm run analyze 31 | ``` 32 | 33 | ## Architecture Overview 34 | 35 | This is a personal portfolio/resume website built with Next.js and TypeScript, designed to be easily forked and customized. 36 | 37 | ### Technology Stack 38 | - **Next.js 16** with App Router and Turbopack 39 | - **TypeScript** for type safety 40 | - **React 19** with functional components and hooks 41 | - **Biome** for linting and code formatting 42 | - **Prettier** for markdown, CSS, and SCSS formatting 43 | - **SCSS** for styling 44 | - **Jest** with React Testing Library and SWC 45 | - **Static Export** for GitHub Pages deployment 46 | - **Node 20+** runtime 47 | 48 | ### Project Structure 49 | - `/app/` - Next.js App Router pages and layouts 50 | - `/src/components/` - React components organized by feature 51 | - `/src/data/` - Static data files (resume, projects, stats) 52 | - `/src/static/` - SCSS styles 53 | - `/public/` - Static assets (images, favicons) 54 | 55 | ### Key Design Patterns 56 | 1. **App Router**: File-based routing with layouts 57 | 2. **Component Structure**: TypeScript functional components with type safety 58 | 3. **Styling**: SCSS modules with shared variables and mixins 59 | 4. **Data Management**: Static TypeScript files in `/src/data/` 60 | 5. **Performance**: Static export, lazy loading, optimized fonts 61 | 62 | ### Deployment 63 | - Static export to `/out` directory 64 | - GitHub Actions for automatic deployment to GitHub Pages 65 | - Custom domain support through CNAME file 66 | 67 | ### Important Notes 68 | - All pages must remain exactly the same as the original React site 69 | - The site uses static export (`output: 'export'`) for GitHub Pages compatibility 70 | - Client components use 'use client' directive 71 | - Google Analytics 4 is configured with NEXT_PUBLIC_GA_TRACKING_ID using @next/third-parties 72 | - Fonts are optimized using Next.js font optimization -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "personal-site", 3 | "version": "4.0.0", 4 | "homepage": "https://mldangelo.com/", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/mldangelo/personal-site.git" 9 | }, 10 | "engines": { 11 | "node": ">=20.x" 12 | }, 13 | "scripts": { 14 | "dev": "TURBOPACK=1 next dev --turbopack", 15 | "dev:webpack": "next dev", 16 | "build": "next build", 17 | "build:turbo": "TURBOPACK=1 next build --turbopack", 18 | "start": "next start", 19 | "lint": "biome check .", 20 | "type-check": "tsc --noEmit", 21 | "format": "biome format --write . && prettier --write \"**/*.{md,css,scss}\"", 22 | "format:check": "biome format . && prettier --check \"**/*.{md,css,scss}\"", 23 | "test": "jest", 24 | "test:watch": "jest --watch", 25 | "test:coverage": "jest --coverage", 26 | "predeploy": "npm run build", 27 | "analyze": "ANALYZE=true npm run build", 28 | "analyze:server": "BUNDLE_ANALYZE=server npm run analyze", 29 | "analyze:browser": "BUNDLE_ANALYZE=browser npm run analyze" 30 | }, 31 | "dependencies": { 32 | "@fortawesome/fontawesome-svg-core": "^7.1.0", 33 | "@fortawesome/free-brands-svg-icons": "^7.1.0", 34 | "@fortawesome/free-regular-svg-icons": "^7.1.0", 35 | "@fortawesome/react-fontawesome": "^3.1.1", 36 | "@next/third-parties": "^16.0.3", 37 | "dayjs": "^1.11.19", 38 | "markdown-to-jsx": "^9.3.5", 39 | "next": "^16.0.10", 40 | "react": "^19.2.0", 41 | "react-burger-menu": "^3.1.0", 42 | "react-dom": "^19.2.3" 43 | }, 44 | "devDependencies": { 45 | "@biomejs/biome": "^2.3.8", 46 | "@next/bundle-analyzer": "^16.0.10", 47 | "@swc/core": "^1.15.4", 48 | "@swc/jest": "^0.2.39", 49 | "@testing-library/jest-dom": "^6.9.1", 50 | "@testing-library/react": "^16.3.0", 51 | "@types/jest": "^30.0.0", 52 | "@types/node": "^25.0.2", 53 | "@types/react": "^19.2.7", 54 | "@types/react-burger-menu": "^2.8.7", 55 | "@types/react-dom": "^19.2.3", 56 | "identity-obj-proxy": "^3.0.0", 57 | "jest": "^30.2.0", 58 | "jest-environment-jsdom": "^30.2.0", 59 | "postcss-normalize": "^13.0.1", 60 | "prettier": "^3.7.4", 61 | "sass": "^1.96.0", 62 | "ts-node": "^10.9.2", 63 | "typescript": "^5.9.3" 64 | }, 65 | "browserslist": { 66 | "production": [ 67 | ">0.2%", 68 | "not dead", 69 | "not op_mini all" 70 | ], 71 | "development": [ 72 | "last 1 chrome version", 73 | "last 1 firefox version", 74 | "last 1 safari version" 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/data/resume/courses.ts: -------------------------------------------------------------------------------- 1 | export interface Course { 2 | title: string; 3 | number: string; 4 | link: string; 5 | university: string; 6 | } 7 | 8 | const courses: Course[] = [ 9 | { 10 | title: 'Convex Optimization', 11 | number: 'EE 364a', 12 | link: 'http://stanford.edu/class/ee364a/', 13 | university: 'Stanford', 14 | }, 15 | { 16 | title: 'Machine Learning', 17 | number: 'CS 229', 18 | link: 'http://cs229.stanford.edu/', 19 | university: 'Stanford', 20 | }, 21 | { 22 | title: 'Convolutional Neural Networks for Visual Recognition', 23 | number: 'CS 231n', 24 | link: 'http://cs231n.stanford.edu/', 25 | university: 'Stanford', 26 | }, 27 | { 28 | title: 'Numerical Linear Algebra', 29 | number: 'CME 302', 30 | link: 'http://scpd.stanford.edu/search/publicCourseSearchDetails.do;jsessionid=561188A06434D7D97953C4706DE12831?method=load&courseId=11685', 31 | university: 'Stanford', 32 | }, 33 | { 34 | title: 'Numerical Optimization', 35 | number: 'CME 304', 36 | link: 'http://web.stanford.edu/class/cme304/', 37 | university: 'Stanford', 38 | }, 39 | { 40 | title: 'Discrete Mathematics and Algorithms', 41 | number: 'CME 305', 42 | link: 'http://stanford.edu/~rezab/discrete/', 43 | university: 'Stanford', 44 | }, 45 | { 46 | title: 'Stochastic Methods in Engineering', 47 | number: 'CME 306', 48 | link: 'http://web.stanford.edu/class/cme306/', 49 | university: 'Stanford', 50 | }, 51 | { 52 | title: 'Optimization', 53 | number: 'CME 307', 54 | link: 'http://stanford.edu/class/cme307/', 55 | university: 'Stanford', 56 | }, 57 | { 58 | title: 'Stochastic Processes', 59 | number: 'CME 308', 60 | link: 'http://web.stanford.edu/class/cme308/', 61 | university: 'Stanford', 62 | }, 63 | { 64 | title: 'Randomized Algorithms and Probabilistic Analysis', 65 | number: 'CS 365', 66 | link: 'http://web.stanford.edu/class/cs365/', 67 | university: 'Stanford', 68 | }, 69 | { 70 | title: 'Deep Learning for Natural Language Processing', 71 | number: 'CS 224d', 72 | link: 'http://cs224d.stanford.edu', 73 | university: 'Stanford', 74 | }, 75 | { 76 | title: 'Mining Massive Data Sets', 77 | number: 'CS 246', 78 | link: 'http://web.stanford.edu/class/cs246/', 79 | university: 'Stanford', 80 | }, 81 | { 82 | title: 'Computer Vision: Foundations and Applications', 83 | number: 'CS 131', 84 | link: 'http://vision.stanford.edu/teaching/cs131_fall1415/index.html', 85 | university: 'Stanford', 86 | }, 87 | ]; 88 | 89 | export default courses; 90 | -------------------------------------------------------------------------------- /docs/design-goals.md: -------------------------------------------------------------------------------- 1 | # Design Goals 2 | 3 | This projects attempts to follow these design principles. Feedback and discussion around these are encouraged. Please feel free to submit an issue or get in touch. 4 | 5 | ## Simple 6 | 7 | 1. Someone learning web development should be able to clone this repo and start making it their own within a few minutes. 8 | 2. Does not require reading a large amount of documentation. 9 | 10 | ## Fast 11 | 12 | 1. Follows [JAMStack best practices](https://jamstack.org/best-practices/). Everything that can be pre-rendered should be pre-rendered. 13 | 1. Time to interact should be very fast (< 250 ms). Optimized for small bundle sizes. 14 | 15 | ## Good Developer Experience 16 | 17 | 1. Modular 18 | - It should be relatively straight forward to replace the content in this repository or to add a new feature. 19 | - Good separation of concerns. Components keep track of their own state. Props are not over-utilized. 20 | - Limited vertical depth (changes should be relatively self encapsulated). 21 | - Correct abstractions. - Next.js build system is complex, but developers don't need to understand the internals to use this project. 22 | 1. Good Documentation 23 | - Comments exist and have an appropriate level of detail. 24 | - Code should be readable. 25 | 1. Lean 26 | - Projects bloat over time. Actively prune for old and dead code. 27 | - New features that affect the entire project should be carefully considered. 28 | - Buy, don't build. Don't reinvent the wheel. Use popular npm libraries when possible. 29 | 1. Limited horizontal fragmentation 30 | - Linter to prevent easy PR nits & to prevent developers from wasting time thinking about code style. 31 | - Preferred React Style - functional components with TypeScript for type safety. 32 | - Consistent file structure based on current best practices. 33 | - Similar features are built similarly. Code reads like an assembly line, not a layer cake. 34 | 35 | ## Stable 36 | 37 | 1. Use _Boring_ technologies 38 | - TypeScript for type safety while maintaining readability. Limited experimental features. 39 | - Prefer popular and well maintained npm packages. 40 | 1. Maintainable 41 | - Easy setup. 42 | - It should be easy to deploy any version of this site. 43 | - Limited external dependencies (ie no missing headers for external libraries). 44 | - Dependencies are kept up to date (currently uses dependabot). 45 | 1. Good tests. 46 | 1. Stable API - This project has been forked > 100 times. It should be easy for those forks adopt changes in main. 47 | 48 | ## References 49 | 50 | For further reading, please review 51 | 52 | - React's [Design Principles](https://react.dev/learn/thinking-in-react). 53 | -------------------------------------------------------------------------------- /src/static/css/components/_mini-post.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Mini Post */ 8 | .cell-container { 9 | position: relative; 10 | 11 | .description { 12 | position: absolute; 13 | padding: 0; 14 | bottom: 0; 15 | border-top: solid 1px _palette(border); 16 | 17 | p { 18 | font-size: 0.9rem; 19 | margin: 0; 20 | padding: 1.25em; 21 | background-color: rgba(_palette(bg), 0.7); 22 | color: _palette(fg-bold); 23 | font-family: 'Raleway', Helvetica, sans-serif; 24 | z-index: 1000; 25 | transition: all 0.1s ease-in-out; 26 | } 27 | 28 | p:hover { 29 | background-color: rgba(_palette(bg), 0.9); 30 | } 31 | } 32 | 33 | @media (max-width: 480px) { 34 | .description { 35 | position: inherit; 36 | padding: 0; 37 | bottom: inherit; 38 | } 39 | } 40 | } 41 | 42 | .mini-post { 43 | @include vendor('display', 'flex'); 44 | @include vendor('flex-direction', 'column'); 45 | background: _palette(bg); 46 | border: solid 1px _palette(border); 47 | margin: 0 0 _size(element-margin); 48 | 49 | .image { 50 | overflow: hidden; 51 | width: 100%; 52 | 53 | img { 54 | @include vendor('transition', 'transform #{_duration(transition)} ease-out'); 55 | width: 100%; 56 | object-fit: cover; 57 | } 58 | 59 | &:hover { 60 | img { 61 | @include vendor('transform', 'scale(1.05)'); 62 | } 63 | } 64 | } 65 | 66 | header { 67 | z-index: 2; 68 | @include padding(1.25em, 1.25em, (0, 3em, 0, 0)); 69 | min-height: 4em; 70 | position: relative; 71 | @include vendor('flex-grow', '1'); 72 | 73 | h3 { 74 | font-size: 0.7em; 75 | } 76 | 77 | .published { 78 | display: block; 79 | font-family: _font(family-heading); 80 | font-size: 0.6em; 81 | font-weight: _font(weight-heading); 82 | letter-spacing: _font(kerning-heading); 83 | margin: -0.625em 0 (_size(element-margin) * 0.85) 0; 84 | text-transform: uppercase; 85 | } 86 | } 87 | } 88 | 89 | .mini-posts { 90 | margin: 0 0 _size(element-margin); 91 | @include breakpoint(large) { 92 | @include vendor('display', 'flex'); 93 | @include vendor('flex-wrap', 'wrap'); 94 | width: calc(100% + #{_size(element-margin)}); 95 | 96 | > * { 97 | margin: _size(element-margin) _size(element-margin) 0 0; 98 | width: calc(50% - #{_size(element-margin)}); 99 | } 100 | 101 | > :nth-child(-n + 2) { 102 | margin-top: 0; 103 | } 104 | } 105 | @include breakpoint(xsmall) { 106 | display: block; 107 | width: 100%; 108 | 109 | > * { 110 | margin: 0 0 _size(element-margin); 111 | width: 100%; 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/static/css/components/_form.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Form */ 8 | 9 | form { 10 | margin: 0 0 _size(element-margin) 0; 11 | } 12 | 13 | label { 14 | color: _palette(fg-bold); 15 | display: block; 16 | font-size: 0.9em; 17 | font-weight: _font(weight-bold); 18 | margin: 0 0 (_size(element-margin) * 0.5) 0; 19 | } 20 | 21 | input[type='text'], 22 | input[type='password'], 23 | input[type='email'], 24 | input[type='tel'], 25 | select, 26 | textarea { 27 | @include vendor('appearance', 'none'); 28 | background: _palette(border-bg); 29 | border: none; 30 | border: solid 1px _palette(border); 31 | border-radius: 0; 32 | color: inherit; 33 | display: block; 34 | outline: 0; 35 | padding: 0 1em; 36 | text-decoration: none; 37 | width: 100%; 38 | 39 | &:invalid { 40 | box-shadow: none; 41 | } 42 | 43 | &:focus { 44 | border-color: _palette(accent); 45 | box-shadow: inset 0 0 0 1px _palette(accent); 46 | } 47 | } 48 | 49 | input[type='text'], 50 | input[type='password'], 51 | input[type='email'], 52 | select { 53 | height: _size(element-height); 54 | } 55 | 56 | textarea { 57 | padding: 0.75em 1em; 58 | } 59 | 60 | input[type='checkbox'], 61 | input[type='radio'] { 62 | @include vendor('appearance', 'none'); 63 | display: block; 64 | float: left; 65 | margin-right: -2em; 66 | opacity: 0; 67 | width: 1em; 68 | z-index: -1; 69 | 70 | & + label { 71 | @include icon; 72 | color: _palette(fg); 73 | cursor: pointer; 74 | display: inline-block; 75 | font-size: 1em; 76 | font-weight: _font(weight); 77 | padding-left: (_size(element-height) * 0.6) + 0.75em; 78 | padding-right: 0.75em; 79 | position: relative; 80 | 81 | &:before { 82 | background: _palette(border-bg); 83 | border: solid 1px _palette(border); 84 | content: ''; 85 | display: inline-block; 86 | height: (_size(element-height) * 0.6); 87 | left: 0; 88 | line-height: (_size(element-height) * 0.575); 89 | position: absolute; 90 | text-align: center; 91 | top: 0; 92 | width: (_size(element-height) * 0.6); 93 | } 94 | } 95 | 96 | &:checked + label { 97 | &:before { 98 | background: _palette(fg-bold); 99 | border-color: _palette(fg-bold); 100 | color: _palette(bg); 101 | content: '\f00c'; 102 | } 103 | } 104 | 105 | &:focus + label { 106 | &:before { 107 | border-color: _palette(accent); 108 | box-shadow: 0 0 0 1px _palette(accent); 109 | } 110 | } 111 | } 112 | 113 | input[type='radio'] { 114 | & + label { 115 | &:before { 116 | border-radius: 100%; 117 | } 118 | } 119 | } 120 | 121 | ::placeholder { 122 | color: _palette(fg-light) !important; 123 | opacity: 1; 124 | } 125 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.3.7/schema.json", 3 | "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, 4 | "files": { "ignoreUnknown": false }, 5 | "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": false, 10 | "complexity": { "noUselessTypeConstraint": "error" }, 11 | "correctness": { "noUnusedVariables": "warn" }, 12 | "performance": { 13 | "noImgElement": "warn", 14 | "noUnwantedPolyfillio": "warn", 15 | "useGoogleFontPreconnect": "warn" 16 | }, 17 | "style": { 18 | "noCommonJs": "error", 19 | "noHeadElement": "warn", 20 | "noNamespace": "error", 21 | "useArrayLiterals": "error", 22 | "useAsConstAssertion": "error", 23 | "useBlockStatements": "off" 24 | }, 25 | "suspicious": { 26 | "noDocumentImportInPage": "error", 27 | "noExplicitAny": "error", 28 | "noExtraNonNullAssertion": "error", 29 | "noHeadImportInDocument": "error", 30 | "noMisleadingInstantiator": "error", 31 | "noNonNullAssertedOptionalChain": "error", 32 | "noUnsafeDeclarationMerging": "error", 33 | "useGoogleFontDisplay": "warn", 34 | "useNamespaceKeyword": "error" 35 | } 36 | } 37 | }, 38 | "javascript": { "formatter": { "quoteStyle": "single" } }, 39 | "overrides": [ 40 | { 41 | "includes": ["*.ts", "*.tsx", "*.mts", "*.cts"], 42 | "linter": { 43 | "rules": { 44 | "complexity": { "noArguments": "error" }, 45 | "correctness": { 46 | "noConstAssign": "off", 47 | "noGlobalObjectCalls": "off", 48 | "noInvalidBuiltinInstantiation": "off", 49 | "noInvalidConstructorSuper": "off", 50 | "noSetterReturn": "off", 51 | "noUndeclaredVariables": "off", 52 | "noUnreachable": "off", 53 | "noUnreachableSuper": "off" 54 | }, 55 | "style": { "useConst": "error" }, 56 | "suspicious": { 57 | "noClassAssign": "off", 58 | "noDuplicateClassMembers": "off", 59 | "noDuplicateObjectKeys": "off", 60 | "noDuplicateParameters": "off", 61 | "noFunctionAssign": "off", 62 | "noImportAssign": "off", 63 | "noRedeclare": "off", 64 | "noUnsafeNegation": "off", 65 | "noVar": "error", 66 | "noWith": "off", 67 | "useGetterReturn": "off" 68 | } 69 | } 70 | } 71 | }, 72 | { 73 | "includes": ["*.config.ts", "*.config.js", "jest.setup.ts"], 74 | "linter": { 75 | "rules": { 76 | "style": { "noCommonJs": "off" }, 77 | "suspicious": { "noExplicitAny": "off" } 78 | } 79 | } 80 | } 81 | ], 82 | "assist": { 83 | "enabled": true, 84 | "actions": { "source": { "organizeImports": "on" } } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /.github/workflows/github-pages.yml: -------------------------------------------------------------------------------- 1 | # Deploy to GitHub Pages 2 | name: Deploy to GitHub Pages 3 | 4 | on: 5 | # Runs on pushes targeting the main branch 6 | push: 7 | branches: ["main"] 8 | # Allows manual deployment from Actions tab 9 | workflow_dispatch: 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow only one concurrent deployment 18 | concurrency: 19 | group: "pages" 20 | cancel-in-progress: false 21 | 22 | jobs: 23 | # Build job 24 | build: 25 | name: Build 26 | runs-on: ubuntu-latest 27 | timeout-minutes: 10 28 | environment: github-pages 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 # Fetch all history for proper git info 34 | 35 | - name: Setup Node.js 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version-file: ".nvmrc" 39 | cache: "npm" 40 | 41 | - name: Setup Pages 42 | uses: actions/configure-pages@v5 43 | with: 44 | # Automatically inject basePath in Next.js config 45 | static_site_generator: next 46 | 47 | - name: Cache dependencies 48 | uses: actions/cache@v4 49 | with: 50 | path: | 51 | ~/.npm 52 | node_modules 53 | .next/cache 54 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} 55 | restore-keys: | 56 | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- 57 | 58 | - name: Install dependencies 59 | run: npm ci 60 | 61 | - name: Build with Next.js 62 | run: npm run build 63 | env: 64 | NODE_ENV: production 65 | # Use environment variable for GA tracking ID 66 | NEXT_PUBLIC_GA_TRACKING_ID: ${{ vars.NEXT_PUBLIC_GA_TRACKING_ID }} 67 | 68 | - name: Verify build output 69 | run: | 70 | if [ ! -d "out" ]; then 71 | echo "Error: 'out' directory not found. Static export failed." 72 | exit 1 73 | fi 74 | echo "Build successful. Generated $(find out -type f | wc -l) files." 75 | du -sh out/ 76 | 77 | - name: Ensure .nojekyll exists in output 78 | run: | 79 | if [ ! -f "out/.nojekyll" ]; then 80 | cp public/.nojekyll out/.nojekyll || touch out/.nojekyll 81 | fi 82 | 83 | - name: Upload artifact 84 | uses: actions/upload-pages-artifact@v3 85 | with: 86 | path: ./out 87 | 88 | # Deployment job 89 | deploy: 90 | name: Deploy 91 | environment: 92 | name: github-pages 93 | url: ${{ steps.deployment.outputs.page_url }} 94 | runs-on: ubuntu-latest 95 | needs: build 96 | timeout-minutes: 10 97 | steps: 98 | - name: Deploy to GitHub Pages 99 | id: deployment 100 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /docs/roadmap.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | This site has been a work in progress since 2014. I have tried to make updates to reflect a) my knowledge of web development and b) current best practices. It will continue to evolve a as a WIP guided by the following design goals. 4 | 5 | ## Recently Completed ✅ 6 | 7 | - **Migrated to TypeScript** - The entire codebase now uses TypeScript for better type safety and developer experience 8 | - **Upgraded to Next.js 15.4.1** - Using the latest version with App Router 9 | - **Upgraded to React 19** - Latest React version with improved performance 10 | - **Implemented modern Google Analytics 4** - Using @next/third-parties for optimal performance 11 | - **Added Prettier** - Consistent code formatting across the project 12 | - **Enabled Turbopack** - Faster development builds 13 | - **Migrated from SCSS `@import` to `@use`** - Addressed all deprecation warnings 14 | - **Added Jest testing with SWC** - 20x faster test execution with TypeScript support 15 | - **Implemented SEO features** - Sitemap generation, Open Graph tags, proper metadata 16 | - **Fixed Resume page styling** - Restored original design consistency 17 | - **Optimized images** - Using Next.js Image component for better performance 18 | 19 | ## Future Direction 20 | 21 | ### Improvements 22 | 23 | - use JSON resume instead of reinventing the wheel (perform literature search for wiki data resume standard). 24 | - Fix navbar (use nav provided by template) -> Reduce Bundle size. 25 | - Separate concerns better in src/data. Some files are data, others are template variables. 26 | - Get better at redefining duplicate types. They are especially prevalent in resume components. 27 | - Make code splitting better - some bundles are under 1KB. 28 | - Make styles more modular. 29 | - Make FA integration less terrible (consider building FA library). 30 | - Simplify Favicon. See: https://news.ycombinator.com/item?id=25520655 31 | - Better tests 32 | - Add more comprehensive component tests 33 | - test using playwright. 34 | - test cross browser compatibility. 35 | - Use google lighthouse. 36 | - Introduce a spell checker. 37 | 38 | ### New Features 39 | 40 | - Completely gut and redo server integration, use JWT 41 | - auto deploy backend, keep frontend on CDN. 42 | - revisit posts/blog 43 | - put one or two examples up from my knowledge base. 44 | 45 | ### Repository Cleanup 46 | 47 | - Don't allow pushes to main. 48 | - Generate releases using github action (increment version in package.json too) using semantic versioning. 49 | - Add contributing guidelines. 50 | - encourage more PRs that support this roadmap / pay bug bounties. 51 | - Build something that allows people to propose changes. 52 | - Make main / server distinction cleaner -> make sure PRs to main also land in server. 53 | 54 | - Implement better analytics 55 | - Capture information about the community of people that have cloned this site. 56 | 57 | ### Under Consideration 58 | 59 | - Add support for more exotic integrations (reason, webassembly). 60 | - hydrate all unique content on the site from one location -> deploy as npm package + json. 61 | - Use husky for git pre-commit hooks. 62 | - Consider migrating to Tailwind CSS for more maintainable styles -------------------------------------------------------------------------------- /src/static/css/pages/_skills.scss: -------------------------------------------------------------------------------- 1 | /* Skills */ 2 | 3 | .skills .title { 4 | text-align: center; 5 | } 6 | 7 | .skill-button-container { 8 | margin: 1.5em 0 2.5em 0; 9 | text-align: center; 10 | display: flex; 11 | flex-wrap: wrap; 12 | justify-content: center; 13 | gap: 0.5em; 14 | 15 | @include breakpoint(small) { 16 | gap: 0.35em; 17 | } 18 | } 19 | 20 | .skillbutton { 21 | display: inline-block; 22 | height: 2.75em; 23 | line-height: 2.75em; 24 | padding: 0 1.5em; 25 | border: 0; 26 | border-radius: 0; 27 | background: _palette(bg); 28 | color: _palette(fg); 29 | font-family: _font(family-heading); 30 | font-size: 0.6em; 31 | font-weight: _font(weight-heading); 32 | letter-spacing: _font(kerning-heading); 33 | text-transform: uppercase; 34 | cursor: pointer; 35 | box-shadow: inset 0 0 0 1px _palette(border); 36 | transition: all _duration(transition) ease; 37 | outline: 0; 38 | 39 | &:hover { 40 | color: _palette(accent); 41 | box-shadow: inset 0 0 0 1px _palette(accent); 42 | } 43 | 44 | &:focus { 45 | outline: 0; 46 | box-shadow: inset 0 0 0 1px _palette(accent); 47 | } 48 | } 49 | 50 | .skillbutton-active { 51 | background: _palette(accent); 52 | color: _palette(bg) !important; 53 | box-shadow: none !important; 54 | 55 | &:hover { 56 | opacity: 0.9; 57 | } 58 | } 59 | 60 | .skillbar { 61 | position: relative; 62 | display: block; 63 | margin-bottom: _size(element-margin) * 0.75; 64 | width: 100%; 65 | background: _palette(border-bg); 66 | height: 2.5em; 67 | border-radius: 0; 68 | overflow: hidden; 69 | transition: opacity _duration(transition) ease; 70 | 71 | @include breakpoint(small) { 72 | height: 2.25em; 73 | margin-bottom: _size(element-margin) * 0.5; 74 | } 75 | 76 | &:hover { 77 | opacity: 0.85; 78 | } 79 | } 80 | 81 | .skillbar-title { 82 | position: absolute; 83 | top: 0; 84 | left: 0; 85 | font-weight: _font(weight-bold); 86 | font-size: 0.85em; 87 | color: _palette(bg); 88 | background: _palette(accent); 89 | height: 100%; 90 | min-width: 110px; 91 | z-index: 2; 92 | 93 | @include breakpoint(small) { 94 | font-size: 0.8em; 95 | min-width: 100px; 96 | } 97 | } 98 | 99 | .skillbar-title span { 100 | display: flex; 101 | align-items: center; 102 | background: rgba(0, 0, 0, 0.15); 103 | padding: 0 1em; 104 | height: 100%; 105 | white-space: nowrap; 106 | 107 | @include breakpoint(small) { 108 | padding: 0 0.75em; 109 | } 110 | } 111 | 112 | .skillbar-bar { 113 | height: 100%; 114 | width: 0px; 115 | background: _palette(accent); 116 | position: relative; 117 | transition: width 1.4s ease-in-out; 118 | } 119 | 120 | .skill-bar-percent { 121 | position: absolute; 122 | right: 1em; 123 | top: 0; 124 | font-family: _font(family-heading); 125 | font-size: 0.7em; 126 | font-weight: _font(weight-heading); 127 | letter-spacing: 0.05em; 128 | height: 100%; 129 | display: flex; 130 | align-items: center; 131 | color: _palette(fg-light); 132 | z-index: 1; 133 | 134 | @include breakpoint(small) { 135 | font-size: 0.65em; 136 | right: 0.75em; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/components/Resume/Skills.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useReducer } from 'react'; 4 | 5 | import type { Category, Skill } from '@/data/resume/skills'; 6 | 7 | import CategoryButton from './Skills/CategoryButton'; 8 | import SkillBar from './Skills/SkillBar'; 9 | 10 | interface SkillsProps { 11 | skills: Skill[]; 12 | categories: Category[]; 13 | } 14 | 15 | // React 19: Using useReducer for complex filter state management 16 | type ButtonState = Record; 17 | 18 | type ButtonAction = { 19 | type: 'TOGGLE_CATEGORY'; 20 | label: string; 21 | }; 22 | 23 | const buttonReducer = ( 24 | state: ButtonState, 25 | action: ButtonAction, 26 | ): ButtonState => { 27 | switch (action.type) { 28 | case 'TOGGLE_CATEGORY': { 29 | // Toggle button that was clicked. Turn all other buttons off. 30 | const newButtons = Object.keys(state).reduce( 31 | (obj, key) => ({ 32 | ...obj, 33 | [key]: action.label === key && !state[key], 34 | }), 35 | {} as ButtonState, 36 | ); 37 | // Turn on 'All' button if other buttons are off 38 | newButtons.All = !Object.keys(state).some((key) => newButtons[key]); 39 | return newButtons; 40 | } 41 | default: 42 | return state; 43 | } 44 | }; 45 | 46 | const Skills: React.FC = ({ skills, categories }) => { 47 | const initialButtons = Object.fromEntries( 48 | [['All', false]].concat(categories.map(({ name }) => [name, false])), 49 | ); 50 | 51 | const [buttons, dispatch] = useReducer(buttonReducer, initialButtons); 52 | 53 | const handleChildClick = (label: string) => { 54 | dispatch({ type: 'TOGGLE_CATEGORY', label }); 55 | }; 56 | 57 | const getButtons = () => 58 | Object.keys(buttons).map((key) => ( 59 | 65 | )); 66 | 67 | const getRows = () => { 68 | // search for true active categories 69 | const actCat = Object.keys(buttons).reduce( 70 | (cat, key) => (buttons[key] ? key : cat), 71 | 'All', 72 | ); 73 | 74 | const comparator = (a: Skill, b: Skill) => { 75 | let ret = 0; 76 | if (a.competency > b.competency) ret = -1; 77 | else if (a.competency < b.competency) ret = 1; 78 | else if (a.category[0] > b.category[0]) ret = -1; 79 | else if (a.category[0] < b.category[0]) ret = 1; 80 | else if (a.title > b.title) ret = 1; 81 | else if (a.title < b.title) ret = -1; 82 | return ret; 83 | }; 84 | 85 | return skills 86 | .sort(comparator) 87 | .filter((skill) => actCat === 'All' || skill.category.includes(actCat)) 88 | .map((skill) => ( 89 | 90 | )); 91 | }; 92 | 93 | return ( 94 |
    95 |
    96 |
    97 |

    Skills

    98 |
    99 |
    {getButtons()}
    100 |
    {getRows()}
    101 |
    102 | ); 103 | }; 104 | 105 | export default Skills; 106 | -------------------------------------------------------------------------------- /src/static/css/pages/_resume.scss: -------------------------------------------------------------------------------- 1 | /* Resume */ 2 | 3 | // The resume page uses the .post wrapper for consistent styling 4 | #resume { 5 | // Navigation section inside header 6 | .link-container { 7 | padding-top: 1em; 8 | 9 | h4 { 10 | text-decoration: none; 11 | border-bottom: none; 12 | display: inline-flex; 13 | 14 | a { 15 | padding: 0 0.5em; 16 | margin: 0; 17 | } 18 | } 19 | } 20 | 21 | // Common section styles 22 | .education, 23 | .experience, 24 | .skills, 25 | .courses, 26 | .references { 27 | .title { 28 | text-align: center; 29 | margin-bottom: 2em; 30 | } 31 | } 32 | 33 | // Experience section 34 | .experience { 35 | .title { 36 | text-align: center; 37 | } 38 | } 39 | 40 | .jobs-container { 41 | margin-bottom: 2em; 42 | 43 | .points { 44 | li { 45 | margin: 0; 46 | padding: 0; 47 | font-size: 0.9em; 48 | } 49 | } 50 | } 51 | 52 | // Courses section 53 | .courses { 54 | padding-top: 1.6em; 55 | 56 | .title { 57 | text-align: center; 58 | } 59 | 60 | .course-list { 61 | text-align: center; 62 | 63 | h4 { 64 | white-space: nowrap; 65 | } 66 | } 67 | } 68 | 69 | // Skills section 70 | .skills { 71 | margin-top: 2em; 72 | 73 | .title { 74 | text-align: center; 75 | 76 | p { 77 | font-size: 0.9em; 78 | } 79 | } 80 | } 81 | 82 | // Summary text in experience 83 | .summary { 84 | margin-bottom: 0.5em; 85 | font-size: 0.9em; 86 | 87 | // Override any code block styling from markdown 88 | code, 89 | pre { 90 | background: none; 91 | border: none; 92 | padding: 0; 93 | margin: 0; 94 | font-family: inherit; 95 | font-size: inherit; 96 | } 97 | 98 | pre code { 99 | display: inline; 100 | line-height: inherit; 101 | } 102 | } 103 | 104 | // References section 105 | .references { 106 | margin: 2em 0; 107 | 108 | .title { 109 | text-align: center; 110 | } 111 | } 112 | 113 | // Anchor links 114 | .link-to { 115 | position: relative; 116 | top: -4.5em; 117 | } 118 | } 119 | 120 | // Date ranges 121 | .daterange { 122 | margin-bottom: 0.2em; 123 | } 124 | 125 | // Education degrees 126 | .degree-container { 127 | h4 { 128 | font-weight: normal; 129 | } 130 | 131 | .degree { 132 | margin-bottom: 0.1em; 133 | text-transform: none; 134 | letter-spacing: 0.16em; 135 | font-size: 0.8em; 136 | } 137 | 138 | .school { 139 | text-transform: none; 140 | padding-top: 0.3em; 141 | margin-bottom: 1em; 142 | } 143 | } 144 | 145 | // Course styling 146 | .courses { 147 | .course-number { 148 | display: inline; 149 | } 150 | 151 | .course-name { 152 | display: inline; 153 | font-family: _font(family-heading); 154 | font-size: 0.7em; 155 | font-weight: 400; 156 | letter-spacing: 0.25em; 157 | line-height: 2.5; 158 | margin-top: -1em; 159 | text-transform: uppercase; 160 | } 161 | 162 | ul li { 163 | display: inline; 164 | } 165 | } 166 | 167 | .course-dot { 168 | display: inline; 169 | } 170 | 171 | // Responsive 172 | @include breakpoint(small) { 173 | .course-dot { 174 | display: none; 175 | } 176 | 177 | .course-container a { 178 | display: block; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/data/resume/skills.ts: -------------------------------------------------------------------------------- 1 | export interface Skill { 2 | title: string; 3 | competency: number; 4 | category: string[]; 5 | } 6 | 7 | export interface Category { 8 | name: string; 9 | color: string; 10 | } 11 | 12 | const skills: Skill[] = [ 13 | // Languages 14 | { 15 | title: 'Python', 16 | competency: 5, 17 | category: ['Languages', 'ML Engineering'], 18 | }, 19 | { 20 | title: 'TypeScript', 21 | competency: 5, 22 | category: ['Languages', 'Web Development'], 23 | }, 24 | { 25 | title: 'SQL', 26 | competency: 4, 27 | category: ['Languages', 'Databases'], 28 | }, 29 | // AI & LLM 30 | { 31 | title: 'AI Agents', 32 | competency: 5, 33 | category: ['ML Engineering'], 34 | }, 35 | { 36 | title: 'LLM Evaluation', 37 | competency: 5, 38 | category: ['ML Engineering'], 39 | }, 40 | { 41 | title: 'AI Red-teaming', 42 | competency: 5, 43 | category: ['ML Engineering'], 44 | }, 45 | { 46 | title: 'LLM APIs', 47 | competency: 5, 48 | category: ['ML Engineering'], 49 | }, 50 | { 51 | title: 'RAG', 52 | competency: 4, 53 | category: ['ML Engineering'], 54 | }, 55 | { 56 | title: 'Prompt Engineering', 57 | competency: 4, 58 | category: ['ML Engineering'], 59 | }, 60 | { 61 | title: 'Vector Databases', 62 | competency: 4, 63 | category: ['ML Engineering', 'Databases'], 64 | }, 65 | { 66 | title: 'PyTorch', 67 | competency: 4, 68 | category: ['ML Engineering'], 69 | }, 70 | { 71 | title: 'Pandas', 72 | competency: 5, 73 | category: ['ML Engineering', 'Data Engineering'], 74 | }, 75 | // Web Development 76 | { 77 | title: 'Node.js', 78 | competency: 5, 79 | category: ['Web Development'], 80 | }, 81 | { 82 | title: 'FastAPI', 83 | competency: 4, 84 | category: ['Web Development'], 85 | }, 86 | { 87 | title: 'Next.js', 88 | competency: 3, 89 | category: ['Web Development'], 90 | }, 91 | // Databases 92 | { 93 | title: 'PostgreSQL', 94 | competency: 4, 95 | category: ['Databases'], 96 | }, 97 | { 98 | title: 'Redis', 99 | competency: 3, 100 | category: ['Databases'], 101 | }, 102 | // Infrastructure 103 | { 104 | title: 'AWS', 105 | competency: 4, 106 | category: ['Infrastructure'], 107 | }, 108 | { 109 | title: 'Docker', 110 | competency: 4, 111 | category: ['Infrastructure'], 112 | }, 113 | { 114 | title: 'Kubernetes', 115 | competency: 3, 116 | category: ['Infrastructure'], 117 | }, 118 | { 119 | title: 'Observability', 120 | competency: 4, 121 | category: ['Infrastructure', 'ML Engineering'], 122 | }, 123 | ].map((skill) => ({ ...skill, category: skill.category.sort() })); 124 | 125 | // this is a list of colors that I like. The length should be === to the 126 | // number of categories. Re-arrange this list until you find a pattern you like. 127 | const colors: string[] = [ 128 | '#6968b3', 129 | '#37b1f5', 130 | '#40494e', 131 | '#515dd4', 132 | '#e47272', 133 | '#cc7b94', 134 | '#3896e2', 135 | '#c3423f', 136 | '#d75858', 137 | '#747fff', 138 | '#64cb7b', 139 | ]; 140 | 141 | const categories: Category[] = Array.from( 142 | new Set(skills.flatMap(({ category }) => category)), 143 | ) 144 | .sort() 145 | .map((category, index) => ({ 146 | name: category, 147 | color: colors[index], 148 | })); 149 | 150 | export { categories, skills }; 151 | -------------------------------------------------------------------------------- /src/static/css/base/_typography.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Type */ 8 | 9 | body, 10 | input, 11 | select, 12 | textarea { 13 | color: _palette(fg); 14 | font-family: _font(family); 15 | font-size: 14pt; 16 | font-weight: _font(weight); 17 | line-height: 1.75; 18 | 19 | @include breakpoint(xlarge) { 20 | font-size: 12pt; 21 | } 22 | 23 | @include breakpoint(large) { 24 | font-size: 12pt; 25 | } 26 | 27 | @include breakpoint(medium) { 28 | font-size: 12pt; 29 | } 30 | 31 | @include breakpoint(small) { 32 | font-size: 12pt; 33 | } 34 | 35 | @include breakpoint(xsmall) { 36 | font-size: 12pt; 37 | } 38 | } 39 | 40 | a { 41 | @include vendor( 42 | 'transition', 43 | ('color #{_duration(transition)} ease', 'border-bottom-color #{_duration(transition)} ease') 44 | ); 45 | border-bottom: dotted 1px _palette(border-alt); 46 | color: inherit; 47 | text-decoration: none; 48 | 49 | &:before { 50 | @include vendor('transition', ('color #{_duration(transition)} ease')); 51 | } 52 | 53 | &:hover { 54 | border-bottom-color: transparent; 55 | color: _palette(accent) !important; 56 | 57 | &:before { 58 | color: _palette(accent) !important; 59 | } 60 | } 61 | } 62 | 63 | strong, 64 | b { 65 | color: _palette(fg-bold); 66 | font-weight: _font(weight-bold); 67 | } 68 | 69 | em, 70 | i { 71 | font-style: italic; 72 | } 73 | 74 | p { 75 | margin: 0 0 _size(element-margin) 0; 76 | } 77 | 78 | h1, 79 | h2, 80 | h3, 81 | h4, 82 | h5, 83 | h6 { 84 | color: _palette(fg-bold); 85 | font-family: _font(family-heading); 86 | font-weight: _font(weight-heading-bold); 87 | letter-spacing: _font(kerning-heading); 88 | line-height: 1.65; 89 | margin: 0 0 (_size(element-margin) * 0.5) 0; 90 | text-transform: uppercase; 91 | 92 | a { 93 | color: inherit; 94 | border-bottom: 0; 95 | } 96 | } 97 | 98 | h2 { 99 | font-size: 1.1em; 100 | } 101 | 102 | h3 { 103 | font-size: 0.9em; 104 | } 105 | 106 | h4 { 107 | font-size: 0.7em; 108 | } 109 | 110 | h5 { 111 | font-size: 0.7em; 112 | } 113 | 114 | h6 { 115 | font-size: 0.7em; 116 | } 117 | 118 | sub { 119 | font-size: 0.8em; 120 | position: relative; 121 | top: 0.5em; 122 | } 123 | 124 | sup { 125 | font-size: 0.8em; 126 | position: relative; 127 | top: -0.5em; 128 | } 129 | 130 | blockquote { 131 | border-left: solid 4px _palette(border); 132 | font-style: italic; 133 | margin: 0 0 _size(element-margin) 0; 134 | padding: calc(_size(element-margin) / 4) 0 calc(_size(element-margin) / 4) _size(element-margin); 135 | } 136 | 137 | code { 138 | background: _palette(border-bg); 139 | border: solid 1px _palette(border); 140 | font-family: _font(family-fixed); 141 | font-size: 0.9em; 142 | margin: 0 0.25em; 143 | padding: 0.25em 0.65em; 144 | } 145 | 146 | pre { 147 | -webkit-overflow-scrolling: touch; 148 | font-family: _font(family-fixed); 149 | font-size: 0.9em; 150 | margin: 0 0 _size(element-margin) 0; 151 | 152 | code { 153 | display: block; 154 | line-height: 1.75em; 155 | padding: 1em 1.5em; 156 | overflow-x: auto; 157 | } 158 | } 159 | 160 | hr { 161 | border: 0; 162 | border-bottom: solid 1px _palette(border); 163 | margin: _size(element-margin) 0; 164 | 165 | &.major { 166 | margin: (_size(element-margin) * 1.5) 0; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Personal Website 2 | 3 | Welcome to my [personal website](https://mldangelo.com)! This is an [MIT licensed](https://github.com/mldangelo/personal-site/blob/main/LICENSE) Next.js-based Jamstack application. It offers a simple interface, easy modifications, static export capabilities, and free automatic deployments via [GitHub Pages](https://pages.github.com/). 4 | 5 | ## 🚀 Features 6 | 7 | - Built with modern TypeScript, using [Next.js 16](https://nextjs.org/), [React 19](https://react.dev/), and SCSS. 8 | - Type-safe development with TypeScript strict mode. 9 | - Optimized performance with static export and automatic font optimization. 10 | - Automated workflows via [GitHub Actions](https://github.com/features/actions). 11 | - And more! 12 | 13 | ## 🛠 Adapting this Project 14 | 15 | Want to create your own personal website based on this project? You can set it up in as little as 30 minutes! Follow the setup instructions below and check out the **[detailed guide and checklist](./docs/adapting-guide.md)** on adapting this project to your needs. If you encounter any challenges, don't hesitate to contact me through an issue or email at [help@mldangelo.com](mailto:help@mldangelo.com). 16 | 17 | ## 🤝 Contributing 18 | 19 | Your contributions are warmly welcomed! If you wish to contribute, please review the [design goals](./docs/design-goals.md), [roadmap](./docs/roadmap.md), and [contributing guidelines](./docs/contributing.md). For any bugs or suggestions, you can reach out via email, submit a pull request (I'd be happy to get you a coffee as a thank-you!), or open an issue. 20 | 21 | ## 🔧 Dependencies 22 | 23 | Ensure you have [node](https://nodejs.org/) >= v20. Optionally, use [nvm](https://github.com/nvm-sh/nvm#installing-and-updating) to manage node versions. 24 | 25 | ## 🚀 Setup and Running 26 | 27 | 1. Clone the repository: 28 | 29 | ```bash 30 | git clone git://github.com/mldangelo/personal-site.git 31 | cd personal-site 32 | ``` 33 | 34 | 2. (Optional) Ensure you're on Node v20 or higher: 35 | 36 | ```bash 37 | nvm install 38 | node --version 39 | ``` 40 | 41 | 3. Install dependencies: 42 | 43 | ```bash 44 | npm install 45 | ``` 46 | 47 | 4. Start the development server: 48 | 49 | ```bash 50 | npm run dev 51 | ``` 52 | 53 | By default, the application will be available at [http://localhost:3000/](http://localhost:3000/). 54 | 55 | ## 🏗 Building for Production 56 | 57 | 1. Build the static export: 58 | 59 | ```bash 60 | npm run build 61 | ``` 62 | 63 | The build process automatically creates a static export in the `out/` directory. 64 | 65 | 2. Preview the production build locally: 66 | 67 | ```bash 68 | npm run start 69 | ``` 70 | 71 | ## 🚢 Deploying 72 | 73 | ### Deploying to GitHub Pages 74 | 75 | 1. Update the environment variables and Git remote URL in [`.github/workflows/github-pages.yml`](.github/workflows/github-pages.yml). 76 | 77 | 2. Enable GitHub Actions and Pages for your repository. 78 | 79 | 3. Push to the `main` branch to trigger automatic deployment. 80 | 81 | ```bash 82 | git add . 83 | git commit -m "Deploy to GitHub Pages" 84 | git push origin main 85 | ``` 86 | 87 | ### Static Export 88 | 89 | You can export the site as static HTML to host anywhere: 90 | 91 | ```bash 92 | npm run build 93 | ``` 94 | 95 | The static files will be automatically generated in the `out/` directory. 96 | 97 | ## 🔬 Testing 98 | 99 | ```bash 100 | npm run lint # Run Biome linter 101 | npm run type-check # Run TypeScript type checking 102 | npm run format # Format code with Biome and Prettier 103 | npm run format:check # Check code formatting 104 | npm test # Run Jest tests 105 | ``` 106 | 107 | ## 🎨 Customization 108 | 109 | - **Personal Information**: Update files in `src/data/` with your information. 110 | - **Images**: Replace images in `public/images/` with your own. 111 | - **Theme**: Modify SCSS variables in `src/static/css/libs/_vars.scss`. 112 | 113 | ## 📝 License 114 | 115 | [MIT](https://github.com/mldangelo/personal-site/blob/main/LICENSE) -------------------------------------------------------------------------------- /src/components/Contact/EmailLink.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useReducer, useRef } from 'react'; 4 | 5 | // Validates the first half of an email address. 6 | const validateText = (text: string): boolean => { 7 | // NOTE: Passes RFC 5322 but not tested on google's standard. 8 | // eslint-disable-next-line no-useless-escape 9 | const re = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))$/; 10 | return re.test(text) || text.length === 0; 11 | }; 12 | 13 | const messages = [ 14 | 'hi', 15 | 'hello', 16 | 'hola', 17 | 'you-can-email-me-at-literally-anything! Really', 18 | 'well, not anything. But most things', 19 | 'like-this', 20 | 'or-this', 21 | 'but not this :( ', 22 | 'you.can.also.email.me.with.specific.topics.like', 23 | 'just-saying-hi', 24 | 'please-work-for-us', 25 | 'help', 26 | 'admin', 27 | 'or-I-really-like-your-website', 28 | 'thanks', 29 | ]; 30 | 31 | const useInterval = (callback: () => void, delay: number | null) => { 32 | const savedCallback = useRef<() => void>(callback); 33 | 34 | useEffect(() => { 35 | savedCallback.current = callback; 36 | }, [callback]); 37 | 38 | useEffect(() => { 39 | if (delay) { 40 | const id = setInterval(() => { 41 | savedCallback.current?.(); 42 | }, delay); 43 | return () => clearInterval(id); 44 | } 45 | return () => {}; // pass linter 46 | }, [delay]); 47 | }; 48 | 49 | // React 19: Using useReducer for complex state management 50 | type AnimationState = { 51 | idx: number; 52 | message: string; 53 | char: number; 54 | isActive: boolean; 55 | }; 56 | 57 | type AnimationAction = 58 | | { type: 'TICK'; loopMessage: boolean; hold: number } 59 | | { type: 'PAUSE' } 60 | | { type: 'RESUME'; maxIdx: number }; 61 | 62 | const animationReducer = ( 63 | state: AnimationState, 64 | action: AnimationAction, 65 | ): AnimationState => { 66 | switch (action.type) { 67 | case 'TICK': { 68 | let newIdx = state.idx; 69 | let newChar = state.char; 70 | 71 | if (state.char - action.hold >= messages[state.idx].length) { 72 | newIdx += 1; 73 | newChar = 0; 74 | } 75 | 76 | if (newIdx === messages.length) { 77 | if (action.loopMessage) { 78 | return { 79 | idx: 0, 80 | message: '', 81 | char: 0, 82 | isActive: true, 83 | }; 84 | } 85 | return { 86 | ...state, 87 | isActive: false, 88 | }; 89 | } 90 | 91 | return { 92 | idx: newIdx, 93 | message: messages[newIdx].slice(0, newChar), 94 | char: newChar + 1, 95 | isActive: true, 96 | }; 97 | } 98 | case 'PAUSE': 99 | return { ...state, isActive: false }; 100 | case 'RESUME': 101 | return { 102 | ...state, 103 | isActive: state.idx < action.maxIdx, 104 | }; 105 | default: 106 | return state; 107 | } 108 | }; 109 | 110 | interface EmailLinkProps { 111 | loopMessage?: boolean; 112 | } 113 | 114 | const EmailLink: React.FC = ({ loopMessage = false }) => { 115 | const hold = 50; // ticks to wait after message is complete before rendering next message 116 | const delay = 50; // tick length in mS 117 | 118 | const [state, dispatch] = useReducer(animationReducer, { 119 | idx: 0, 120 | message: messages[0], 121 | char: 0, 122 | isActive: true, 123 | }); 124 | 125 | useInterval( 126 | () => { 127 | dispatch({ type: 'TICK', loopMessage, hold }); 128 | }, 129 | state.isActive ? delay : null, 130 | ); 131 | 132 | return ( 133 |
    dispatch({ type: 'PAUSE' })} 137 | onMouseLeave={() => dispatch({ type: 'RESUME', maxIdx: messages.length })} 138 | > 139 | 146 | {state.message} 147 | @mldangelo.com 148 | 149 |
    150 | ); 151 | }; 152 | 153 | export default EmailLink; 154 | -------------------------------------------------------------------------------- /src/static/css/components/_post.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Post */ 8 | 9 | .post { 10 | @include padding(_size(section-spacing), _size(section-spacing)); 11 | background: _palette(bg); 12 | border: solid 1px _palette(border); 13 | margin: 0 0 _size(section-spacing) 0; 14 | position: relative; 15 | 16 | > header { 17 | @include vendor('display', 'flex'); 18 | border-bottom: solid 1px _palette(border); 19 | left: (_size(section-spacing) * -1); 20 | margin: (_size(section-spacing) * -1) 0 _size(section-spacing) 0; 21 | position: relative; 22 | width: calc(100% + #{_size(section-spacing) * 2}); 23 | 24 | .title { 25 | @include vendor('flex-grow', '1'); 26 | padding: (_size(section-spacing) * 1.25) _size(section-spacing) (_size(section-spacing) * 1.1) 27 | _size(section-spacing); 28 | 29 | h2 { 30 | font-weight: _font(weight-heading-extrabold); 31 | font-size: 1.5em; 32 | } 33 | 34 | > :last-child { 35 | margin-bottom: 0; 36 | } 37 | } 38 | 39 | .meta { 40 | @include padding(_size(section-spacing) * 1.25, _size(section-spacing)); 41 | border-left: solid 1px _palette(border); 42 | min-width: 17em; 43 | text-align: right; 44 | width: 17em; 45 | 46 | > * { 47 | margin: 0 0 (_size(element-margin) * 0.5) 0; 48 | } 49 | 50 | > :last-child { 51 | margin-bottom: 0; 52 | } 53 | 54 | .published { 55 | color: _palette(fg-bold); 56 | display: block; 57 | font-family: _font(family-heading); 58 | font-size: 0.7em; 59 | font-weight: _font(weight-heading-bold); 60 | letter-spacing: _font(kerning-heading); 61 | margin-top: 0.5em; 62 | text-transform: uppercase; 63 | white-space: nowrap; 64 | } 65 | } 66 | } 67 | 68 | > footer { 69 | @include vendor('display', 'flex'); 70 | @include vendor('align-items', 'center'); 71 | 72 | .actions { 73 | @include vendor('flex-grow', '1'); 74 | } 75 | } 76 | 77 | @include breakpoint(medium) { 78 | border-left: 0; 79 | border-right: 0; 80 | left: _size(section-spacing) * -1; 81 | width: calc(100% + (#{_size(section-spacing)} * 2)); 82 | 83 | > header { 84 | @include vendor('flex-direction', 'column'); 85 | @include padding(_size(section-spacing) * 1.25, _size(section-spacing), (0, 0, -0.5em, 0)); 86 | border-left: 0; 87 | 88 | .title { 89 | margin: 0 0 _size(element-margin) 0; 90 | padding: 0; 91 | text-align: center; 92 | } 93 | 94 | .meta { 95 | @include vendor('align-items', 'center'); 96 | @include vendor('display', 'flex'); 97 | @include vendor('justify-content', 'center'); 98 | border-left: 0; 99 | margin: 0 0 _size(element-margin) 0; 100 | padding-top: 0; 101 | padding: 0; 102 | text-align: left; 103 | width: 100%; 104 | 105 | > * { 106 | border-left: solid 1px _palette(border); 107 | margin-left: 2em; 108 | padding-left: 2em; 109 | } 110 | 111 | > :first-child { 112 | border-left: 0; 113 | margin-left: 0; 114 | padding-left: 0; 115 | } 116 | 117 | .published { 118 | margin-bottom: 0; 119 | margin-top: 0; 120 | } 121 | } 122 | } 123 | } 124 | 125 | @include breakpoint(small) { 126 | @include padding(_size(section-spacing-small), _size(section-spacing-small)); 127 | left: _size(section-spacing-small) * -1; 128 | margin: 0 0 _size(element-margin) 0; 129 | width: calc(100% + (#{_size(section-spacing-small)} * 2)); 130 | 131 | > header { 132 | @include padding( 133 | _size(section-spacing-small) * 2, 134 | _size(section-spacing-small), 135 | (0, 0, -0.5em, 0) 136 | ); 137 | left: (_size(section-spacing-small) * -1); 138 | margin: (_size(section-spacing-small) * -1) 0 _size(section-spacing-small) 0; 139 | width: calc(100% + #{_size(section-spacing-small) * 2}); 140 | 141 | .title { 142 | h2 { 143 | font-size: 1.1em; 144 | } 145 | } 146 | } 147 | } 148 | 149 | @include breakpoint(xsmall) { 150 | > header { 151 | .meta { 152 | @include vendor('align-items', 'center'); 153 | @include vendor('flex-direction', 'column'); 154 | 155 | > * { 156 | border-left: 0; 157 | margin: (_size(element-margin) * 0.5) 0 0 0; 158 | padding-left: 0; 159 | } 160 | } 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/static/css/layout/_header.scss: -------------------------------------------------------------------------------- 1 | /// 2 | /// Future Imperfect by HTML5 UP 3 | /// html5up.net | @ajlkn 4 | /// Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) 5 | /// 6 | 7 | /* Header */ 8 | 9 | body { 10 | padding-top: 3.5em; 11 | } 12 | 13 | #header { 14 | @include vendor('display', 'flex'); 15 | @include vendor('justify-content', 'space-between'); 16 | background-color: _palette(bg); 17 | border-bottom: solid 1px _palette(border); 18 | height: 3.5em; 19 | left: 0; 20 | line-height: 3.5em; 21 | position: fixed; 22 | top: 0; 23 | width: 100%; 24 | z-index: _misc(z-index-base); 25 | 26 | a { 27 | color: inherit; 28 | text-decoration: none; 29 | } 30 | 31 | ul { 32 | list-style: none; 33 | margin: 0; 34 | padding-left: 0; 35 | 36 | li { 37 | display: inline-block; 38 | padding-left: 0; 39 | } 40 | } 41 | 42 | h1 { 43 | height: inherit; 44 | line-height: inherit; 45 | padding: 0 0 0 1.5em; 46 | white-space: nowrap; 47 | 48 | a { 49 | font-size: 0.7em; 50 | } 51 | } 52 | 53 | .links { 54 | @include vendor('flex', '1'); 55 | border-left: solid 1px _palette(border); 56 | height: inherit; 57 | line-height: inherit; 58 | margin-left: 1.5em; 59 | overflow: hidden; 60 | padding-left: 1.5em; 61 | 62 | ul { 63 | li { 64 | border-left: solid 1px _palette(border); 65 | line-height: 1; 66 | margin-left: 1em; 67 | padding-left: 1em; 68 | 69 | &:first-child { 70 | border-left: 0; 71 | margin-left: 0; 72 | padding-left: 0; 73 | } 74 | 75 | a { 76 | border-bottom: 0; 77 | font-family: _font(family-heading); 78 | font-size: 0.7em; 79 | font-weight: _font(weight-heading); 80 | letter-spacing: _font(kerning-heading); 81 | text-transform: uppercase; 82 | } 83 | } 84 | } 85 | } 86 | 87 | .main { 88 | height: inherit; 89 | line-height: inherit; 90 | text-align: right; 91 | 92 | ul { 93 | height: inherit; 94 | line-height: inherit; 95 | 96 | li { 97 | border-left: solid 1px _palette(border); 98 | height: inherit; 99 | line-height: inherit; 100 | white-space: nowrap; 101 | 102 | > i { 103 | text-decoration: none; 104 | border-bottom: 0; 105 | overflow: hidden; 106 | position: relative; 107 | text-indent: 4em; 108 | margin-right: 1.5em; 109 | } 110 | 111 | > * { 112 | display: block; 113 | float: left; 114 | } 115 | 116 | > a { 117 | @include icon; 118 | border-bottom: 0; 119 | color: _palette(fg-light); 120 | overflow: hidden; 121 | position: relative; 122 | text-indent: 4em; 123 | width: 4em; 124 | 125 | &:before { 126 | display: block; 127 | height: inherit; 128 | left: 0; 129 | line-height: inherit; 130 | position: absolute; 131 | text-align: center; 132 | text-indent: 0; 133 | top: 0; 134 | width: inherit; 135 | } 136 | } 137 | } 138 | } 139 | } 140 | 141 | @include breakpoint(medium) { 142 | .links { 143 | display: none; 144 | } 145 | } 146 | 147 | @include breakpoint(small) { 148 | height: 2.75em; 149 | line-height: 2.75em; 150 | 151 | h1 { 152 | padding: 0 0 0 1em; 153 | } 154 | 155 | .main { 156 | .search { 157 | display: none; 158 | } 159 | } 160 | } 161 | } 162 | 163 | /* Position and sizing of burger button */ 164 | .bm-burger-button { 165 | display: none; 166 | } 167 | 168 | /* Color/shape of burger icon bars */ 169 | .bm-burger-bars { 170 | display: none; 171 | } 172 | 173 | /* Position and sizing of clickable cross button */ 174 | .bm-cross-button { 175 | display: none; 176 | } 177 | 178 | /* Color/shape of close button cross */ 179 | .bm-cross { 180 | display: none; 181 | } 182 | 183 | /* General sidebar styles */ 184 | .bm-menu { 185 | background: _palette(bg); 186 | padding: 2.5em 1.5em 0; 187 | font-size: 1.15em; 188 | } 189 | 190 | /* Wrapper for item list */ 191 | .bm-item-list { 192 | color: _palette(fg-bold); 193 | padding: 0.8em; 194 | font-family: 'Raleway', Helvetica, sans-serif; 195 | } 196 | 197 | #header .index-link { 198 | z-index: 3; 199 | } 200 | 201 | #header .main .menu { 202 | cursor: pointer; 203 | } 204 | 205 | #header .main .close-menu { 206 | border: 0; 207 | z-index: 10000; 208 | } 209 | 210 | #header .main .open-menu { 211 | border: 0; 212 | } 213 | 214 | .open-menu { 215 | position: fixed; 216 | right: 0; 217 | border: none; 218 | } 219 | 220 | .close-menu { 221 | position: fixed; 222 | right: 0; 223 | z-index: 3; 224 | border-left: 0; 225 | } 226 | 227 | .hamburger-container { 228 | display: none; 229 | } 230 | 231 | @include breakpoint(medium) { 232 | .hamburger-container { 233 | display: initial; 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | workflow_dispatch: 9 | 10 | # Cancel in-progress runs for the same workflow and branch 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | # Shared job for installing dependencies with caching 17 | install: 18 | name: Install Dependencies 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 5 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version-file: ".nvmrc" 29 | cache: "npm" 30 | 31 | - name: Cache node_modules 32 | id: cache-node-modules 33 | uses: actions/cache@v4 34 | with: 35 | path: node_modules 36 | key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} 37 | restore-keys: | 38 | ${{ runner.os }}-node-modules- 39 | 40 | - name: Install dependencies 41 | if: steps.cache-node-modules.outputs.cache-hit != 'true' 42 | run: npm ci 43 | 44 | # Lint, format, and type check 45 | code-quality: 46 | name: Code Quality 47 | needs: install 48 | runs-on: ubuntu-latest 49 | timeout-minutes: 5 50 | steps: 51 | - name: Checkout code 52 | uses: actions/checkout@v4 53 | 54 | - name: Setup Node.js 55 | uses: actions/setup-node@v4 56 | with: 57 | node-version-file: ".nvmrc" 58 | cache: "npm" 59 | 60 | - name: Restore node_modules 61 | uses: actions/cache@v4 62 | with: 63 | path: node_modules 64 | key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} 65 | 66 | - name: Type Check 67 | run: npm run type-check 68 | 69 | - name: Lint 70 | run: npm run lint 71 | 72 | - name: Format Check 73 | run: npm run format:check 74 | 75 | # Run tests 76 | test: 77 | name: Test 78 | needs: install 79 | runs-on: ubuntu-latest 80 | timeout-minutes: 10 81 | steps: 82 | - name: Checkout code 83 | uses: actions/checkout@v4 84 | 85 | - name: Setup Node.js 86 | uses: actions/setup-node@v4 87 | with: 88 | node-version-file: ".nvmrc" 89 | cache: "npm" 90 | 91 | - name: Restore node_modules 92 | uses: actions/cache@v4 93 | with: 94 | path: node_modules 95 | key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} 96 | 97 | - name: Run tests 98 | run: npm test -- --ci --coverage 99 | 100 | - name: Upload coverage reports 101 | if: always() 102 | uses: actions/upload-artifact@v4 103 | with: 104 | name: coverage 105 | path: coverage/ 106 | retention-days: 7 107 | 108 | # Build on multiple OS and Node versions 109 | build: 110 | name: Build (${{ matrix.os }}, Node ${{ matrix.node-version }}) 111 | needs: install 112 | runs-on: ${{ matrix.os }} 113 | timeout-minutes: 10 114 | strategy: 115 | fail-fast: false 116 | matrix: 117 | node-version: [20.x, 22.x] 118 | os: [ubuntu-latest, windows-latest, macos-latest] 119 | exclude: 120 | # Reduce matrix size for faster CI 121 | - os: windows-latest 122 | node-version: 20.x 123 | - os: macos-latest 124 | node-version: 20.x 125 | steps: 126 | - name: Checkout code 127 | uses: actions/checkout@v4 128 | 129 | - name: Setup Node.js ${{ matrix.node-version }} 130 | uses: actions/setup-node@v4 131 | with: 132 | node-version: ${{ matrix.node-version }} 133 | cache: "npm" 134 | 135 | - name: Install dependencies 136 | run: npm ci 137 | 138 | - name: Build 139 | run: npm run build 140 | env: 141 | NODE_ENV: production 142 | NEXT_PUBLIC_GA_TRACKING_ID: ${{ vars.NEXT_PUBLIC_GA_TRACKING_ID || 'UA-XXXXXXXXX-X' }} 143 | 144 | - name: Verify static export 145 | shell: bash 146 | run: | 147 | if [ ! -d "out" ]; then 148 | echo "Error: 'out' directory not found. Static export may have failed." 149 | exit 1 150 | fi 151 | echo "Static export successful. Found $(find out -type f | wc -l) files." 152 | 153 | - name: Upload build artifacts 154 | if: matrix.os == 'ubuntu-latest' && matrix.node-version == '22.x' 155 | uses: actions/upload-artifact@v4 156 | with: 157 | name: build-output 158 | path: out/ 159 | retention-days: 7 160 | 161 | # Summary job to ensure all checks pass 162 | ci-success: 163 | name: CI Success 164 | needs: [code-quality, test, build] 165 | runs-on: ubuntu-latest 166 | if: always() 167 | steps: 168 | - name: Check if all jobs succeeded 169 | run: | 170 | if [[ "${{ needs.code-quality.result }}" != "success" || 171 | "${{ needs.test.result }}" != "success" || 172 | "${{ needs.build.result }}" != "success" ]]; then 173 | echo "One or more jobs failed" 174 | exit 1 175 | fi 176 | echo "All CI checks passed!" -------------------------------------------------------------------------------- /docs/adapting-guide.md: -------------------------------------------------------------------------------- 1 | # Adapting this Website 2 | 3 | Many people have contacted me about adapting this website. I have tried to make things as simple as possible. There are still bugs. I am sorry. If you find a bug, please email me (help@mldangelo.com), submit a pull request (I'll buy you a coffee as a thank you), or submit an issue. 4 | 5 | You may wish to fork this repository or remove my remote origin and add your own. Go [here](https://help.github.com/articles/changing-a-remote-s-url/) for more information on how to change remotes. 6 | 7 | ## Before you start 8 | 9 | 1. Make sure you have a good text editor. I recommend [Visual Studio Code](https://code.visualstudio.com/). 10 | 2. Review the project structure. This is a Next.js app using the App Router. Pages are defined in the `app/` directory. If you wish to add or remove a page, you should do so there. 11 | 12 | ## Checklist 13 | 14 | ### Setup 15 | 16 | 1. Run the project before making any modifications by following the set up and running instructions in the main [README.md](https://github.com/mldangelo/personal-site#set-up). 17 | 2. Change `homepage` in `package.json` to reflect where you plan to host the site. This is important for static exporting. This also changes your path when developing locally. For example, a homepage of `mldangelo.com` places the site at `localhost:3000` and a homepage of `https://mldangelo.github.io/personal-site/` places the site at `localhost:3000/personal-site/`. If you plan to host at on a path such as `https://[your-github-username].github.io/[your-repo-name]`, you should set this now so that your development environment mirrors your production environment. 18 | 3. Create a `.env.local` file. To do this, run: 19 | 20 | ```bash 21 | cp .env.example .env.local 22 | ``` 23 | 24 | and set values as appropriate. Most people will only need to update the Google Analytics tracking ID. 25 | 26 | ### Adapt Content 27 | 28 | I recommend keeping the project running as you go (with `npm run dev`) to help correct mistakes quickly. 29 | 30 | 1. Start by changing text in the sidebar. This file is located at `src/components/Template/SideBar.tsx`. 31 | 2. Add an image of yourself in `public/images/me.jpg`. Your image should be approximately 256 x 256 pixels. Larger and smaller is ok, but avoid very large images to save bandwidth. If you need help resizing your image, Adobe makes a great online tool [here](https://www.adobe.com/photoshop/online/resize-image.html). 32 | 3. Modify the text on the homepage. This file is located at `app/page.tsx`. 33 | 4. Modify the files in `src/data/resume/` next. 34 | 5. Modify all of the other files in the `src/data/` directory. 35 | 6. You've finished modifying >95% of the pages. Search through the rest of the files for references to `Michael` or `Angelo` and change values to your name. 36 | 7. Change or remove the favicon in `public/favicon.ico` and images in `public/images/favicon/`. [This](https://realfavicongenerator.net/) website may be helpful. 37 | 38 | ### Deploy 39 | 40 | See deployment instructions [here](https://github.com/mldangelo/personal-site#deploying-to-github-pages). If you plan to use a custom url, modify `public/CNAME` and enter your URL. You can run: 41 | 42 | ```bash 43 | echo "[your-custom-domain][.com]" > public/CNAME 44 | ``` 45 | 46 | as a shortcut. 47 | 48 | I recommend purchasing your own domain name from [Google Domains](https://domains.google). The project is pre-configured to automatically deploy to github pages via the deploy github action. Go to `https://github.com/[your-github-username]/[your-repo-name]/settings` and configure accordingly: 49 | 50 |
    51 | 52 | Next, configure your domains DNS record. See [here](https://help.github.com/articles/using-a-custom-domain-with-github-pages/) for more information. After a few minutes, your website should be live on your domain. 53 | 54 | That's it. Thank you for reading. If you go through this guide and run into issues or areas you find unclear, please consider submitting a PR to help others like you. 55 | 56 | ## Common Pitfalls 57 | 58 | Here are answers to questions I've been asked at least twice. I've attempted to simplify development and improve documentation throughout the project to address them. This section is updated frequently. 59 | 60 | 1. My CSS isn't rendering, or I see a 404 instead of my site: 61 | 62 | Make sure the `homepage` field of `package.json` points to where you plan to host your site index. Also, double check that you created a `CNAME` file (see deployment instructions above). If neither of these work, please open an issue or send me an [email](mailto:help@mldangelo.com). 63 | 64 | 2. LF / CRLF issues with eslint. 65 | 66 | This is a common Windows development pitfall. See @[FrozenFury](https://github.com/FrozenFury)'s [comment](https://github.com/mldangelo/personal-site/issues/263#issuecomment-759216299) for how to update your eslint config to resolve this issue. 67 | 68 | 3. master / main 69 | 70 | Github decided to rename the default branch of all of their repositories from master to main, and so did I. See their reasoning [here](https://github.com/github/renaming). If you're trying to pull in recent changes, consider renaming your own branch, or just create a merge commit from my main. 71 | 72 | 4. Google Analytics Warnings when building. 73 | 74 | Either set up Google Analytics 4 or remove the `` component from `app/layout.tsx`. The site now uses the official `@next/third-parties` package for GA4 integration. 75 | 76 | 5. How do I configure git? What is nano? 77 | 78 | Read through [git-scm](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup)'s excellent documentation. I recommend setting your default text editor to something you're comfortable with. I like to use vim for writing commit messages. 79 | 80 | 6. Can I host at [username.github.io]? 81 | 82 | Sure, see github's documentation [here](https://pages.github.com/). 83 | 84 | 7. How do I disable eslint? 85 | 86 | `echo "*\n" > .eslintignore` Although I really don't recommend it. Linters are good. They help prevent errors, enforce uniform style so that you can spend less time thinking about formatting and more time reading code, and eliminate easy nits for code reviews. If the rules aren't working for you, you should change them. See eslint's documentation [here](https://eslint.org/docs/about/) for more information. 87 | 88 | 8. Why is my website rendering the readme file? 89 | 90 | See 1. above and make sure that `.nojekyll` still exists in `public`. This file directs github to not attempt to render the website with Jekyll. -------------------------------------------------------------------------------- /src/data/resume/work.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Conforms to https://jsonresume.org/schema/ 3 | */ 4 | export interface Position { 5 | name: string; 6 | position: string; 7 | url: string; 8 | startDate: string; 9 | endDate?: string; 10 | summary?: string; 11 | highlights?: string[]; 12 | } 13 | 14 | const work: Position[] = [ 15 | { 16 | name: 'Promptfoo', 17 | position: 'Co-founder & CTO', 18 | url: 'https://promptfoo.dev', 19 | startDate: '2024-07-01', 20 | summary: `Promptfoo discovers and eliminates LLM risks before they reach production through automated 21 | red-teaming and vulnerability analysis. Our open-source, developer-first approach has made Promptfoo the most 22 | widely adopted LLM security tool, with over 200,000 users at companies like Anthropic, Amazon, and Shopify.`, 23 | highlights: [ 24 | 'Own company-wide technical strategy and product roadmap, balancing open-source community, commercial product, and research investments.', 25 | 'Led technical due diligence for $23M+ in venture financing from Insight Partners and Andreessen Horowitz.', 26 | 'Built and scaled the engineering team from founding through Series A, including hiring, leveling, and performance management.', 27 | 'Led development of core evaluation framework, vulnerability scanning, and automated red-teaming capabilities.', 28 | ], 29 | }, 30 | { 31 | name: 'Smile ID', 32 | position: 'VP Engineering & Head of AI', 33 | url: 'https://usesmileid.com', 34 | startDate: '2022-01-01', 35 | endDate: '2024-07-01', 36 | summary: `Smile Identity provides ML-powered identity verification APIs used by banks, fintechs, and 37 | telcos across Africa. Promoted from Director to VP Engineering to VP Engineering & Head of AI within one year, 38 | leading teams building APIs that now process hundreds of millions of identity checks.`, 39 | highlights: [ 40 | 'Owned engineering org design, headcount planning, and hiring across backend and ML teams, growing to 20+ engineers.', 41 | 'Transformed engineering velocity from weekly releases to continuous deployment, migrating to TypeScript, adding tests in CI, and leading ceremonies.', 42 | 'Re-architected inference APIs on AWS Lambda, scaling from 1,000 to 1M+ users per day and cutting job time from 30+ seconds to 7 seconds.', 43 | 'Built computer vision pipelines for liveness detection certification.', 44 | 'Pitched, designed, and shipped a fraud detection product using 1-N facial recognition with embeddings and vector search.', 45 | ], 46 | }, 47 | { 48 | name: 'Skeptical Investments', 49 | position: 'Co-founder', 50 | url: 'http://skepticalinvestments.biz', 51 | startDate: '2017-04-01', 52 | summary: `Skeptical Investments is a micro-VC fund focused on early-stage technical founders, 53 | with investments in ML, infrastructure, and space startups.`, 54 | highlights: [ 55 | 'Created InstaSafe, a tool that automates YC-standard investment documents.', 56 | 'Advise portfolio founders on ML, infrastructure, hiring, and fundraising strategy.', 57 | ], 58 | }, 59 | { 60 | name: 'Arthena', 61 | position: 'Co-founder & CTO', 62 | url: 'https://arthena.com', 63 | startDate: '2014-01-01', 64 | endDate: '2022-01-01', 65 | summary: `Arthena was a quantitative art investment platform backed by Anthemis, 66 | Foundation Capital, and Y Combinator. 67 | Built Arthena from idea to acquisition by Masterworks in 2023.`, 68 | highlights: [ 69 | 'Co-founded Arthena and led technical strategy; sat on board and led fundraising, including debt financing for a 9-figure investment vehicle in the auction guarantee market.', 70 | 'Built and managed a cross-functional team of 20 engineers, data scientists, and analysts.', 71 | 'Built data pipelines, quantitative research tools, and visualization systems to scale investment research and augment analyst decision-making.', 72 | 'Developed valuation models on irregularly-sampled time series using graph embeddings, probabilistic forecasting with calibrated prediction intervals, and online learning with walk-forward validation.', 73 | 'Designed micro-service architecture for data collection, feature engineering, backtesting, and reporting.', 74 | ], 75 | }, 76 | { 77 | name: 'Matroid', 78 | position: 'Co-founder', 79 | url: 'https://matroid.com', 80 | startDate: '2015-09-01', 81 | endDate: '2016-06-01', 82 | summary: `Matroid is a computer vision platform for creating and deploying detectors, now funded by 83 | NEA and Accel. I co-founded 84 | the company and built the initial product through our first fundraise.`, 85 | highlights: [ 86 | 'Defined company vision and product strategy.', 87 | 'Architected and built the initial detector platform for identifying objects, events, and patterns in video.', 88 | 'Led technical fundraising efforts for seed round.', 89 | ], 90 | }, 91 | { 92 | name: 'Planet', 93 | position: 'Avionics Intern', 94 | url: 'https://planet.com', 95 | startDate: '2014-06-01', 96 | endDate: '2015-01-01', 97 | highlights: [ 98 | 'Built models for cloud detection and optimal exposure using Earth albedo, incorporating sensor physics, astronomy, and optics.', 99 | 'Worked with optics and camera hardware; developed satellite software in C++ and Python (OpenCV, NumPy, SciPy).', 100 | ], 101 | }, 102 | { 103 | name: 'Planetary Resources', 104 | position: 'Avionics Intern', 105 | url: 'https://www.planetaryresources.com', 106 | startDate: '2014-01-01', 107 | endDate: '2014-05-01', 108 | highlights: [ 109 | 'Developed simulations for Attitude Determination and Control Subsystem.', 110 | 'Built flight hardware for Electrical Power Subsystem in clean room; performed thermal vacuum chamber testing.', 111 | ], 112 | }, 113 | { 114 | name: 'Facebook', 115 | position: 'Software Engineering Intern', 116 | url: 'https://facebook.com', 117 | startDate: '2013-06-01', 118 | endDate: '2013-09-01', 119 | highlights: [ 120 | "Worked on Facebook's first GPU compute team, benchmarking NVIDIA GPUs for initial data center deployment.", 121 | 'Built log collection software and performed statistical analysis in Python, Hack, R, and HQL.', 122 | ], 123 | }, 124 | { 125 | name: 'UB Nanosatellite Program', 126 | position: 'Program Manager', 127 | url: 'https://ubnl.space/', 128 | startDate: '2011-06-01', 129 | endDate: '2012-05-01', 130 | summary: 131 | 'Led a 60-person student team through the satellite development lifecycle for the AFRL University Nanosatellite Program.', 132 | highlights: [ 133 | 'Co-authored grant proposal to design and build a multi-spectral imaging satellite.', 134 | 'Established budget and schedule from initial concept through design reviews.', 135 | 'Designed ADCS and worked on sensor integration.', 136 | ], 137 | }, 138 | ]; 139 | 140 | export default work; 141 | -------------------------------------------------------------------------------- /src/data/about.ts: -------------------------------------------------------------------------------- 1 | export const aboutMarkdown = `# Intro 2 | 3 | I am the co-founder and CTO of [Promptfoo](https://promptfoo.dev), the most widely adopted open-source LLM security tool. We help teams discover and eliminate LLM risks through automated red-teaming and vulnerability analysis. Before Promptfoo, I was the VP of Engineering at [Smile ID](https://usesmileid.com), where I led teams building identity verification APIs that now process hundreds of millions of checks across Africa. I also co-founded [Arthena](https://arthena.com) (acquired by Masterworks) and [Matroid](https://matroid.com). 4 | 5 | In my spare time, I enjoy investing in people and ideas through a [small venture fund](https://skepticalinvestments.biz), focusing on projects with high social impact. If you think I can be helpful to you or your cause, or if you're interested in collaborating, feel free to get in touch. 6 | 7 | # Some History 8 | 9 | - My parents put a computer in my bedroom in 1993 when I was 3. It was an old Tandy that ran MS-DOS. My favorite games were Street Rod 2, Wolfenstein 3D, and Tom and Jerry. It had a mechanical keyboard and a turbo button. To this day, I still don't know what pressing the turbo button really did. 10 | - We subscribed to AOL in 1995. I still remember installing it from a floppy disk onto our brand-new Packard Bell. It took years for me to send my first email. 11 | - In the summer of 1996, my uncle purchased [MegaRace](https://en.wikipedia.org/wiki/MegaRace) from [Media Play](https://en.wikipedia.org/wiki/Media_Play) and installed it on my mom's work computer. I might have endangered her business by using her computer too much. 12 | - At 7, I discovered the mini-games hidden in Microsoft Office. I also beat Minesweeper on expert for the first time. 13 | - At 8, my parents bought me a Sony Mavica MVC-FD71 digital camera after I stole their SLR one too many times. It could fit 10 images to a floppy disk at a 0.3MP resolution. I still have it, and it still works. I've been taking photographs ever since, now with a Nikon D750, D800, and occasionally with a Mamiya 6II. 14 | - At 10, I built my first website with Microsoft FrontPage on our Pentium III [Gateway](https://en.wikipedia.org/wiki/Gateway,_Inc.). My website was terrible. 15 | - I was 11 when I built my first [Tesla Coil](https://en.wikipedia.org/wiki/Tesla_coil) (without my parents' permission). Over the next few years, I built several more, including one of the first audio modulated coils and one of the first DRSSTCs. 16 | - When I was 12, I set the all-time high record at my local laser tag facility by reverse engineering the charging station and weapon protocols with a photo-resistor, micro-cassette recorder, and a lot of patience. I was unstoppable. 17 | - At 13, I went to space camp and fell in love. I went back two more times and promised myself that I'd work in space. I've since helped build three generations of satellites and have contributed to two more. 18 | - At 14, I was almost expelled for finding a backdoor into my high school's file server and telling everyone but the faculty members about it. Later that year, I figured out how to turn off the internet firewall by editing system registry keys. I anonymously shared my work months later. 19 | - At 16, I participated in a foreign exchange program in Dortmund, Germany. Since then, I've gone back almost every year. 20 | - 14 - 17, I played a lot of video games. My favorites included Counter-Strike Source, Command and Conquer 3, Halo 2, and Age of Empires II. 21 | - At 18, in the summer before college, my friends and I started playing [Muggle Quidditch](). We went on to start over 8 teams in the [International Quidditch Association](https://en.wikipedia.org/wiki/International_Quidditch_Association), including the [Buffalo Quidditch Society](https://www.facebook.com/buffaloquidditch/). At our height, we were ranked third in the IQA. Although I don't play anymore, you can still see pictures of me holding a broom while wearing a chess camp t-shirt on Facebook. 22 | - At 19, I took my first graduate course and published my first academic paper. 23 | - At 20, I co-authored a grant to build a satellite and managed a 60+ person team for the next two years. You can read more about that [here](https://ubnl.space/glados/). 24 | 25 | Ask me in person for other stories that I'm afraid to share with the internet. 26 | 27 | # I Like 28 | 29 | - Running 30 | - Skiing 31 | - Sailing and the sea 32 | - Space 33 | - Summer 34 | - [Books](https://www.goodreads.com/mdangelo) 35 | - Colored pencils ([Faber-Castell Polychromos](https://www.faber-castell.com/products/art-and-graphic/polychromos)) 36 | - Podcasts ([The Daily](https://www.nytimes.com/column/the-daily), [The Ezra Klein Show](https://www.nytimes.com/column/ezra-klein-podcast), [Planet Money](https://www.npr.org/sections/money/), [The Indicator](https://www.npr.org/podcasts/510325/the-indicator-from-planet-money), [This American Life](https://www.thisamericanlife.org/), [99% Invisible](https://99percentinvisible.org/episodes/), [The Economist](http://radio.economist.com/), [Radiolab](https://www.wnycstudios.org/shows/radiolab), [Hidden Brain](https://www.npr.org/series/423302056/hidden-brain), [Inquiring Minds](https://inquiring.show), and others) 37 | - [Good design](/) 38 | - [Photography](https://instagram.com/dangelosaurus) 39 | 40 | # Travel / Geography 41 | 42 | - I am originally from Buffalo, New York. I have since lived in Palo Alto, Mountain View, San Francisco, Seattle, and New York City. 43 | - I've been to approximately 50 countries, some of which I have forgotten, many of which I would like to revisit. 44 | - In 2016, I visited Canada, Ethiopia, Austria, Germany, Belgium, Ireland, Northern Ireland, Italy, Romania, Sweden, Norway, Svalbard, Panama, Costa Rica, Uganda, Japan, and the UAE, mostly in that order. 45 | - In 2017, I visited Canada, Japan, Denmark, Germany, Sweden, Estonia, Russia, the Netherlands, Belgium, the U.K., Spain, Iceland, France, Switzerland, Ethiopia, and Luxembourg. 46 | - In 2018, I visited Canada, France, Italy, Israel, and the U.K. 47 | - In 2019, I visited Canada, England, France, and Switzerland. 48 | - In 2020, I traveled barely 20 blocks. I stayed in New York and remodeled an apartment. 49 | - In 2021, I continued remodeling an apartment. 50 | - In 2022, I switched jobs and visited the UK, France, Greece, Belgium, Luxembourg, Germany, and Kenya. 51 | - In 2023, I visited France, the UK, Ireland, and Rwanda. 52 | - I am an Oregon Trail II enthusiast. 53 | 54 | # Fun Facts 55 | 56 | - I have a list of thousands of ideas, like creating matching bow ties for cats and humans. 57 | - I almost always have a sketchbook with me and a [01 Sakura Pigma Micron Pen](https://www.sakuraofamerica.com/product/pigma-micron/). 58 | - I can't locate every country on a map. 59 | - I operate a [small angel fund](http://skepticalinvestments.biz/) with terrible returns. 60 | - I break about 30 traffic laws on a skateboard or [bicycle](https://www.citibikenyc.com/) every single day. 61 | - I stack-rank coffee shops, restaurants, and every dog I see in New York. 62 | - I added this page because many people complained that my site was just my resume. 63 | 64 | # I Dream Of 65 | 66 | - Inspiring and feeling inspired. 67 | - Enabling a brighter future for everyone, regardless of political or socioeconomic status. 68 | - Treating every individual with genuine kindness and respect. 69 | - Staying curious. 70 | - Continually improving. 71 | - You not checking the commit history for earlier drafts of this file. 72 | 73 | # Websites from People I Admire 74 | 75 | - [Alex Peysakhovich](http://alexpeys.github.io/) 76 | - [Chris Lengerich](http://www.chrislengerich.com/) 77 | - [Chris Saad](https://www.chrissaad.com/) 78 | - [Duncan Tomlin](http://duncantomlin.com/) 79 | - [Ed Kearney](https://edkearney.com/) 80 | - [Hawley Moore](http://hawleymoore.com/) 81 | - [Holman Gao](https://golmansax.com/) 82 | - [Ian Webster](http://ianww.com/) 83 | - [Johanna Flato](https://www.johannaflato.com/) 84 | - [Judy Mou](http://www.judymou.com/) 85 | - [Judy Suh](https://www.judysuh.com/) 86 | - [Kristina Monakhova](https://kristinamonakhova.com/) 87 | - [Noah Trueblood](http://notrueblood.com/) 88 | - [Ruoxi Wang](http://ruoxiw.com/) 89 | - [Tom Sachs](https://www.tomsachs.org/) 90 | - [Will Holley](https://willholley.com/) 91 | 92 | If we are friends and you feel like you belong on this list, you're probably right. Submit a PR, or ask me, and I'll add you. 93 | `; 94 | -------------------------------------------------------------------------------- /src/static/css/libs/_skel.scss: -------------------------------------------------------------------------------- 1 | // skel.scss v3.0.1 | (c) skel.io | MIT licensed */ 2 | 3 | @use 'sass:map'; 4 | @use 'sass:list'; 5 | @use 'sass:string'; 6 | 7 | // Vars. 8 | 9 | /// Breakpoints. 10 | /// @var {list} 11 | $breakpoints: (); 12 | 13 | /// Vendor prefixes. 14 | /// @var {list} 15 | $vendor-prefixes: ('-moz-', '-webkit-', '-ms-', ''); 16 | 17 | /// Properties that should be vendorized. 18 | /// @var {list} 19 | $vendor-properties: ( 20 | 'align-content', 21 | 'align-items', 22 | 'align-self', 23 | 'animation', 24 | 'animation-delay', 25 | 'animation-direction', 26 | 'animation-duration', 27 | 'animation-fill-mode', 28 | 'animation-iteration-count', 29 | 'animation-name', 30 | 'animation-play-state', 31 | 'animation-timing-function', 32 | 'appearance', 33 | 'backface-visibility', 34 | 'box-sizing', 35 | 'filter', 36 | 'flex', 37 | 'flex-basis', 38 | 'flex-direction', 39 | 'flex-flow', 40 | 'flex-grow', 41 | 'flex-shrink', 42 | 'flex-wrap', 43 | 'justify-content', 44 | 'order', 45 | 'perspective', 46 | 'pointer-events', 47 | 'transform', 48 | 'transform-origin', 49 | 'transform-style', 50 | 'transition', 51 | 'transition-delay', 52 | 'transition-duration', 53 | 'transition-property', 54 | 'transition-timing-function', 55 | 'user-select' 56 | ); 57 | 58 | /// Values that should be vendorized. 59 | /// @var {list} 60 | $vendor-values: ('filter', 'flex', 'linear-gradient', 'radial-gradient', 'transform'); 61 | 62 | // Functions. 63 | 64 | /// Removes a specific item from a list. 65 | /// @author Hugo Giraudel 66 | /// @param {list} $list List. 67 | /// @param {integer} $index Index. 68 | /// @return {list} Updated list. 69 | @function remove-nth($list, $index) { 70 | $result: null; 71 | 72 | @if type-of($index) != number { 73 | @warn "$index: #{quote($index)} is not a number for `remove-nth`."; 74 | } @else if $index == 0 { 75 | @warn "List index 0 must be a non-zero integer for `remove-nth`."; 76 | } @else if abs($index) > list.length($list) { 77 | @warn "List index is #{$index} but list is only #{list.length($list)} item long for `remove-nth`."; 78 | } @else { 79 | $result: (); 80 | $index: if($index < 0, list.length($list) + $index + 1, $index); 81 | 82 | @for $i from 1 through list.length($list) { 83 | @if $i != $index { 84 | $result: list.append($result, list.nth($list, $i)); 85 | } 86 | } 87 | } 88 | 89 | @return $result; 90 | } 91 | 92 | /// Replaces a substring within another string. 93 | /// @author Hugo Giraudel 94 | /// @param {string} $string String. 95 | /// @param {string} $search Substring. 96 | /// @param {string} $replace Replacement. 97 | /// @return {string} Updated string. 98 | @function str-replace($string, $search, $replace: '') { 99 | $index: string.index($string, $search); 100 | 101 | @if $index { 102 | @return string.slice($string, 1, $index - 1) + $replace + 103 | str-replace(string.slice($string, $index + string.length($search)), $search, $replace); 104 | } 105 | 106 | @return $string; 107 | } 108 | 109 | /// Replaces a substring within each string in a list. 110 | /// @param {list} $strings List of strings. 111 | /// @param {string} $search Substring. 112 | /// @param {string} $replace Replacement. 113 | /// @return {list} Updated list of strings. 114 | @function str-replace-all($strings, $search, $replace: '') { 115 | @each $string in $strings { 116 | $strings: list.set-nth( 117 | $strings, 118 | list.index($strings, $string), 119 | str-replace($string, $search, $replace) 120 | ); 121 | } 122 | 123 | @return $strings; 124 | } 125 | 126 | /// Gets a value from a map. 127 | /// @author Hugo Giraudel 128 | /// @param {map} $map Map. 129 | /// @param {string} $keys Key(s). 130 | /// @return {string} Value. 131 | @function val($map, $keys...) { 132 | @if list.nth($keys, 1) == null { 133 | $keys: remove-nth($keys, 1); 134 | } 135 | 136 | @each $key in $keys { 137 | $map: map.get($map, $key); 138 | } 139 | 140 | @return $map; 141 | } 142 | 143 | // Mixins. 144 | 145 | /// Sets the global box model. 146 | /// @param {string} $model Model (default is content). 147 | @mixin boxModel($model: 'content') { 148 | $x: $model + '-box'; 149 | 150 | *, 151 | *:before, 152 | *:after { 153 | box-sizing: #{$x}; 154 | } 155 | } 156 | 157 | /// Wraps @content in a @media block using a given breakpoint. 158 | /// @param {string} $breakpoint Breakpoint. 159 | /// @param {map} $queries Additional queries. 160 | @mixin breakpoint($breakpoint: null, $queries: null) { 161 | $query: 'screen'; 162 | 163 | // Breakpoint. 164 | @if $breakpoint and map.has-key($breakpoints, $breakpoint) { 165 | $query: $query + ' and ' + map.get($breakpoints, $breakpoint); 166 | } 167 | 168 | // Queries. 169 | @if $queries { 170 | @each $k, $v in $queries { 171 | $query: $query + ' and (' + $k + ':' + $v + ')'; 172 | } 173 | } 174 | 175 | @media #{$query} { 176 | @content; 177 | } 178 | } 179 | 180 | /// Wraps @content in a @media block targeting a specific orientation. 181 | /// @param {string} $orientation Orientation. 182 | @mixin orientation($orientation) { 183 | @media screen and (orientation: #{$orientation}) { 184 | @content; 185 | } 186 | } 187 | 188 | /// Utility mixin for containers. 189 | /// @param {mixed} $width Width. 190 | @mixin containers($width) { 191 | // Locked? 192 | $lock: false; 193 | 194 | @if list.length($width) == 2 { 195 | $width: list.nth($width, 1); 196 | $lock: true; 197 | } 198 | 199 | // Modifiers. 200 | .container.\31 25\25 { 201 | width: 100%; 202 | max-width: $width * 1.25; 203 | min-width: $width; 204 | } 205 | .container.\37 5\25 { 206 | width: $width * 0.75; 207 | } 208 | .container.\35 0\25 { 209 | width: $width * 0.5; 210 | } 211 | .container.\32 5\25 { 212 | width: $width * 0.25; 213 | } 214 | 215 | // Main class. 216 | .container { 217 | @if $lock { 218 | width: $width !important; 219 | } @else { 220 | width: $width; 221 | } 222 | } 223 | } 224 | 225 | /// Utility mixin for grid. 226 | /// @param {list} $gutters Column and row gutters (default is 40px). 227 | /// @param {string} $breakpointName Optional breakpoint name. 228 | @mixin grid($gutters: 40px, $breakpointName: null) { 229 | // Gutters. 230 | @include grid-gutters($gutters); 231 | @include grid-gutters($gutters, \32 00\25, 2); 232 | @include grid-gutters($gutters, \31 50\25, 1.5); 233 | @include grid-gutters($gutters, \35 0\25, 0.5); 234 | @include grid-gutters($gutters, \32 5\25, 0.25); 235 | 236 | // Cells. 237 | $x: ''; 238 | 239 | @if $breakpointName { 240 | $x: '\\28' + $breakpointName + '\\29'; 241 | } 242 | 243 | .\31 2u#{$x}, 244 | .\31 2u\24#{$x} { 245 | width: 100%; 246 | clear: none; 247 | margin-left: 0; 248 | } 249 | .\31 1u#{$x}, 250 | .\31 1u\24#{$x} { 251 | width: 91.6666666667%; 252 | clear: none; 253 | margin-left: 0; 254 | } 255 | .\31 0u#{$x}, 256 | .\31 0u\24#{$x} { 257 | width: 83.3333333333%; 258 | clear: none; 259 | margin-left: 0; 260 | } 261 | .\39 u#{$x}, 262 | .\39 u\24#{$x} { 263 | width: 75%; 264 | clear: none; 265 | margin-left: 0; 266 | } 267 | .\38 u#{$x}, 268 | .\38 u\24#{$x} { 269 | width: 66.6666666667%; 270 | clear: none; 271 | margin-left: 0; 272 | } 273 | .\37 u#{$x}, 274 | .\37 u\24#{$x} { 275 | width: 58.3333333333%; 276 | clear: none; 277 | margin-left: 0; 278 | } 279 | .\36 u#{$x}, 280 | .\36 u\24#{$x} { 281 | width: 50%; 282 | clear: none; 283 | margin-left: 0; 284 | } 285 | .\35 u#{$x}, 286 | .\35 u\24#{$x} { 287 | width: 41.6666666667%; 288 | clear: none; 289 | margin-left: 0; 290 | } 291 | .\34 u#{$x}, 292 | .\34 u\24#{$x} { 293 | width: 33.3333333333%; 294 | clear: none; 295 | margin-left: 0; 296 | } 297 | .\33 u#{$x}, 298 | .\33 u\24#{$x} { 299 | width: 25%; 300 | clear: none; 301 | margin-left: 0; 302 | } 303 | .\32 u#{$x}, 304 | .\32 u\24#{$x} { 305 | width: 16.6666666667%; 306 | clear: none; 307 | margin-left: 0; 308 | } 309 | .\31 u#{$x}, 310 | .\31 u\24#{$x} { 311 | width: 8.3333333333%; 312 | clear: none; 313 | margin-left: 0; 314 | } 315 | 316 | .\31 2u\24#{$x} + *, 317 | .\31 1u\24#{$x} + *, 318 | .\31 0u\24#{$x} + *, 319 | .\39 u\24#{$x} + *, 320 | .\38 u\24#{$x} + *, 321 | .\37 u\24#{$x} + *, 322 | .\36 u\24#{$x} + *, 323 | .\35 u\24#{$x} + *, 324 | .\34 u\24#{$x} + *, 325 | .\33 u\24#{$x} + *, 326 | .\32 u\24#{$x} + *, 327 | .\31 u\24#{$x} + * { 328 | clear: left; 329 | } 330 | 331 | .\-11u#{$x} { 332 | margin-left: 91.6666666667%; 333 | } 334 | .\-10u#{$x} { 335 | margin-left: 83.3333333333%; 336 | } 337 | .\-9u#{$x} { 338 | margin-left: 75%; 339 | } 340 | .\-8u#{$x} { 341 | margin-left: 66.6666666667%; 342 | } 343 | .\-7u#{$x} { 344 | margin-left: 58.3333333333%; 345 | } 346 | .\-6u#{$x} { 347 | margin-left: 50%; 348 | } 349 | .\-5u#{$x} { 350 | margin-left: 41.6666666667%; 351 | } 352 | .\-4u#{$x} { 353 | margin-left: 33.3333333333%; 354 | } 355 | .\-3u#{$x} { 356 | margin-left: 25%; 357 | } 358 | .\-2u#{$x} { 359 | margin-left: 16.6666666667%; 360 | } 361 | .\-1u#{$x} { 362 | margin-left: 8.3333333333%; 363 | } 364 | } 365 | 366 | /// Utility mixin for grid. 367 | /// @param {list} $gutters Gutters. 368 | /// @param {string} $class Optional class name. 369 | /// @param {integer} $multiplier Multiplier (default is 1). 370 | @mixin grid-gutters($gutters, $class: null, $multiplier: 1) { 371 | // Expand gutters if it's not a list. 372 | @if list.length($gutters) == 1 { 373 | $gutters: ($gutters, 0); 374 | } 375 | 376 | // Get column and row gutter values. 377 | $c: list.nth($gutters, 1); 378 | $r: list.nth($gutters, 2); 379 | 380 | // Get class (if provided). 381 | $x: ''; 382 | 383 | @if $class { 384 | $x: '.' + $class; 385 | } 386 | 387 | // Default. 388 | .row#{$x} > * { 389 | padding: ($r * $multiplier) 0 0 ($c * $multiplier); 390 | } 391 | .row#{$x} { 392 | margin: ($r * $multiplier * -1) 0 -1px ($c * $multiplier * -1); 393 | } 394 | 395 | // Uniform. 396 | .row.uniform#{$x} > * { 397 | padding: ($c * $multiplier) 0 0 ($c * $multiplier); 398 | } 399 | .row.uniform#{$x} { 400 | margin: ($c * $multiplier * -1) 0 -1px ($c * $multiplier * -1); 401 | } 402 | } 403 | 404 | /// Wraps @content in vendorized keyframe blocks. 405 | /// @param {string} $name Name. 406 | @mixin keyframes($name) { 407 | @keyframes #{$name} { 408 | @content; 409 | } 410 | } 411 | 412 | /// 413 | /// Sets breakpoints. 414 | /// @param {map} $x Breakpoints. 415 | /// 416 | @mixin skel-breakpoints($x: ()) { 417 | $breakpoints: $x !global; 418 | } 419 | 420 | /// 421 | /// Initializes layout module. 422 | /// @param {map} config Config. 423 | /// 424 | @mixin skel-layout($config: ()) { 425 | // Config. 426 | $configPerBreakpoint: (); 427 | 428 | $z: map.get($config, 'breakpoints'); 429 | 430 | @if $z { 431 | $configPerBreakpoint: $z; 432 | } 433 | 434 | // Reset. 435 | $x: map.get($config, 'reset'); 436 | 437 | @if $x { 438 | /* Reset */ 439 | 440 | @include reset($x); 441 | } 442 | 443 | // Box model. 444 | $x: map.get($config, 'boxModel'); 445 | 446 | @if $x { 447 | /* Box Model */ 448 | 449 | @include boxModel($x); 450 | } 451 | 452 | // Containers. 453 | $containers: map.get($config, 'containers'); 454 | 455 | @if $containers { 456 | /* Containers */ 457 | 458 | .container { 459 | margin-left: auto; 460 | margin-right: auto; 461 | } 462 | 463 | // Use default is $containers is just "true". 464 | @if $containers == true { 465 | $containers: 960px; 466 | } 467 | 468 | // Apply base. 469 | @include containers($containers); 470 | 471 | // Apply per-breakpoint. 472 | @each $name in map.keys($breakpoints) { 473 | // Get/use breakpoint setting if it exists. 474 | $x: map.get($configPerBreakpoint, $name); 475 | 476 | // Per-breakpoint config exists? 477 | @if $x { 478 | $y: map.get($x, 'containers'); 479 | 480 | // Setting exists? Use it. 481 | @if $y { 482 | $containers: $y; 483 | } 484 | } 485 | 486 | // Create @media block. 487 | @media screen and #{map.get($breakpoints, $name)} { 488 | @include containers($containers); 489 | } 490 | } 491 | } 492 | 493 | // Grid. 494 | $grid: map.get($config, 'grid'); 495 | 496 | @if $grid { 497 | /* Grid */ 498 | 499 | // Use defaults if $grid is just "true". 500 | @if $grid == true { 501 | $grid: (); 502 | } 503 | 504 | // Sub-setting: Gutters. 505 | $grid-gutters: 40px; 506 | $x: map.get($grid, 'gutters'); 507 | 508 | @if $x { 509 | $grid-gutters: $x; 510 | } 511 | 512 | // Rows. 513 | .row { 514 | border-bottom: solid 1px transparent; 515 | box-sizing: border-box; 516 | } 517 | 518 | .row > * { 519 | float: left; 520 | box-sizing: border-box; 521 | } 522 | 523 | .row:after, 524 | .row:before { 525 | content: ''; 526 | display: block; 527 | clear: both; 528 | height: 0; 529 | } 530 | 531 | .row.uniform > * > :first-child { 532 | margin-top: 0; 533 | } 534 | 535 | .row.uniform > * > :last-child { 536 | margin-bottom: 0; 537 | } 538 | 539 | // Gutters (0%). 540 | @include grid-gutters($grid-gutters, \30 \25, 0); 541 | 542 | // Apply base. 543 | @include grid($grid-gutters); 544 | 545 | // Apply per-breakpoint. 546 | @each $name in map.keys($breakpoints) { 547 | // Get/use breakpoint setting if it exists. 548 | $x: map.get($configPerBreakpoint, $name); 549 | 550 | // Per-breakpoint config exists? 551 | @if $x { 552 | $y: map.get($x, 'grid'); 553 | 554 | // Setting exists? 555 | @if $y { 556 | // Sub-setting: Gutters. 557 | $x: map.get($y, 'gutters'); 558 | 559 | @if $x { 560 | $grid-gutters: $x; 561 | } 562 | } 563 | } 564 | 565 | // Create @media block. 566 | @media screen and #{map.get($breakpoints, $name)} { 567 | @include grid($grid-gutters, $name); 568 | } 569 | } 570 | } 571 | } 572 | 573 | /// Resets browser styles. 574 | /// @param {string} $mode Mode (default is 'normalize'). 575 | @mixin reset($mode: 'normalize') { 576 | @if $mode == 'normalize' { 577 | // normalize.css v3.0.2 | MIT License | git.io/normalize 578 | html { 579 | font-family: sans-serif; 580 | -webkit-text-size-adjust: 100%; 581 | } 582 | body { 583 | margin: 0; 584 | } 585 | article, 586 | aside, 587 | details, 588 | figcaption, 589 | figure, 590 | footer, 591 | header, 592 | hgroup, 593 | main, 594 | menu, 595 | nav, 596 | section, 597 | summary { 598 | display: block; 599 | } 600 | audio, 601 | canvas, 602 | progress, 603 | video { 604 | display: inline-block; 605 | vertical-align: baseline; 606 | } 607 | audio:not([controls]) { 608 | display: none; 609 | height: 0; 610 | } 611 | [hidden], 612 | template { 613 | display: none; 614 | } 615 | a { 616 | background-color: transparent; 617 | } 618 | a:active, 619 | a:hover { 620 | outline: 0; 621 | } 622 | abbr[title] { 623 | border-bottom: 1px dotted; 624 | } 625 | b, 626 | strong { 627 | font-weight: 700; 628 | } 629 | dfn { 630 | font-style: italic; 631 | } 632 | h1 { 633 | font-size: 2em; 634 | margin: 0.67em 0; 635 | } 636 | mark { 637 | background: #ff0; 638 | color: #000; 639 | } 640 | small { 641 | font-size: 80%; 642 | } 643 | sub, 644 | sup { 645 | font-size: 75%; 646 | line-height: 0; 647 | position: relative; 648 | vertical-align: baseline; 649 | } 650 | sup { 651 | top: -0.5em; 652 | } 653 | sub { 654 | bottom: -0.25em; 655 | } 656 | img { 657 | border: 0; 658 | } 659 | svg:not(:root) { 660 | overflow: hidden; 661 | } 662 | figure { 663 | margin: 1em 40px; 664 | } 665 | hr { 666 | box-sizing: content-box; 667 | height: 0; 668 | } 669 | pre { 670 | overflow: auto; 671 | } 672 | code, 673 | kbd, 674 | pre, 675 | samp { 676 | font-family: monospace, monospace; 677 | font-size: 1em; 678 | } 679 | button, 680 | input, 681 | optgroup, 682 | select, 683 | textarea { 684 | color: inherit; 685 | font: inherit; 686 | margin: 0; 687 | } 688 | button { 689 | overflow: visible; 690 | } 691 | button, 692 | select { 693 | text-transform: none; 694 | } 695 | button, 696 | html input[type='button'], 697 | input[type='reset'], 698 | input[type='submit'] { 699 | -webkit-appearance: button; 700 | cursor: pointer; 701 | } 702 | button[disabled], 703 | html input[disabled] { 704 | cursor: default; 705 | } 706 | button::-moz-focus-inner, 707 | input::-moz-focus-inner { 708 | border: 0; 709 | padding: 0; 710 | } 711 | input { 712 | line-height: normal; 713 | } 714 | input[type='checkbox'], 715 | input[type='radio'] { 716 | box-sizing: border-box; 717 | padding: 0; 718 | } 719 | input[type='number']::-webkit-inner-spin-button, 720 | input[type='number']::-webkit-outer-spin-button { 721 | height: auto; 722 | } 723 | input[type='search'] { 724 | -webkit-appearance: textfield; 725 | box-sizing: content-box; 726 | } 727 | input[type='search']::-webkit-search-cancel-button, 728 | input[type='search']::-webkit-search-decoration { 729 | -webkit-appearance: none; 730 | } 731 | fieldset { 732 | border: 1px solid silver; 733 | margin: 0 2px; 734 | padding: 0.35em 0.625em 0.75em; 735 | } 736 | legend { 737 | border: 0; 738 | padding: 0; 739 | } 740 | textarea { 741 | overflow: auto; 742 | } 743 | optgroup { 744 | font-weight: 700; 745 | } 746 | table { 747 | border-collapse: collapse; 748 | border-spacing: 0; 749 | } 750 | td, 751 | th { 752 | padding: 0; 753 | } 754 | } @else if $mode == 'full' { 755 | // meyerweb.com/eric/tools/css/reset v2.0 | 20110126 | License: none (public domain) 756 | html, 757 | body, 758 | div, 759 | span, 760 | applet, 761 | object, 762 | iframe, 763 | h1, 764 | h2, 765 | h3, 766 | h4, 767 | h5, 768 | h6, 769 | p, 770 | blockquote, 771 | pre, 772 | a, 773 | abbr, 774 | acronym, 775 | address, 776 | big, 777 | cite, 778 | code, 779 | del, 780 | dfn, 781 | em, 782 | img, 783 | ins, 784 | kbd, 785 | q, 786 | s, 787 | samp, 788 | small, 789 | strike, 790 | strong, 791 | sub, 792 | sup, 793 | tt, 794 | var, 795 | b, 796 | u, 797 | i, 798 | center, 799 | dl, 800 | dt, 801 | dd, 802 | ol, 803 | ul, 804 | li, 805 | fieldset, 806 | form, 807 | label, 808 | legend, 809 | table, 810 | caption, 811 | tbody, 812 | tfoot, 813 | thead, 814 | tr, 815 | th, 816 | td, 817 | article, 818 | aside, 819 | canvas, 820 | details, 821 | embed, 822 | figure, 823 | figcaption, 824 | footer, 825 | header, 826 | hgroup, 827 | menu, 828 | nav, 829 | output, 830 | ruby, 831 | section, 832 | summary, 833 | time, 834 | mark, 835 | audio, 836 | video { 837 | margin: 0; 838 | padding: 0; 839 | border: 0; 840 | font-size: 100%; 841 | font: inherit; 842 | vertical-align: baseline; 843 | } 844 | article, 845 | aside, 846 | details, 847 | figcaption, 848 | figure, 849 | footer, 850 | header, 851 | hgroup, 852 | menu, 853 | nav, 854 | section { 855 | display: block; 856 | } 857 | body { 858 | line-height: 1; 859 | } 860 | ol, 861 | ul { 862 | list-style: none; 863 | } 864 | blockquote, 865 | q { 866 | quotes: none; 867 | } 868 | blockquote:before, 869 | blockquote:after, 870 | q:before, 871 | q:after { 872 | content: ''; 873 | content: none; 874 | } 875 | table { 876 | border-collapse: collapse; 877 | border-spacing: 0; 878 | } 879 | body { 880 | -webkit-text-size-adjust: none; 881 | } 882 | } 883 | } 884 | 885 | /// Vendorizes a declaration's property and/or value(s). 886 | /// @param {string} $property Property. 887 | /// @param {mixed} $value String/list of value(s). 888 | @mixin vendor($property, $value) { 889 | // Determine if property should expand. 890 | $expandProperty: list.index($vendor-properties, $property); 891 | 892 | // Determine if value should expand (and if so, add '-prefix-' placeholder). 893 | $expandValue: false; 894 | 895 | @each $x in $value { 896 | @each $y in $vendor-values { 897 | @if $y == string.slice($x, 1, string.length($y)) { 898 | $value: list.set-nth($value, list.index($value, $x), '-prefix-' + $x); 899 | $expandValue: true; 900 | } 901 | } 902 | } 903 | 904 | // Expand property? 905 | @if $expandProperty { 906 | @each $vendor in $vendor-prefixes { 907 | #{$vendor}#{$property}: #{str-replace-all($value, '-prefix-', $vendor)}; 908 | } 909 | } 910 | 911 | // Expand just the value? 912 | @else if $expandValue { 913 | @each $vendor in $vendor-prefixes { 914 | #{$property}: #{str-replace-all($value, '-prefix-', $vendor)}; 915 | } 916 | } 917 | 918 | // Neither? Treat them as a normal declaration. 919 | @else { 920 | #{$property}: #{$value}; 921 | } 922 | } 923 | --------------------------------------------------------------------------------