├── .eslintrc
├── .gitignore
├── .prettierrc
├── .vscode
└── settings.json
├── assets
└── Inter.ttf
├── components
├── Blog.js
├── Case.js
├── Contact.js
├── Footer.js
├── Layout.js
├── Navigation.js
├── Portfolio.js
├── SocialLink.js
├── StravaStats.js
└── TimelineItem.js
├── hooks
├── useIsMounted.js
└── useWindowSize.js
├── package-lock.json
├── package.json
├── pages
├── _app.js
├── _document.js
├── api
│ └── og.jsx
├── cv.js
└── index.js
├── postcss.config.js
├── public
├── contact.svg
├── logoaccent.png
├── logoachterderegenboog.png
├── logocarglass.png
├── logocarlier.png
├── logodeckdeckgo.png
├── logokaraton.png
├── logonalo.png
├── logopoa.png
├── logorialto.png
├── me.jpeg
├── myAvatar.ico
├── personal.svg
└── robots.txt
├── styles
└── index.css
├── tailwind.config.js
└── utils
└── db
└── index.js
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 |
17 | # nyc test coverage
18 | .nyc_output
19 |
20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
21 | .grunt
22 |
23 | # node-waf configuration
24 | .lock-wscript
25 |
26 | # Compiled binary addons (http://nodejs.org/api/addons.html)
27 | build/Release
28 |
29 | # Dependency directories
30 | node_modules
31 | jspm_packages
32 |
33 | # Optional npm cache directory
34 | .npm
35 |
36 | # Optional REPL history
37 | .node_repl_history
38 | .next
39 | .now
40 |
41 | .env.local
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "avoid",
3 | "bracketSpacing": false,
4 | "endOfLine": "lf",
5 | "htmlWhitespaceSensitivity": "css",
6 | "insertPragma": false,
7 | "jsxBracketSameLine": false,
8 | "jsxSingleQuote": false,
9 | "printWidth": 80,
10 | "proseWrap": "always",
11 | "quoteProps": "as-needed",
12 | "requirePragma": false,
13 | "semi": false,
14 | "singleQuote": true,
15 | "tabWidth": 2,
16 | "trailingComma": "all",
17 | "useTabs": false
18 | }
19 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.formatOnSave": true,
4 | "editor.detectIndentation": true,
5 | "editor.fontFamily": "'Dank Mono', Menlo, Monaco, 'Courier New', monospace",
6 | "editor.fontLigatures": false,
7 | "editor.rulers": [80],
8 | "editor.snippetSuggestions": "top",
9 | "editor.wordBasedSuggestions": false,
10 | "editor.suggest.localityBonus": true,
11 | "editor.acceptSuggestionOnCommitCharacter": false,
12 | "[javascript]": {
13 | "editor.defaultFormatter": "esbenp.prettier-vscode",
14 | "editor.suggestSelection": "recentlyUsed",
15 | "editor.suggest.showKeywords": false
16 | },
17 | "editor.renderWhitespace": "boundary",
18 | "files.exclude": {
19 | "USE_GITIGNORE": true
20 | },
21 | "files.defaultLanguage": "{activeEditorLanguage}",
22 | "javascript.validate.enable": false,
23 | "search.exclude": {
24 | "**/node_modules": true,
25 | "**/bower_components": true,
26 | "**/coverage": true,
27 | "**/dist": true,
28 | "**/build": true,
29 | "**/.build": true,
30 | "**/.gh-pages": true
31 | },
32 | "editor.codeActionsOnSave": {
33 | "source.fixAll.eslint": false
34 | },
35 | "eslint.validate": [
36 | "javascript",
37 | "javascriptreact",
38 | "typescript",
39 | "typescriptreact"
40 | ],
41 | "eslint.options": {
42 | "env": {
43 | "browser": true,
44 | "jest/globals": true,
45 | "es6": true
46 | },
47 | "parserOptions": {
48 | "ecmaVersion": 2019,
49 | "sourceType": "module",
50 | "ecmaFeatures": {
51 | "jsx": true
52 | }
53 | },
54 | "rules": {
55 | "no-debugger": "off"
56 | }
57 | },
58 | "workbench.colorTheme": "Night Owl",
59 | "workbench.iconTheme": "material-icon-theme",
60 | "breadcrumbs.enabled": true,
61 | "grunt.autoDetect": "off",
62 | "gulp.autoDetect": "off",
63 | "npm.runSilent": true,
64 | "explorer.confirmDragAndDrop": false,
65 | "editor.formatOnPaste": false,
66 | "editor.cursorSmoothCaretAnimation": true,
67 | "editor.smoothScrolling": true,
68 | "php.suggest.basic": false
69 | }
70 |
--------------------------------------------------------------------------------
/assets/Inter.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomasledoux1/website-thomas/958ba6b44cccd170c50cf3d830a338c7d68a3b96/assets/Inter.ttf
--------------------------------------------------------------------------------
/components/Blog.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {faEye} from '@fortawesome/free-regular-svg-icons'
3 | import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'
4 |
5 | const Blog = ({blogs}) => {
6 | const blogsToShow = blogs
7 | .sort((a, b) => b.page_views_count - a.page_views_count)
8 | .slice(0, 5)
9 |
10 | return (
11 | <>
12 | {blogsToShow &&
13 | blogsToShow.map((blog, i) => (
14 |
22 |
27 | <>
28 |
29 |
30 |
31 | {blog.title}
32 |
33 |
34 |
35 | {blog.page_views_count}
36 |
37 |
38 |
39 | {blog.description}
40 |
41 | {blog.tag_list.map((tag, i) => (
42 |
46 | {tag}
47 |
48 | ))}
49 |
50 | >
51 |
52 |
53 | ))}
54 |
60 | Read more blogs
61 |
62 | >
63 | )
64 | }
65 |
66 | export default Blog
67 |
--------------------------------------------------------------------------------
/components/Case.js:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 | import React from 'react'
3 |
4 | export default function Case({url, logoWidth, logoAlt, img, tags, children}) {
5 | return (
6 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/components/Contact.js:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react'
2 | import Image from 'next/image'
3 |
4 | const Contact = () => {
5 | const [formResult, setFormResult] = useState('')
6 | const submitForm = ev => {
7 | ev.preventDefault()
8 | const form = ev.target
9 | const data = new FormData(form)
10 | const xhr = new XMLHttpRequest()
11 | xhr.open(form.method, form.action)
12 | xhr.setRequestHeader('Accept', 'application/json')
13 | xhr.onreadystatechange = () => {
14 | if (xhr.readyState !== XMLHttpRequest.DONE) return
15 | if (xhr.status === 200) {
16 | form.reset()
17 | setFormResult('ok')
18 | } else {
19 | setFormResult('error')
20 | }
21 | }
22 | xhr.send(data)
23 | }
24 |
25 | return (
26 | <>
27 |
28 |
35 |
36 |
37 |
Drop me a message
38 |
86 |
87 | >
88 | )
89 | }
90 |
91 | export default Contact
92 |
--------------------------------------------------------------------------------
/components/Footer.js:
--------------------------------------------------------------------------------
1 | import SocialLink from './SocialLink'
2 | import {faGithub, faLinkedin, faDev} from '@fortawesome/free-brands-svg-icons'
3 | const Footer = () => (
4 |
27 | )
28 |
29 | export default Footer
30 |
--------------------------------------------------------------------------------
/components/Layout.js:
--------------------------------------------------------------------------------
1 | import Navigation from './Navigation'
2 | import Footer from './Footer'
3 | const Layout = ({children}) => {
4 | return (
5 | <>
6 |
7 | {children}
8 |
9 | >
10 | )
11 | }
12 |
13 | export default Layout
14 |
--------------------------------------------------------------------------------
/components/Navigation.js:
--------------------------------------------------------------------------------
1 | import React, {useRef, useState} from 'react'
2 | import Link from 'next/link'
3 | import {useRouter} from 'next/router'
4 | import {useTheme} from 'next-themes'
5 | import useWindowSize from '../hooks/useWindowSize'
6 | import useIsMounted from '../hooks/useIsMounted'
7 |
8 | const Navigation = () => {
9 | const navigationMobileRef = useRef(null)
10 | const mobileIconRef = useRef(null)
11 | const [mobileNavOpen, setMobileNavOpen] = useState(false)
12 | const isMounted = useIsMounted()
13 | const router = useRouter()
14 | const {width} = useWindowSize()
15 | const {theme, setTheme} = useTheme()
16 |
17 | const toggleMobileNavigation = () => {
18 | navigationMobileRef.current.classList.add('touched')
19 | navigationMobileRef.current.classList.toggle('translate-x-full')
20 | setMobileNavOpen(!mobileNavOpen)
21 | }
22 |
23 | const linkClicked = event => {
24 | if (event.currentTarget.href.indexOf('cv') > -1) {
25 | document.querySelectorAll('nav li a').forEach(navEl => {
26 | navEl.classList.remove('active')
27 | })
28 | }
29 | if (width <= 768) {
30 | toggleMobileNavigation()
31 | }
32 | }
33 |
34 | const renderNavigationItems = () => {
35 | const linkClasses =
36 | 'relative before:absolute before:bottom-[-5px] before:h-[5px] before:w-[0] before:mt-[5px] before:bg-primary before:transition-all before:duration-300'
37 | return (
38 | <>
39 |
40 |
41 |
42 | Personal
43 |
44 |
45 |
46 |
47 |
48 |
49 | Portfolio
50 |
51 |
52 |
53 |
54 |
55 |
56 | Blog
57 |
58 |
59 |
60 |
61 |
62 |
63 | Stats
64 |
65 |
66 |
67 |
68 |
69 |
70 | Contact
71 |
72 |
73 |
74 |
75 |
76 |
82 | CV
83 |
84 |
85 |
86 | >
87 | )
88 | }
89 |
90 | const LogoLetter = ({letter}) => (
91 | {letter}
92 | )
93 |
94 | return (
95 |
96 |
179 |
180 | )
181 | }
182 |
183 | export default Navigation
184 |
--------------------------------------------------------------------------------
/components/Portfolio.js:
--------------------------------------------------------------------------------
1 | import Case from '../components/Case'
2 | import logoKaraton from '../public/logokaraton.png'
3 | import logoRialto from '../public/logorialto.png'
4 | import logoCarlier from '../public/logocarlier.png'
5 | import logoCarglass from '../public/logocarglass.png'
6 | import logoNalo from '../public/logonalo.png'
7 | import logoAchterderegenboog from '../public/logoachterderegenboog.png'
8 | import logoDeckdeckgo from '../public/logodeckdeckgo.png'
9 | import logoAccent from '../public/logoaccent.png'
10 | import logoPoa from '../public/logopoa.png'
11 |
12 | const Portfolio = () => (
13 |
14 |
21 |
22 | For Happs Development I created and maintained the website for Karaton
23 | where speech therapists and parents of dyslexic could follow up on the
24 | progress their children/patients are making in the Karaton game.
25 |
26 |
27 | There were a lot of graphs to be shown with Highcharts, a payment
28 | integration through Mollie, different roles for
29 | admins/therapists/parents.
30 |
31 |
32 | In this team I worked as a Full Stack Developer, giving me a lot of
33 | insight in how the backend of a web application works.
34 |
35 |
36 |
43 |
44 | At my internship for Rialto I created an iOS app from scratch in Swift
45 | where real estate companies could easily manage their listings.
46 |
47 |
48 | I created the screens in storyboards based on the designs provided by
49 | our designer.
50 |
51 |
52 | When the screens were finished I used Swift code to implement
53 | functionality such as logins through an API, fetching the listings
54 | through an API, saving the listings in the SQLite database..
55 |
56 |
57 |
64 |
65 | While working at Happs Development I also created a mobile application
66 | for a speech therapist to help children with discalculia to learn how to
67 | count and do simple math exercises in a fun game form.
68 |
69 |
70 | The app was created from scratch using React Native for fast
71 | development, and Expo to get fast previews of the app on real devices.
72 |
73 |
74 | This project taught me a lot about animations, how to handle dynamically
75 | generated sound output for the spoken numbers, learn which platform
76 | specific APIs to use..
77 |
78 |
79 |
86 |
87 | At my current job at The Reference I help maintain the website for
88 | Carglass, we keep adding new features and maintain the older code in
89 | sprints.
90 |
91 |
92 | We have a separate Backend Development team, so my focus is purely on
93 | the Frontend Development in ReactJS.
94 |
95 |
96 | In the booking flows we make heavy use of MobX for state management,
97 | Local- and Sessionstorage to save intermediary input by the users and
98 | integrate with APIs from different parties.
99 |
100 |
101 |
108 |
109 | One of the other clients I work for at The Reference is Nationale
110 | Loterij, for this client we constantly create new features with a modern
111 | look on a monthly basis.
112 |
113 |
114 | In this project I get to test out even more new technologies, and new
115 | features in the existing technologies (think React Hooks, CSS3
116 | animations..).
117 |
118 |
119 | The feature I'm most proud of is the interactive Sponsoring Map of
120 | Belgium we created with some nice animations and beautiful design.
121 |
122 |
123 |
130 |
131 | In my free time I like to experiment with other frameworks and
132 | technologies too, this is why I made a website using Wordpress for a
133 | friend of mine who started a psychologists practice.
134 |
135 |
136 | My friend gave me some high level designs, and I got to work! I selected
137 | a fitting theme.{' '}
138 |
139 |
140 | I built on the theme with a lot of plugins to optimize the speed of the
141 | website (Autoptimize), the SEO (Yoast) and anti-spam by Akismet.
142 |
143 |
144 |
151 |
152 | In 2020 I participated in Hacktoberfest for the first time ever. I did
153 | some research on which open source project I would like to contribute
154 | to, and landed on DeckDeckGo.
155 |
156 |
157 | It was a lot of fun to coloborate with other open source contributors,
158 | and to work in a new technological stack. I'm definitely going to
159 | continue contributing to open source in the future!
160 |
161 |
162 |
169 |
170 | At the end of 2020, I got the opportunity to work on a project within
171 | The Reference using our new MACH stack.
172 |
173 |
174 | This was the first time I was using Gatsby for a production website, and
175 | I must say it makes developing a breeze. Connecting everything through
176 | API's, no hard dependecies on a CMS.. I love it.
177 |
178 |
179 |
186 |
187 | Starting february 2021, we started working on a new website for the Port
188 | of Antwerp. This website uses the MACH stack as mentioned above, but
189 | with Next.js instead of Gatsby, and Tailwind for styling!
190 |
191 |
192 | I really like this combo (this website is made with these technologies),
193 | so I couldn't be happier to be the lead frontend developer on this
194 | project. So far I've learned a lot about the many features and
195 | possibilities of Next.js, and I'm hoping to create the most
196 | performant website for this high profile client.
197 |
198 |
199 |
200 | )
201 | export default Portfolio
202 |
--------------------------------------------------------------------------------
/components/SocialLink.js:
--------------------------------------------------------------------------------
1 | import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'
2 | const SocialLink = ({label, href, icon, lastItem, fill = 'fill-current'}) => (
3 |
4 |
5 |
6 |
7 |
8 | )
9 |
10 | export default SocialLink
11 |
--------------------------------------------------------------------------------
/components/StravaStats.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | faBiking,
4 | faRunning,
5 | faRoad,
6 | faClock,
7 | faTachometerAlt,
8 | faMountain,
9 | } from '@fortawesome/free-solid-svg-icons'
10 | import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'
11 |
12 | const StravaStats = ({
13 | stravaStats,
14 | stravaMostRecentRun,
15 | stravaMostRecentRide,
16 | }) => {
17 | const [showRunning, setShowRunning] = React.useState(false)
18 | const btnClass =
19 | 'px-6 lg:px-12 py-2 lg:py-4 border-2 flex justify-center cursor-pointer w-1/2 text-center'
20 | const btnActiveClass = 'bg-primary text-white border-primary'
21 | return (
22 |
23 |
24 | setShowRunning(false)}
31 | >
32 |
33 |
34 | setShowRunning(true)}
41 | >
42 |
43 |
44 |
45 |
46 |
47 |
All time
48 |
49 |
50 | {Math.floor(
51 | (showRunning
52 | ? stravaStats.all_run_totals.distance
53 | : stravaStats.all_ride_totals.distance) / 1000,
54 | )}
55 | km
56 |
57 |
58 |
59 | {showRunning
60 | ? stravaStats.all_run_totals.elevation_gain
61 | : stravaStats.all_ride_totals.elevation_gain}
62 | m
63 |
64 |
65 |
66 | {Math.floor(
67 | (showRunning
68 | ? stravaStats.all_run_totals.moving_time
69 | : stravaStats.all_ride_totals.moving_time) / 3600,
70 | )}
71 | h
72 |
73 |
74 |
75 | {(
76 | (showRunning
77 | ? stravaStats.all_run_totals.distance
78 | : stravaStats.all_ride_totals.distance) /
79 | 1000 /
80 | ((showRunning
81 | ? stravaStats.all_run_totals.moving_time
82 | : stravaStats.all_ride_totals.moving_time) /
83 | 3600)
84 | ).toFixed(2)}{' '}
85 | km/h
86 |
87 |
88 | {showRunning ? 'Running' : 'Biking'} towards{' '}
89 | {showRunning ? '1000' : '5000'}km goal
90 |
100 |
101 |
102 |
103 |
104 | Most recent {showRunning ? 'run' : 'ride'}
105 |
106 |
107 |
108 | {Math.floor(
109 | (showRunning
110 | ? stravaMostRecentRun.distance
111 | : stravaMostRecentRide.distance) / 1000,
112 | )}
113 | km
114 |
115 |
116 |
117 | {showRunning
118 | ? stravaMostRecentRun.total_elevation_gain
119 | : stravaMostRecentRide.total_elevation_gain}
120 | m
121 |
122 |
123 |
124 | {Math.floor(
125 | (showRunning
126 | ? stravaMostRecentRun.moving_time
127 | : stravaMostRecentRide.moving_time) / 3600,
128 | )}
129 | h{' '}
130 | {Math.floor(
131 | ((showRunning
132 | ? stravaMostRecentRun.moving_time
133 | : stravaMostRecentRide.moving_time) %
134 | 3600) /
135 | 60,
136 | )}
137 | m
138 |
139 |
140 |
141 | {(
142 | (showRunning
143 | ? stravaMostRecentRun.distance
144 | : stravaMostRecentRide.distance) /
145 | 1000 /
146 | ((showRunning
147 | ? stravaMostRecentRun.moving_time
148 | : stravaMostRecentRide.moving_time) /
149 | 3600)
150 | ).toFixed(2)}{' '}
151 | km/h
152 |
153 |
154 |
155 |
156 | )
157 | }
158 | export default StravaStats
159 |
--------------------------------------------------------------------------------
/components/TimelineItem.js:
--------------------------------------------------------------------------------
1 | export default function TimelineItem({index, url, children}) {
2 | return (
3 |
7 |
8 | {children}
9 |
14 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/hooks/useIsMounted.js:
--------------------------------------------------------------------------------
1 | import {useRef, useEffect} from 'react'
2 |
3 | const useIsMounted = () => {
4 | const isMounted = useRef(false)
5 | useEffect(() => {
6 | isMounted.current = true
7 | return () => (isMounted.current = false)
8 | }, [])
9 | return isMounted
10 | }
11 |
12 | export default useIsMounted
13 |
--------------------------------------------------------------------------------
/hooks/useWindowSize.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | function useWindowSize() {
3 | // Initialize state with undefined width/height so server and client renders match
4 | // Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
5 | const [windowSize, setWindowSize] = React.useState({
6 | width: undefined,
7 | height: undefined,
8 | })
9 |
10 | React.useEffect(() => {
11 | // only execute all the code below in client side
12 | if (typeof window !== 'undefined') {
13 | // Handler to call on window resize
14 | function handleResize() {
15 | // Set window width/height to state
16 | setWindowSize({
17 | width: window.innerWidth,
18 | height: window.innerHeight,
19 | })
20 | }
21 |
22 | // Add event listener
23 | window.addEventListener('resize', handleResize)
24 |
25 | // Call handler right away so state gets updated with initial window size
26 | handleResize()
27 |
28 | // Remove event listener on cleanup
29 | return () => window.removeEventListener('resize', handleResize)
30 | }
31 | }, []) // Empty array ensures that effect is only run on mount
32 | return windowSize
33 | }
34 |
35 | export default useWindowSize
36 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "website-thomas",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "next -p 8080",
8 | "build": "next build",
9 | "start": "next start -p 8080"
10 | },
11 | "keywords": [],
12 | "author": "",
13 | "license": "ISC",
14 | "dependencies": {
15 | "@fortawesome/fontawesome-svg-core": "^1.2.36",
16 | "@fortawesome/free-brands-svg-icons": "^5.15.4",
17 | "@fortawesome/free-regular-svg-icons": "^5.15.4",
18 | "@fortawesome/free-solid-svg-icons": "^5.15.4",
19 | "@fortawesome/react-fontawesome": "^0.1.15",
20 | "@vercel/og": "^0.0.19",
21 | "aos": "^2.3.4",
22 | "firebase-admin": "^9.12.0",
23 | "next": "^12.3.1",
24 | "next-themes": "^0.0.12",
25 | "react": "^17.0.2",
26 | "react-dom": "^17.0.2",
27 | "sharp": "^0.31.1",
28 | "smoothscroll-polyfill": "^0.4.4"
29 | },
30 | "devDependencies": {
31 | "autoprefixer": "^10.3.7",
32 | "critters": "0.0.10",
33 | "eslint": "^7.32.0",
34 | "eslint-config-next": "^12.0.0",
35 | "postcss-preset-env": "^6.7.0",
36 | "tailwindcss": "^2.2.17"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import '../styles/index.css'
2 | import 'aos/dist/aos.css'
3 | import {ThemeProvider} from 'next-themes'
4 | import Script from 'next/script'
5 | import * as React from 'react'
6 | import Layout from '../components/Layout'
7 |
8 | // This default export is required in a new `pages/_app.js` file.
9 | export default function MyApp({Component, pageProps}) {
10 | return (
11 | <>
12 |
13 |
14 |
15 |
16 |
17 | >
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/pages/_document.js:
--------------------------------------------------------------------------------
1 | import Document, {Html, Head, Main, NextScript} from 'next/document'
2 | import * as React from 'react'
3 |
4 | class MyDocument extends Document {
5 | static async getInitialProps(ctx) {
6 | const initialProps = await Document.getInitialProps(ctx)
7 | return {...initialProps}
8 | }
9 |
10 | render() {
11 | return (
12 |
13 |
14 |
15 |
19 |
23 |
27 |
28 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | )
39 | }
40 | }
41 |
42 | export default MyDocument
43 |
--------------------------------------------------------------------------------
/pages/api/og.jsx:
--------------------------------------------------------------------------------
1 | import {ImageResponse} from '@vercel/og'
2 |
3 | export const config = {
4 | runtime: 'experimental-edge',
5 | }
6 |
7 | const font = fetch(new URL('../../assets/Inter.ttf', import.meta.url)).then(
8 | res => res.arrayBuffer(),
9 | )
10 |
11 | export default async function handler(req) {
12 | const fontData = await font
13 |
14 | try {
15 | const {searchParams} = new URL(req.url)
16 |
17 | // ?title=
18 | const hasTitle = searchParams.has('title')
19 | const title = hasTitle
20 | ? searchParams.get('title')?.slice(0, 200)
21 | : 'My default title'
22 |
23 | return new ImageResponse(
24 | (
25 |
41 |
50 |
58 |
59 |
66 |
Thomas Ledoux's blog
67 |
{title}
68 |
69 |
70 | ),
71 | {
72 | width: 1200,
73 | height: 600,
74 | fonts: [
75 | {
76 | name: 'Inter',
77 | data: fontData,
78 | style: 'normal',
79 | },
80 | ],
81 | },
82 | )
83 | } catch (e) {
84 | console.log(`${e.message}`)
85 | return new Response(`Failed to generate the image`, {
86 | status: 500,
87 | })
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/pages/cv.js:
--------------------------------------------------------------------------------
1 | import React, {useEffect} from 'react'
2 | import AOS from 'aos'
3 | import Head from 'next/head'
4 | import Image from 'next/image'
5 | import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'
6 | import {faStar as faStarEmpty} from '@fortawesome/free-regular-svg-icons'
7 | import {faStar as faStarFull} from '@fortawesome/free-solid-svg-icons'
8 | import TimelineItem from '../components/TimelineItem'
9 | import me from '../public/me.jpeg'
10 |
11 | const CV = () => {
12 | const age = Math.floor(
13 | (new Date() - new Date('1991-07-11').getTime()) / 3.15576e10,
14 | )
15 | const technologies = [
16 | {name: 'React', numberOfStars: 4},
17 | {name: 'Vue.js', numberOfStars: 2},
18 | {name: 'Angular', numberOfStars: 2},
19 | {name: 'Gatsby.js', numberOfStars: 3},
20 | {name: 'Next.js', numberOfStars: 4},
21 | {name: 'React Native', numberOfStars: 3},
22 | {name: 'Swift', numberOfStars: 2},
23 | {name: 'Wordpress', numberOfStars: 3},
24 | {name: 'ES6', numberOfStars: 4},
25 | {name: 'HTML', numberOfStars: 5},
26 | {name: 'CSS', numberOfStars: 4},
27 | ]
28 | const renderStars = amount =>
29 | Array.apply(null, {length: 5}).map((_, i) => (
30 |
31 |
35 |
36 | ))
37 | useEffect(() => {
38 | AOS.init({
39 | duration: 1000,
40 | })
41 | }, [])
42 |
43 | return (
44 | <>
45 |
46 | Thomas Ledoux' Portfolio - CV
47 |
48 |
49 |
50 |
51 |
52 |
59 |
Hello, is it me you're looking for?
60 |
61 |
62 |
63 |
64 | A bit about me
65 |
66 |
67 |
68 | Hi, I'm Thomas. I'm {age} years old, living in Ghent.
69 | I'm a professional Frontend Developer, currently working at{' '}
70 |
76 | The Reference
77 |
78 | .
79 |
80 |
81 | What I like most about Frontend development is the ever-changing
82 | technology. New frameworks being released daily (one better than
83 | the other...), constant improvements to existing frameworks,
84 | yearly new features in ECMAScript..
85 |
86 |
87 | I'm always eager to discover the latest updates, apps,
88 | technologies..!
89 |
90 |
91 |
92 |
93 |
Technologies
94 | {technologies.map((technology, i) => (
95 |
96 |
{technology.name}
97 |
{renderStars(technology.numberOfStars)}
98 |
99 | ))}
100 |
101 |
102 |
My timeline
103 |
104 |
105 | October 2018 - now
106 |
107 | Frontend Developer at{' '}
108 |
114 | The Reference
115 |
116 | , Ghent
117 |
118 |
119 |
120 |
121 | September 2017 - October 2018
122 |
123 |
124 | Full Stack Developer at{' '}
125 |
131 | Happs Development
132 |
133 | , Ghent
134 |
135 |
136 |
137 |
138 | February 2017 - June 2017
139 |
140 |
141 | Internship as Swift Developer at{' '}
142 |
148 | Rialto
149 |
150 | , Ghent
151 |
152 |
153 |
154 |
155 | September 2014 - June 2017
156 |
157 |
158 | Bachelor Applied Computer Sciences at{' '}
159 |
165 | Hogeschool Gent
166 |
167 |
168 |
169 |
170 |
171 | May 2012 - August 2014
172 |
173 |
174 | Support Engineer at{' '}
175 |
181 | Telenet
182 |
183 | , Lochristi
184 |
185 |
186 |
187 |
188 |
189 |
190 | >
191 | )
192 | }
193 |
194 | export default CV
195 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useRef, useState} from 'react'
2 | import Head from 'next/head'
3 | import Image from 'next/image'
4 | import smoothscroll from 'smoothscroll-polyfill'
5 | import db from '../utils/db'
6 | import Portfolio from '../components/Portfolio'
7 | import StravaStats from '../components/StravaStats'
8 | import Blog from '../components/Blog'
9 | import Contact from '../components/Contact'
10 | import me from '../public/me.jpeg'
11 |
12 | const Home = ({
13 | blogs,
14 | stravaMostRecentRide,
15 | stravaMostRecentRun,
16 | stravaStats,
17 | }) => {
18 | const personalRef = useRef(null)
19 | const portfolioRef = useRef(null)
20 | const statsRef = useRef(null)
21 | const contactRef = useRef(null)
22 | const blogRef = useRef(null)
23 | const age = Math.floor(
24 | (new Date() - new Date('1991-07-11').getTime()) / 3.15576e10,
25 | )
26 |
27 | useEffect(() => {
28 | smoothscroll.polyfill()
29 | }, [])
30 |
31 | useEffect(() => {
32 | let observer
33 | if (
34 | personalRef.current &&
35 | portfolioRef.current &&
36 | contactRef.current &&
37 | blogRef.current &&
38 | statsRef.current
39 | ) {
40 | const options = {
41 | threshold: 0.2,
42 | }
43 | observer = new IntersectionObserver((entries, observer) => {
44 | entries.forEach(entry => {
45 | const navElement = document.querySelector(
46 | `a[href="/#${entry.target.id}"]`,
47 | )
48 | if (
49 | entry.isIntersecting &&
50 | !navElement.classList.contains('active')
51 | ) {
52 | navElement.classList.add('active')
53 | } else if (
54 | !entry.isIntersecting &&
55 | navElement.classList.contains('active')
56 | ) {
57 | navElement.classList.remove('active')
58 | }
59 | })
60 | }, options)
61 | observer.observe(personalRef.current)
62 | observer.observe(portfolioRef.current)
63 | observer.observe(contactRef.current)
64 | observer.observe(blogRef.current)
65 | observer.observe(statsRef.current)
66 | }
67 | return () => observer.disconnect()
68 | }, [personalRef, portfolioRef, contactRef, blogRef, statsRef])
69 |
70 | return (
71 | <>
72 |
73 | Thomas Ledoux' Portfolio - Home
74 |
75 |
79 |
80 |
81 | Thomas is a
82 |
83 | developer
84 |
85 |
86 | cyclist
87 |
88 |
89 | travel lover
90 |
91 |
92 |
93 |
100 |
101 |
102 |
107 |
108 |
109 |
110 |
115 |
116 |
123 |
124 |
125 |
Personal Information
126 |
127 | Hi, I'm Thomas. I'm {age} years old, living in Ghent.
128 |
129 | I'm a professional Frontend Developer, currently working at
130 | The Reference.
131 |
132 |
133 | In general I really love to travel. My favourite kind of holiday
134 | is a roadtrip with a campervan, doing lots of hikes in nature
135 | and going for a swim in the sea at the end of the day!
136 | When I'm not traveling I like to sport a lot. I play
137 | badminton, cycle a lot, and I go for the occasional run.
138 |
139 |
140 | I studied Applied Computer Sciences at Hogeschool Gent. I chose
141 | the Mobile Development track, and went on Erasmus to Barcelona
142 | to learn more about Swift and Java. After graduating I
143 | worked for the startup Happs as a full-stack developer, where I
144 | created and maintained the website. I also created an
145 | app for a client in React Native during this period.
146 |
147 |
148 | You can read more about my work in the{' '}
149 |
153 | section below
154 |
155 | .
156 |
157 |
158 |
159 |
160 |
161 |
166 |
167 |
168 | Some of my work
169 |
170 |
171 |
172 |
173 |
178 |
179 |
180 | Personal blog - most read
181 |
182 |
183 |
184 |
185 |
190 |
191 |
192 | My Strava stats
193 |
194 |
199 |
200 |
201 |
210 | >
211 | )
212 | }
213 |
214 | export async function getStaticProps(context) {
215 | const entries = await db.collection('access_tokens').get()
216 | let [{access_token, refresh_token}] = entries.docs.map(entry => entry.data())
217 | const resToken = await fetch(
218 | `https://www.strava.com/api/v3/oauth/token?client_id=${process.env.CLIENT_ID_STRAVA}&client_secret=${process.env.CLIENT_SECRET_STRAVA}&grant_type=refresh_token&refresh_token=${refresh_token}`,
219 | {
220 | method: 'POST',
221 | },
222 | )
223 | const {
224 | access_token: newToken,
225 | refresh_token: newRefreshToken,
226 | } = await resToken.json()
227 | const resStats = await fetch(
228 | 'https://www.strava.com/api/v3/athletes/40229513/stats',
229 | {
230 | headers: {
231 | Authorization: `Bearer ${newToken}`,
232 | },
233 | },
234 | )
235 | const resActivities = await fetch(
236 | 'https://www.strava.com/api/v3/athlete/activities?per_page=100',
237 | {
238 | headers: {
239 | Authorization: `Bearer ${newToken}`,
240 | },
241 | },
242 | )
243 | db.collection('access_tokens')
244 | .doc('CSXyda8OfK75Aw0vtbtZ')
245 | .update({
246 | access_token: newToken,
247 | refresh_token: newRefreshToken,
248 | })
249 |
250 | const stravaStats = await resStats.json()
251 | const stravaActivies = await resActivities.json()
252 | const res = await fetch(`https://dev.to/api/articles/me/published`, {
253 | headers: {
254 | 'api-key': process.env.DEV_KEY,
255 | },
256 | })
257 | const blogs = await res.json()
258 | return {
259 | props: {
260 | stravaStats,
261 | stravaMostRecentRide: stravaActivies.filter(
262 | activity => activity.type === 'Ride',
263 | )[0],
264 | stravaMostRecentRun:
265 | stravaActivies.filter(activity => activity.type === 'Run')?.[0] ?? null,
266 | blogs,
267 | },
268 | revalidate: 86400,
269 | }
270 | }
271 |
272 | export default Home
273 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/contact.svg:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
13 |
17 |
21 |
25 |
29 |
33 |
34 |
38 |
42 |
46 |
50 |
54 |
58 |
62 |
--------------------------------------------------------------------------------
/public/logoaccent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomasledoux1/website-thomas/958ba6b44cccd170c50cf3d830a338c7d68a3b96/public/logoaccent.png
--------------------------------------------------------------------------------
/public/logoachterderegenboog.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomasledoux1/website-thomas/958ba6b44cccd170c50cf3d830a338c7d68a3b96/public/logoachterderegenboog.png
--------------------------------------------------------------------------------
/public/logocarglass.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomasledoux1/website-thomas/958ba6b44cccd170c50cf3d830a338c7d68a3b96/public/logocarglass.png
--------------------------------------------------------------------------------
/public/logocarlier.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomasledoux1/website-thomas/958ba6b44cccd170c50cf3d830a338c7d68a3b96/public/logocarlier.png
--------------------------------------------------------------------------------
/public/logodeckdeckgo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomasledoux1/website-thomas/958ba6b44cccd170c50cf3d830a338c7d68a3b96/public/logodeckdeckgo.png
--------------------------------------------------------------------------------
/public/logokaraton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomasledoux1/website-thomas/958ba6b44cccd170c50cf3d830a338c7d68a3b96/public/logokaraton.png
--------------------------------------------------------------------------------
/public/logonalo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomasledoux1/website-thomas/958ba6b44cccd170c50cf3d830a338c7d68a3b96/public/logonalo.png
--------------------------------------------------------------------------------
/public/logopoa.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomasledoux1/website-thomas/958ba6b44cccd170c50cf3d830a338c7d68a3b96/public/logopoa.png
--------------------------------------------------------------------------------
/public/logorialto.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomasledoux1/website-thomas/958ba6b44cccd170c50cf3d830a338c7d68a3b96/public/logorialto.png
--------------------------------------------------------------------------------
/public/me.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomasledoux1/website-thomas/958ba6b44cccd170c50cf3d830a338c7d68a3b96/public/me.jpeg
--------------------------------------------------------------------------------
/public/myAvatar.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomasledoux1/website-thomas/958ba6b44cccd170c50cf3d830a338c7d68a3b96/public/myAvatar.ico
--------------------------------------------------------------------------------
/public/personal.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
12 |
16 |
20 |
24 |
28 |
32 |
33 |
37 |
41 |
42 |
43 |
44 |
48 |
49 |
53 |
57 |
61 |
65 |
69 |
70 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
3 |
--------------------------------------------------------------------------------
/styles/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html {
6 | --purple: #f5ebff;
7 | --primary: #5851f9;
8 | --text: #000;
9 | --yellow: #f7df1e;
10 | --blue: #2965f1;
11 | --darkgray: #222831;
12 | --beige: #eee;
13 | --secondary: #f5ebff;
14 | --lightpurple: #f0efff;
15 | }
16 |
17 | html.dark {
18 | --primary: #b55400;
19 | --text: var(--beige);
20 | --secondary: var(--darkgray);
21 | }
22 |
23 | html {
24 | scroll-behavior: smooth;
25 | }
26 |
27 | html.dark progress[value]::-webkit-progress-value {
28 | background-color: var(--primary);
29 | }
30 |
31 | .logo:hover .letter {
32 | animation: bounce 0.3s ease alternate;
33 | }
34 |
35 | .logo:hover .letter:nth-child(1) {
36 | animation-delay: 0.1s;
37 | }
38 |
39 | .logo:hover .letter:nth-child(2) {
40 | animation-delay: 0.2s;
41 | }
42 |
43 | .logo:hover .letter:nth-child(3) {
44 | animation-delay: 0.3s;
45 | }
46 |
47 | .logo:hover .letter:nth-child(4) {
48 | animation-delay: 0.4s;
49 | }
50 |
51 | .logo:hover .letter:nth-child(5) {
52 | animation-delay: 0.5s;
53 | }
54 |
55 | .logo:hover .letter:nth-child(6) {
56 | animation-delay: 0.6s;
57 | }
58 |
59 | .logo:hover .letter:nth-child(7) {
60 | animation-delay: 0.7s;
61 | }
62 |
63 | .logo:hover .letter:nth-child(8) {
64 | animation-delay: 0.8s;
65 | }
66 |
67 | .logo:hover .letter:nth-child(9) {
68 | animation-delay: 0.9s;
69 | }
70 |
71 | .logo:hover .letter:nth-child(10) {
72 | animation-delay: 1s;
73 | }
74 |
75 | .logo:hover .letter:nth-child(11) {
76 | animation-delay: 1.1s;
77 | }
78 |
79 | .logo:hover .letter:nth-child(12) {
80 | animation-delay: 1.2s;
81 | }
82 |
83 | .logo:hover .letter:nth-child(13) {
84 | animation-delay: 1.3s;
85 | }
86 |
87 | .timeline-container::after {
88 | left: calc(50% - 2px);
89 | }
90 |
91 | .timeline-item:after {
92 | box-shadow: 1px -1px 1px rgba(0, 0, 0, 0.1);
93 | top: calc(50% - 0.5rem);
94 | }
95 |
96 | .timeline-item:nth-child(even)::after {
97 | box-shadow: -1px 1px 1px rgba(0, 0, 0, 0.1);
98 | }
99 |
100 | nav ul li a.active:before,
101 | nav ul li a.active:after,
102 | nav ul li:hover a:before,
103 | nav ul li:hover a:after {
104 | width: 100%;
105 | }
106 |
107 | @keyframes bounce {
108 | 50% {
109 | top: -5px;
110 | }
111 |
112 | 100% {
113 | top: 0;
114 | }
115 | }
116 |
117 | .star svg.full {
118 | animation: pop 0.3s linear 1;
119 | }
120 |
121 | .star:nth-child(1) svg.full {
122 | animation-delay: 0.6s;
123 | }
124 |
125 | .star:nth-child(2) svg.full {
126 | animation-delay: 0.7s;
127 | }
128 |
129 | .star:nth-child(3) svg.full {
130 | animation-delay: 0.8s;
131 | }
132 |
133 | .star:nth-child(4) svg.full {
134 | animation-delay: 0.9s;
135 | }
136 |
137 | .star:nth-child(5) svg.full {
138 | animation-delay: 1s;
139 | }
140 |
141 | section {
142 | scroll-margin-top: 4rem;
143 | }
144 |
145 | li.css {
146 | background-color: var(--blue);
147 | color: #fff;
148 | }
149 |
150 | li.nextjs,
151 | li.discuss {
152 | background-color: #000;
153 | color: #fff;
154 | }
155 |
156 | li.react {
157 | background-color: #222222;
158 | color: #61daf6;
159 | }
160 |
161 | li.javascript {
162 | background-color: var(--yellow);
163 | color: #000;
164 | }
165 |
166 | li.html {
167 | background-color: #f53900;
168 | color: white;
169 | }
170 |
171 | li.performance {
172 | background-color: #ffa364;
173 | color: #000;
174 | }
175 |
176 | li.showdev {
177 | background-color: #091b47;
178 | color: #b2ffe1;
179 | }
180 |
181 | li.gatsby {
182 | background-color: #663399;
183 | color: #fff;
184 | }
185 |
186 | progress[value]::-webkit-progress-bar {
187 | background-color: var(--beige);
188 | border-radius: 2px;
189 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.25) inset;
190 | }
191 |
192 | progress[value]::-webkit-progress-value {
193 | background-color: var(--primary);
194 | }
195 |
196 | @keyframes pop {
197 | 50% {
198 | transform: scale(1.2);
199 | }
200 | }
201 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const defaultTheme = require('tailwindcss/defaultTheme')
2 | module.exports = {
3 | mode: 'jit',
4 | purge: ['./pages/*.js', './components/*.js'],
5 | darkMode: 'class',
6 | theme: {
7 | extend: {
8 | colors: {
9 | purple: '#F5EBFF',
10 | primary: 'var(--primary)',
11 | lightgrey: '#393e46',
12 | linkedIn: '#0076b5',
13 | darkgrey: 'var(--darkgray)',
14 | text: 'var(--text)',
15 | orange: '#b55400',
16 | yellow: 'var(--yellow)',
17 | blue: 'var(--blue)',
18 | secondary: 'var(--secondary)',
19 | lightpurple: 'var(--lightpurple)',
20 | },
21 | inset: {
22 | timelineCircle: 'calc(50% - 0.5em)',
23 | },
24 | boxShadow: {
25 | case: '0 1px 3px rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.24)',
26 | 'case-hover': '0 10px 28px rgba(0,0,0,.25), 0 8px 10px rgba(0,0,0,.22)',
27 | link: 'inset 0 -4px 0 #6c63ff',
28 | 'link-hover': 'inset 0 -18px 0 #6c63ff',
29 | 'link-dark': 'inset 0 -4px 0 #b55400',
30 | 'link-dark-hover': 'inset 0 -18px 0 #b55400',
31 | },
32 | minHeight: {
33 | 'screen-without-nav': 'calc(100vh - 4rem)',
34 | },
35 | keyframes: {
36 | 'title-part1': {
37 | '0%, 100%': {color: 'var(--text)'},
38 | '50%': {color: 'var(--primary)'},
39 | },
40 | 'title-part2': {
41 | '0%, 100%': {color: 'var(--text)'},
42 | '50%': {color: 'var(--yellow)'},
43 | },
44 | 'title-part3': {
45 | '0%, 100%': {color: 'var(--text)'},
46 | '50%': {color: 'var(--blue)'},
47 | },
48 | },
49 | animation: {
50 | 'title-part1': 'title-part1 3s ease-in-out infinite',
51 | 'title-part2': 'title-part2 3s ease-in-out 1s infinite',
52 | 'title-part3': 'title-part3 3s ease-in-out 2s infinite',
53 | },
54 | rotate: {
55 | '135': '135deg',
56 | '-135': '-135deg',
57 | },
58 | },
59 | },
60 | }
61 |
--------------------------------------------------------------------------------
/utils/db/index.js:
--------------------------------------------------------------------------------
1 | import admin from 'firebase-admin'
2 |
3 | if (!admin.apps.length) {
4 | try {
5 | admin.initializeApp({
6 | credential: admin.credential.cert({
7 | type: 'service_account',
8 | auth_uri: 'https://accounts.google.com/o/oauth2/auth',
9 | token_uri: 'https://oauth2.googleapis.com/token',
10 | auth_provider_x509_cert_url:
11 | 'https://www.googleapis.com/oauth2/v1/certs',
12 | client_x509_cert_url:
13 | 'https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-j3bwb%40personal-website-e4e38.iam.gserviceaccount.com',
14 | project_id: process.env.PROJECT_ID,
15 | private_key_id: process.env.PRIVATE_KEY_ID,
16 | private_key: process.env.PRIVATE_KEY,
17 | client_id: process.env.CLIENT_EMAIL,
18 | client_email: process.env.CLIENT_EMAIL,
19 | }),
20 | })
21 | } catch (error) {
22 | console.log('Firebase admin initialization error', error.stack)
23 | }
24 | }
25 | export default admin.firestore()
26 |
--------------------------------------------------------------------------------