├── .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 |
8 | {data.map((s) => (
9 |
10 |
11 |
12 |
13 |
14 | ))}
15 |
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 |
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 | handleClick(label)}
18 | >
19 | {label}
20 |
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 |
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 |
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 |
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 |
19 |
20 | {dayjs(data.date).format('MMMM, YYYY')}
21 |
22 |
23 |
24 |
25 |
26 |
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 |
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 |
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 |
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 |
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 |
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 |
17 |
32 |
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 |
39 | ))}
40 |
41 |
42 |
43 |
44 |
48 |
49 |
53 |
54 |
58 |
59 |
63 |
64 |
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 |
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 |
--------------------------------------------------------------------------------