├── src ├── react-app-env.d.ts ├── index.css ├── components │ ├── Banner.tsx │ ├── TerminalOutput.tsx │ ├── ErrorMessage.tsx │ ├── WelcomeMessage.tsx │ ├── InputArea.tsx │ └── Terminal.tsx ├── index.tsx ├── firebase.ts ├── App.tsx ├── App.css └── serviceWorker.ts ├── .firebaserc ├── README.md ├── public ├── CV.pdf ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── MSc_Thesis.pdf ├── manifest.json └── index.html ├── firebase.json ├── .gitignore ├── tsconfig.json └── package.json /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "personal-website-6a2a9" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Personal Website 2 | 3 | My personal website built using React and TypeScript. 4 | -------------------------------------------------------------------------------- /public/CV.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craig-feldman/personal-website-react/HEAD/public/CV.pdf -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craig-feldman/personal-website-react/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craig-feldman/personal-website-react/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craig-feldman/personal-website-react/HEAD/public/logo512.png -------------------------------------------------------------------------------- /public/MSc_Thesis.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craig-feldman/personal-website-react/HEAD/public/MSc_Thesis.pdf -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; 5 | } 6 | 7 | body, 8 | html, 9 | #root { 10 | height: 100%; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Banner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type BannerProps = { 4 | banner: string; 5 | }; 6 | const Banner = (props: BannerProps) => ( 7 |
{props.banner}
8 | ); 9 | 10 | export default Banner; 11 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 5 | "rewrites": [ 6 | { 7 | "source": "**", 8 | "destination": "/index.html" 9 | } 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/TerminalOutput.tsx: -------------------------------------------------------------------------------- 1 | import React, { JSX } from "react"; 2 | 3 | type OutputProps = { 4 | outputs: (string | JSX.Element)[]; 5 | }; 6 | const TerminalOutput = (props: OutputProps) => { 7 | const outputList = props.outputs.map((o, key) =>
{o}
); 8 | return <>{outputList}; 9 | }; 10 | 11 | export default TerminalOutput; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .firebase 26 | firebase-debug.log 27 | -------------------------------------------------------------------------------- /src/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type ErrorMessageProps = { 4 | command: string; 5 | }; 6 | const ErrorMessage = (props: ErrorMessageProps) => { 7 | return ( 8 |
9 | 10 | {`command not found: ${props.command}.`} 11 | 12 | {`Type 'help' to view a list of available commands`} 13 |
14 | ); 15 | }; 16 | 17 | export default ErrorMessage; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Craig Feldman", 3 | "name": "Craig Feldman's website", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#171717" 25 | } 26 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | import * as serviceWorker from "./serviceWorker"; 6 | 7 | const container = document.getElementById("root"); 8 | const root = createRoot(container!); 9 | root.render( 10 | 11 | 12 | 13 | ); 14 | 15 | // If you want your app to work offline and load faster, you can change 16 | // unregister() to register() below. Note this comes with some pitfalls. 17 | // Learn more about service workers: https://bit.ly/CRA-PWA 18 | serviceWorker.unregister(); 19 | -------------------------------------------------------------------------------- /src/firebase.ts: -------------------------------------------------------------------------------- 1 | import { initializeApp } from "firebase/app"; 2 | import { getAnalytics } from "firebase/analytics"; 3 | 4 | const firebaseConfig = { 5 | apiKey: "AIzaSyC2B7qHjCFzv1Aq5kfjIbIe_psLG35iskc", 6 | authDomain: "personal-website-6a2a9.firebaseapp.com", 7 | databaseURL: "https://personal-website-6a2a9.firebaseio.com", 8 | projectId: "personal-website-6a2a9", 9 | storageBucket: "personal-website-6a2a9.appspot.com", 10 | messagingSenderId: "280241671766", 11 | appId: "1:280241671766:web:bc863440cededa05f075f7", 12 | measurementId: "G-H7KL5J0KG8", 13 | }; 14 | 15 | // Initialize Firebase 16 | const app = initializeApp(firebaseConfig); 17 | const analytics = getAnalytics(app); 18 | 19 | export { analytics }; 20 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 17 | 18 | 19 | craigfeldman.com 20 | 21 | 22 | 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "craigfeldman.com", 3 | "private": true, 4 | "dependencies": { 5 | "@types/node": "^24.3.3", 6 | "@types/react": "^19.1.13", 7 | "@types/react-dom": "^19.1.9", 8 | "firebase": "^12.2.1", 9 | "react": "^19.1.1", 10 | "react-dom": "^19.1.1", 11 | "react-scripts": "5.0.1", 12 | "typescript": "^5.9.2" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "deploy": "npm run build && firebase deploy", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": "react-app" 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./App.css"; 3 | import Terminal from "./components/Terminal"; 4 | 5 | const getYear = () => { 6 | return new Date().getFullYear(); 7 | }; 8 | 9 | const welcomeMessage = `Welcome to my site fellow humans and bots. 10 | 11 | Type 'help' to view a list of available commands. 12 | `; 13 | 14 | const bannerCondensed = 15 | " _ __ _ _ \n" + 16 | " __ _ _ __ _(_)__ _ / _|___| |__| |_ __ __ _ _ _ \n" + 17 | "/ _| '_/ _` | / _` | | _/ -_) / _` | ' \\/ _` | ' \\ \n" + 18 | "\\__|_| \\__,_|_\\__, | |_| \\___|_\\__,_|_|_|_\\__,_|_||_|\n " + 19 | " |___/ \n" + 20 | " \u00A9 " + 21 | getYear(); 22 | 23 | const prompt = ">"; 24 | 25 | function App() { 26 | return ( 27 | 32 | ); 33 | } 34 | 35 | export default App; 36 | -------------------------------------------------------------------------------- /src/components/WelcomeMessage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | 3 | type WelcomeMessageProps = { 4 | message: string; 5 | inputRef: React.RefObject; 6 | }; 7 | const WelcomeMessage = (props: WelcomeMessageProps) => { 8 | const welcomeMessageRef = React.useRef(null); 9 | useEffect(() => { 10 | if (welcomeMessageRef.current) { 11 | // Clear previous text 12 | welcomeMessageRef.current.textContent = ""; 13 | } 14 | if (props.inputRef.current) { 15 | props.inputRef.current.disabled = true; 16 | } 17 | let index = 0; 18 | const typeText = setInterval(() => { 19 | if (!welcomeMessageRef.current) { 20 | return; 21 | } 22 | welcomeMessageRef.current.insertAdjacentText( 23 | "beforeend", 24 | props.message[index++] 25 | ); 26 | if (index === props.message.length) { 27 | clearInterval(typeText); 28 | if (props.inputRef.current) { 29 | props.inputRef.current.disabled = false; 30 | props.inputRef.current.focus(); 31 | } 32 | } 33 | }, 30); 34 | return () => clearInterval(typeText); // Cleanup on unmount to prevent overlapping intervals 35 | }, [props.inputRef, props.message]); 36 | return ( 37 |
38 | ); 39 | }; 40 | 41 | export default WelcomeMessage; 42 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .terminal-container { 2 | height: 100%; 3 | overflow-y: auto; 4 | overflow-x: hidden; 5 | cursor: text; 6 | background-color: #171717; 7 | } 8 | 9 | .terminal-content { 10 | padding: 20px; 11 | font-size: 15px; 12 | line-height: 20px; 13 | white-space: pre-wrap; 14 | color: #aaa; 15 | font-family: monospace; 16 | display: flex; 17 | flex-direction: column; 18 | } 19 | 20 | .terminal-banner { 21 | color: teal; 22 | text-shadow: 0 0 5px teal; 23 | line-height: normal; 24 | font-weight: bold; 25 | font-size: calc(1vw + 7px); 26 | margin-bottom: 20px; 27 | } 28 | @media only screen and (max-width: 400px) { 29 | .terminal-banner { 30 | font-size: 8px; 31 | } 32 | } 33 | 34 | @media only screen and (max-width: 300px) { 35 | .terminal-banner { 36 | font-size: 6px; 37 | } 38 | } 39 | 40 | .terminal-welcome-message { 41 | margin-bottom: 20px; 42 | } 43 | 44 | .terminal-command-output { 45 | padding: 10px; 46 | max-width: 800px; 47 | } 48 | 49 | .terminal-command-output dt { 50 | color: #eeeeee; 51 | text-shadow: 0 0 4px #eeeeee; 52 | } 53 | 54 | .terminal-command-output dd { 55 | margin-inline-start: 20px; 56 | } 57 | 58 | .terminal-command-output dd:not(:last-child) { 59 | margin-block-end: 0.3em; 60 | } 61 | 62 | .terminal-command-output dd::before { 63 | content: "- "; 64 | } 65 | 66 | .terminal-command-output ul { 67 | margin-top: 0; 68 | } 69 | 70 | .terminal-command-record { 71 | scroll-margin: 15px; 72 | } 73 | 74 | .terminal-input-area { 75 | display: inline-flex; 76 | width: 100%; 77 | align-items: center; 78 | } 79 | 80 | .terminal-prompt { 81 | margin-right: 5px; 82 | } 83 | 84 | .terminal-heading::before { 85 | margin-right: 5px; 86 | content: '-- ' 87 | } 88 | 89 | .terminal-input { 90 | font-family: inherit; 91 | font-size: inherit; 92 | font-size: inherit; 93 | color: rgb(240, 191, 129); 94 | background: transparent; 95 | border: 0px; 96 | outline: none; /* no highlight on focus */ 97 | width: 100%; 98 | } 99 | 100 | .terminal-error-group { 101 | display: flex; 102 | flex-direction: column; 103 | } 104 | 105 | .terminal-error { 106 | color: red; 107 | } 108 | 109 | .terminal-glow { 110 | color: #eeeeee; 111 | text-shadow: 0 0 4px #eeeeee; 112 | } 113 | 114 | a { 115 | color: #dea5f5; 116 | } 117 | a:hover { 118 | color: black; 119 | background-color: #c4a5f5; 120 | text-decoration: none; 121 | } 122 | -------------------------------------------------------------------------------- /src/components/InputArea.tsx: -------------------------------------------------------------------------------- 1 | import React, { JSX, useState } from "react"; 2 | 3 | type InputAreaProps = { 4 | terminalPrompt: string; 5 | setOutput: React.Dispatch>; 6 | processCommand: (input: string) => void; 7 | getHistory: (direction: "up" | "down") => string; 8 | getAutocomplete: (input: string) => string; 9 | inputRef: React.RefObject; 10 | }; 11 | const InputArea = (props: InputAreaProps) => { 12 | const [input, setInput] = useState(""); 13 | /** 14 | * Sets the input state to the value of the input. 15 | * 16 | * If the input is a period, we get the autocomplete for the input up to, but excluding the period. 17 | * We handle this case specially to allow autocomplete on mobile because KeyboardEvent.key tends to be 'unidentified' for inputs on mobile keyboards. 18 | */ 19 | const handleInputChange = (event: React.ChangeEvent) => { 20 | const inputValue = event.target.value; 21 | if (inputValue.charAt(inputValue.length - 1) === ".") { 22 | setInput( 23 | props.getAutocomplete(inputValue.substring(0, inputValue.length - 1)) 24 | ); 25 | } else setInput(inputValue); 26 | }; 27 | const handleInputKeyDown = (event: React.KeyboardEvent) => { 28 | switch (event.key) { 29 | case "Enter": 30 | props.processCommand(input); 31 | setInput(""); 32 | break; 33 | case "ArrowUp": 34 | event.preventDefault(); 35 | setInput(props.getHistory("up")); 36 | break; 37 | case "ArrowDown": 38 | event.preventDefault(); 39 | setInput(props.getHistory("down")); 40 | break; 41 | case "Tab": 42 | // Provide autocomplete on tab. For mobile, we have to handle autocomplete in the input's onChange event. 43 | event.preventDefault(); 44 | setInput(props.getAutocomplete(input)); 45 | break; 46 | } 47 | }; 48 | return ( 49 |
50 | {props.terminalPrompt} 51 | 63 |
64 | ); 65 | }; 66 | 67 | export default InputArea; 68 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl, { 112 | headers: { 'Service-Worker': 'script' } 113 | }) 114 | .then(response => { 115 | // Ensure service worker exists, and that we really are getting a JS file. 116 | const contentType = response.headers.get('content-type'); 117 | if ( 118 | response.status === 404 || 119 | (contentType != null && contentType.indexOf('javascript') === -1) 120 | ) { 121 | // No service worker found. Probably a different app. Reload the page. 122 | navigator.serviceWorker.ready.then(registration => { 123 | registration.unregister().then(() => { 124 | window.location.reload(); 125 | }); 126 | }); 127 | } else { 128 | // Service worker found. Proceed as normal. 129 | registerValidSW(swUrl, config); 130 | } 131 | }) 132 | .catch(() => { 133 | console.log( 134 | 'No internet connection found. App is running in offline mode.' 135 | ); 136 | }); 137 | } 138 | 139 | export function unregister() { 140 | if ('serviceWorker' in navigator) { 141 | navigator.serviceWorker.ready 142 | .then(registration => { 143 | registration.unregister(); 144 | }) 145 | .catch(error => { 146 | console.error(error.message); 147 | }); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/components/Terminal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, JSX } from "react"; 2 | import Banner from "./Banner"; 3 | import TerminalOutput from "./TerminalOutput"; 4 | import InputArea from "./InputArea"; 5 | import ErrorMessage from "./ErrorMessage"; 6 | import WelcomeMessage from "./WelcomeMessage"; 7 | import { logEvent } from "firebase/analytics"; 8 | import { analytics } from "../firebase"; 9 | 10 | // Just a little helper function so I don't have to continually update my age 11 | const getAge = (birthDate: Date) => { 12 | var today = new Date(); 13 | var age = today.getFullYear() - birthDate.getFullYear(); 14 | var m = today.getMonth() - birthDate.getMonth(); 15 | if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) { 16 | age--; 17 | } 18 | return age; 19 | }; 20 | 21 | const downloadFile = (uri: string, downloadName: string) => { 22 | const link = document.createElement("a"); 23 | link.download = downloadName; 24 | link.href = uri; 25 | link.click(); 26 | link.remove(); 27 | }; 28 | 29 | type TerminalProps = { 30 | terminalPrompt?: string; 31 | banner?: string; 32 | welcomeMessage?: string; 33 | }; 34 | const Terminal = (props: TerminalProps) => { 35 | const { terminalPrompt = ">", banner, welcomeMessage } = props; 36 | const [output, setOutput] = useState<(string | JSX.Element)[]>([]); 37 | const [history, setHistory] = useState([]); 38 | const [historyIndex, setHistoryIndex] = useState(3); 39 | const inputRef = React.useRef(null); 40 | const scrollRef = React.useRef(null); 41 | 42 | const scrollLastCommandTop = () => { 43 | scrollRef.current?.scrollIntoView(); 44 | }; 45 | 46 | useEffect(scrollLastCommandTop, [output]); 47 | 48 | const echoCommands = [ 49 | "help", 50 | "about", 51 | "employment", 52 | "projects", 53 | "contact", 54 | "awards", 55 | "repo", 56 | "skills", 57 | "website", 58 | ] as const; 59 | type EchoCommand = (typeof echoCommands)[number]; 60 | const utilityCommands = ["clear", "all", "cv"] as const; 61 | type UtilityCommand = (typeof utilityCommands)[number]; 62 | const allCommands = [...echoCommands, ...utilityCommands] as const; 63 | type Command = (typeof allCommands)[number]; 64 | 65 | function isEchoCommand(arg: string): arg is EchoCommand { 66 | return (echoCommands as ReadonlyArray).includes(arg); 67 | } 68 | 69 | function isUtilityCommand(arg: string): arg is UtilityCommand { 70 | return (utilityCommands as ReadonlyArray).includes(arg); 71 | } 72 | 73 | function isValidCommand(arg: string): arg is Command { 74 | return isEchoCommand(arg) || isUtilityCommand(arg); 75 | } 76 | 77 | const glow = (text: string) => { 78 | return {text}; 79 | }; 80 | 81 | const commands: { [key in EchoCommand]: JSX.Element } = { 82 | help: ( 83 | <> 84 |

85 | Wow, I thought the only people who would visit this site would be bots 86 | and spammers, guess I was wrong. Just type any of the commands below 87 | to get some more info. You can even type a few letters and press [tab] 88 | or '.' to autocomplete. 89 |

90 |
91 |
about
92 |
Stop stalking me
93 |
employment
94 |
Where I've worked
95 |
projects
96 |
Yeah, I've made some cool stuff before
97 |
skills
98 |
I'm pretty good at some things
99 |
awards
100 |
A bit of boasting
101 |
repo
102 |
Take a look at some of my work
103 |
cv
104 |
Check out my CV [pdf - 103KB]
105 |
contact
106 |
Bring on the spam
107 |
website
108 |
How I built this
109 |
all
110 |
Tell me everything
111 |
clear
112 |
Clears the terminal of all output
113 |
114 | 115 | ), 116 | about: ( 117 | <> 118 |
Intro
119 |

120 | Hey there! Thanks for taking such a keen interest in me. Hopefully 121 | you're not gonna spam or stalk me... Okay, I guess if you must stalk 122 | me, just give me fair warning so I can look presentable when you 123 | arrive at my door. 124 |

125 |

126 | Right, so, where to begin? Well, my parents met in... Nah, just 127 | kidding. 128 |
129 | As you probably know, my name is {glow("Craig Feldman")}. I'm a{" "} 130 | {getAge(new Date(1992, 12, 23))} year old {glow("Computer Scientist")}{" "} 131 | born and bred in the beautiful South Africa and currently living in 132 | Cape Town. I primarily work as a backend endgineer in the{" "} 133 | {glow("fintech")} space, but lately have been working as a platform 134 | engineer. 135 |

136 |

137 | In my spare time, you'll catch me walking my dog, Daisy, on the 138 | promenade or mountain. If I'm not with Daisy, I'm probably at the gym 139 | or trying out a new coffee shop with some friends. 140 |

141 | 142 |
Motivation
143 |

144 | I thrive in complex problem spaces, where deliberate design and 145 | scalable solutions deliver meaningful impact. While I feel that I have 146 | a lot to offer, I am fully cognisant of the fact that the way to 147 | better oneself (and the end product) is to be surrounded with 148 | exceptional peers from whom one can learn from, but at the same time 149 | contribute to. As such, what I value most is to be able to make 150 | meaningful contributions and work on challenging projects with 151 | talented colleagues. 152 |

153 | 154 |
Education
155 |

156 | I completed a Bachelor of Business Science degree (specialising in 157 | Computer Science) at the University of Cape Town, South Africa where I 158 | also completed a number of additional courses in fields such as 159 | finance, statistics, and economics. I graduated in the top 1% of the 160 | cohort across disciplines and furthered my academic career in 2016 161 | when I was awarded a full academic scholarship to study an MSc in 162 | Computer Science at the University of Oxford. At Oxford, I studied a 163 | variety of courses with a focus on machine learning and computer 164 | security. My thesis involved investigating the applicability of a 165 | blockchain based electronic voting system. 166 |

167 |

168 | At university, I took a particularly keen interest in machine learning 169 | and computer security & cryptography. Oh, I also got very into rowing 170 | at and I'm pretty sure I left Oxford with my croquet skills being 171 | top-notch too! 172 |

173 | 174 |
Experience
175 |

176 | I have worked across several different domains, including finance, 177 | payments, cryptocurrency, and an image and content creation app. I am 178 | proficient across the full stack, with primary expertise in{" "} 179 | {glow("backend architecture, and platform and scalability")}. Most 180 | recently, I manage the platform team for one of South Africa's largest 181 | payments service providers. 182 |

183 |

184 | My experience includes working within highly specialised and focused 185 | teams, as well as working on cross functional projects with looser 186 | team structures and rapidly evolving requirements. I have worked as an 187 | engineer as well as a technical and team lead and have been a key 188 | stakeholder in driving design and architectural decisions across the 189 | companies I have worked at. I am fortunate to have joined several 190 | companies at the early stage and have worked in fast-paced and dynamic 191 | environments. Having been a key stakeholder in the growth of these 192 | companies, I also understand how goals and metrics shift as a company 193 | develops. My most recent role involves ensuring efficient and 194 | effective scalability and growth from a technical perspective. 195 |

196 | 197 | My previous formal work experience includes: 198 | 233 | 234 |

235 | Nowadays I'm developing a method to download food... I wish! I am 236 | currently working at{" "} 237 | 242 | Stitch 243 | 244 | , helping to develop South Africa's leading payment's platform. To see 245 | detailed info about my {glow("experience and employment")}, just type 246 | 'employment'. 247 |

248 |

249 | Please feel free to get in touch with me to discuss any cool 250 | opportunities. My contact details can be found by typing 'contact', 251 | and if you would like to check out my {glow("CV")}, simply type 'cv' 252 | or click{" "} 253 | 254 | here 255 | 256 | . 257 |

258 | 259 | ), 260 | employment: ( 261 | <> 262 |
263 | 268 | Stitch 269 | {" "} 270 | (2011—present) 271 |
272 |

273 | Stitch is one of South Africa's largest payment service providers. 274 | Stitch operates across the online and in-person payments space, 275 | including solutions designed for e-commerce platforms and tools for 276 | more effective finance management. 277 |

278 |

279 | Development is mainly in a nodejs (Typescript) environment. There is 280 | some C# and React on the frontend and the overriding architecture is a 281 | monorepo of microservices - driven by docker/kubernetes. 282 | Infrastructure is predominantly hosted with Microsoft Azure. Other 283 | noteworthy tools and frameworks include: an event’s driven 284 | architecture, leveraging Redis, Kafka and BullMQ, Hasura, Postgres, 285 | Tailscale, Tilt, GraphQL, Helm, Datadog, and Terraform (amongst 286 | others). 287 |

288 |

289 | I joined Stitch when it was an early stage startup and helped scale it 290 | from ~500 daily payments to tens of thousands. Being one of the most 291 | tenured engineers at Stitch, I have worked on a variety of projects 292 | and within a variety of roles, including: 293 |

294 |
    295 |
  • engineering team lead;
  • 296 |
  • 297 | bank/api integrations (both reverse engineering and host-to-host 298 | payments); 299 |
  • 300 |
  • platform engineering;
  • 301 |
  • 302 | developing a self-service onboarding and management dashboard; 303 |
  • 304 |
  • 305 | leading and developing a complete architectural shift in how we 306 | manage clients and their configuration; 307 |
  • 308 |
309 |

310 | In my role as a team lead, I was responsible for the ground-up 311 | development of an enterprise-grade reporting dashboard. This allows 312 | our enterprise clients to easily view and manage their accounts and 313 | transactions. Another major project was the development of a 314 | self-service onboarding and management platform that allows SMEs to 315 | sign up with Stitch and create and track payments - all without ever 316 | having to speak to the sales team! Under my leadership, this project 317 | spun off into its own business domain, generating more than 318 | $100k/month. 319 |

320 |

321 | For the past two years, I have been a key member of the platform team. 322 | The work here involves developer and tech enablement and includes: 323 | scaling, improving the CI/CD process, addressing performance 324 | bottlenecks, improving the developer experience, monitoring and 325 | alerting, etc. Some of my key work here involves effective and 326 | efficient scaling - allowing us to handle hundreds of requests per 327 | second whilst also improving the efficiency of our infrastructure 328 | usage (finding cost savings of over $15k/month). Other, perhaps more 329 | mundane but equally important tasks, involve ensuring our CI/CD and 330 | testing pipeline is quick and effective and that our developers are 331 | enabled to perform effectively and efficiently 332 |

333 | 334 |
335 | 336 | Luno 337 | {" "} 338 | (2020—2021) 339 |
340 |

341 | Luno is Africa's largest cryptocurrency wallet and exchange. During my 342 | time at Luno, I worked in the cross-functional Payment Operations team 343 | - developing software, tools, and internal systems to support the 344 | operational side of fiat payments within the app. Work involved 345 | improving the processes of how one gets fiat currency into and out of 346 | Luno, along with developing a system to perform automatic recon on 347 | these accounts. 348 |

349 |

350 | At Luno, I worked closely with many senior software engineers and the 351 | tech leadership/executive team. Development was mostly in Go and 352 | Typescript, with a focus on developing our own customised internal 353 | tooling and frameworks. 354 |

355 | 356 |
357 | 362 | Over 363 | {" "} 364 | (2018—2019) 365 |
366 |

367 | Over is a graphic design app for mobile devices. Similar to Canva, it 368 | allows one to easily edit images and videos on your mobile device. I 369 | joined Over as an early employee in the backend/services team where we 370 | primarily used Kotlin for our services, and Typescript with Angular 371 | for our internal content management system. When I joined Over, I was 372 | part of a small team responsible for the full backend system. 373 |

374 |

375 | Within the two years of me joining Over, the company grew from about 376 | 20 employees to over 150. I left Over to join Luno shortly after Over 377 | was acquired by GoDaddy and rebranded as GoDaddy Studio. 378 |

379 | 380 | ), 381 | projects: ( 382 | <> 383 |

384 | I'm always working on comp sciey (not really a word) things. Why don't 385 | you check out a few of my public code repositories? Just type 'repo' 386 | to get the links. 387 |

388 |

389 | I've also dabbled in producing a{" "} 390 | 395 | property-management portal 396 | {" "} 397 | that provides property managers and buildings with some really cool 398 | software and tools. The project uses TypeScript, Node.js, React (with 399 | Material-UI components) and Firebase. 400 |

401 |

402 | You can also check out my MSc thesis{" "} 403 | 404 | An investigation into the applicability of a blockchain based voting 405 | system 406 | {" "} 407 | - this one took a while! 408 |

409 | 410 | ), 411 | contact: ( 412 | <> 413 |
414 |
Email
415 |
416 | craig@craigfeldman.com 417 |
418 |
Smoke signals
419 |
general Cape Town region
420 |
myspace
421 |
just kidding
422 |
423 | 424 | ), 425 | awards: ( 426 | <> 427 |
428 |
2016
429 |
University of Oxford full scholarship
430 |
431 | Standard Bank Africa Chairman's Scholarship ( 432 | 437 | view scholarship 438 | 439 | ) 440 |
441 | 442 |
2015
443 |
Dean's Merit List
444 | 445 |
2014
446 |
Dean's Merit List
447 |
BSG Prize (Best 3rd year Computer Science student)
448 |
Class Medal (1st place) for all 3 Computer Science courses
449 |
Commerce Faculty Scholarship
450 | 451 |
2013
452 |
Dean's Merit List
453 |
Computer Science Merit Award (top 5%)
454 |
Class Medal for Inferential Statistics
455 |
Computer Science Merit Award (top 5%)
456 |
Commerce Faculty Scholarship
457 | 458 |
2012
459 |
Dean's Merit List
460 |
Computer Science Merit Award (top 5%)
461 |
462 | 463 | ), 464 | repo: ( 465 | <> 466 |
    467 |
  • 468 | 473 | GitHub 474 | {" "} 475 | - Unfortunately, I could only make a small subset of my projects 476 | public. 477 |
  • 478 |
  • 479 | 484 | Bitbucket 485 | {" "} 486 | - A few university projects. 487 |
  • 488 |
489 | 490 | ), 491 | skills: ( 492 | <> 493 |
Languages
494 |
495 |
TypeScript
496 |
497 | ##{" "} 498 | 499 | ############# 500 | {" "} 501 | ## 502 |
503 |
Go
504 |
505 | ##{" "} 506 | 507 | ############ 508 | 509 | {" "} 510 | ## 511 |
512 |
Kotlin
513 |
514 | ##{" "} 515 | 516 | ########### 517 | 518 | {" "} 519 | ## 520 |
521 |
Java
522 |
523 | ##{" "} 524 | 525 | ########### 526 | 527 | {" "} 528 | ## 529 |
530 |
C# and C++
531 |
532 | ##{" "} 533 | 534 | ######## 535 | 536 | {" "} 537 | ## 538 |
539 |
Python
540 |
541 | ##{" "} 542 | 543 | ##### 544 | 545 | {" "} 546 | ## 547 |
548 |
549 | 550 |
Cloud & Infrastructure
551 |
552 |
GCP / Firebase
553 |
554 | ##{" "} 555 | 556 | ######### 557 | 558 | {" "} 559 | ## 560 |
561 |
Azure
562 |
563 | ##{" "} 564 | 565 | ######### 566 | 567 | {" "} 568 | ## 569 |
570 |
AWS
571 |
572 | ##{" "} 573 | 574 | ######## 575 | 576 | {" "} 577 | ## 578 |
579 |
580 | Infrastructure
581 | 582 | (Docker, Kubernetes, DBs, etc.) 583 | 584 |
585 |
586 | ##{" "} 587 | 588 | ######### 589 | 590 | {" "} 591 | ## 592 |
593 |
594 | 595 |
Web
596 |
597 |
React
598 |
599 | ##{" "} 600 | 601 | ############ 602 | 603 | {" "} 604 | ## 605 |
606 |
Angular
607 |
608 | ##{" "} 609 | 610 | ##### 611 | 612 | {" "} 613 | ## 614 |
615 |
General web development
616 |
617 | ##{" "} 618 | 619 | ######### 620 | 621 | {" "} 622 | ## 623 |
624 |
625 | 626 | ), 627 | website: ( 628 | <> 629 |

630 | I built this website from scratch using {glow("React")} and{" "} 631 | {glow("TypeScript")}. It is a rewrite of my{" "} 632 | 637 | previous 638 | {" "} 639 | website that used{" "} 640 | 645 | JQuery Terminal Plugin 646 | {" "} 647 | (and some inspiration from{" "} 648 | 653 | Ronnie Pyne 654 | 655 | ). 656 |

657 |

658 | The source code for this site can be found on{" "} 659 | 664 | GitHub 665 | 666 | . Feel free to use this website for inspiration, or go ahead and copy 667 | some of the code! If you do, all I ask is that you give this site a 668 | mention :) 669 |

670 | 671 | ), 672 | }; 673 | 674 | const processCommand = (input: string) => { 675 | logEvent(analytics, "command_received", { command: input }); 676 | 677 | // Store a record of this command with a ref to allow us to scroll it into view. 678 | // Note: We use a ref callback here because setting the ref directly, then clearing output seems to set the ref to null. 679 | const commandRecord = ( 680 |
{ 682 | scrollRef.current = el; 683 | }} 684 | className="terminal-command-record" 685 | > 686 | {terminalPrompt}{" "} 687 | {input} 688 |
689 | ); 690 | 691 | // Add command to to history if the command is not empty 692 | if (input.trim()) { 693 | setHistory([...history, input]); 694 | setHistoryIndex(history.length + 1); 695 | } 696 | 697 | // Now process command, ignoring case 698 | const inputCommand = input.toLowerCase(); 699 | if (!isValidCommand(inputCommand)) { 700 | setOutput([ 701 | ...output, 702 | commandRecord, 703 |
704 | 705 |
, 706 | ]); 707 | } else if (isEchoCommand(inputCommand)) { 708 | setOutput([ 709 | ...output, 710 | commandRecord, 711 |
{commands[inputCommand]}
, 712 | ]); 713 | } else if (isUtilityCommand(inputCommand)) { 714 | switch (inputCommand) { 715 | case "clear": { 716 | setOutput([]); 717 | break; 718 | } 719 | case "all": { 720 | // Output all commands in a custom order. 721 | const allCommandsOutput = [ 722 | "about", 723 | "employment", 724 | "awards", 725 | "skills", 726 | "projects", 727 | "repo", 728 | "contact", 729 | "website", 730 | ].map((command) => ( 731 | <> 732 |
{command}
733 |
734 | {commands[command as EchoCommand]} 735 |
736 | 737 | )); 738 | 739 | setOutput([commandRecord, ...allCommandsOutput]); 740 | break; 741 | } 742 | case "cv": { 743 | setOutput([...output, commandRecord]); 744 | downloadFile("CV.pdf", "Craig Feldman - Curriculum Vitae.pdf"); 745 | break; 746 | } 747 | } 748 | } 749 | }; 750 | 751 | const getHistory = (direction: "up" | "down") => { 752 | let updatedIndex; 753 | if (direction === "up") { 754 | updatedIndex = historyIndex === 0 ? 0 : historyIndex - 1; 755 | } else { 756 | updatedIndex = 757 | historyIndex === history.length ? history.length : historyIndex + 1; 758 | } 759 | setHistoryIndex(updatedIndex); 760 | return updatedIndex === history.length ? "" : history[updatedIndex]; 761 | }; 762 | 763 | const getAutocomplete = (input: string) => { 764 | const matchingCommands = allCommands.filter((c) => c.startsWith(input)); 765 | if (matchingCommands.length === 1) { 766 | return matchingCommands[0]; 767 | } else { 768 | const commandRecord = ( 769 |
{ 771 | scrollRef.current = el; 772 | }} 773 | className="terminal-command-record" 774 | > 775 | {terminalPrompt}{" "} 776 | {input} 777 |
778 | ); 779 | setOutput([...output, commandRecord, matchingCommands.join(" ")]); 780 | return input; 781 | } 782 | }; 783 | 784 | const focusOnInput = (event: React.KeyboardEvent) => { 785 | if (event.key === "Tab") { 786 | // Prevent tab from moving focus 787 | event.preventDefault(); 788 | } 789 | inputRef.current?.focus(); 790 | }; 791 | 792 | return ( 793 |
794 |
795 | {banner && } 796 | {welcomeMessage && ( 797 | 798 | )} 799 | 800 | 808 |
809 |
810 | ); 811 | }; 812 | 813 | export default Terminal; 814 | --------------------------------------------------------------------------------