├── .gitignore ├── .parcelrc ├── Dockerfile ├── LICENCE ├── README.md ├── docker-compose.yml ├── package-lock.json ├── package.json └── src ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── app.js ├── apple-touch-icon.png ├── custom-comands.js ├── draggable.js ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── images ├── halloween-bg.jpg └── santa.gif ├── index.html ├── resources └── commands.json ├── scss ├── _halloween.scss ├── _santa.scss ├── _snowflakes.scss └── style.scss ├── site.webmanifest ├── sound └── jingle-bells.mp3 └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .parcel-cache 3 | .idea 4 | dist 5 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@parcel/config-default"] 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bullseye-slim as builder 2 | WORKDIR /data 3 | COPY . . 4 | RUN apt update && apt install -y npm 5 | RUN npm install -i package.json \ 6 | && npm run build 7 | 8 | FROM alpine 9 | 10 | RUN apk update \ 11 | && apk add lighttpd \ 12 | && rm -rf /var/cache/apk/* 13 | 14 | COPY --from=builder /data/dist /var/www/localhost/htdocs 15 | 16 | CMD ["lighttpd","-D","-f","/etc/lighttpd/lighttpd.conf"] 17 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Antoine DAUTRY 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Resume Terminal 2 | 3 | ## About 4 | 5 | This projet use [ParcelJS](https://parceljs.org/) as build tool. 6 | 7 | It is made from scratch, some libraries are used for hidden commands : 8 | 9 | - `pif` [canvas-confetti](https://github.com/catdad/canvas-confetti). 10 | - `rm -rf /` [fireworks-js](https://github.com/crashmax-dev/fireworks-js/). 11 | 12 | ## Run the project 13 | 14 | > First you need to install dependencies with `npm install` 15 | 16 | - To run in dev mode : `npm run dev` 17 | - To build for production : `npm run build` 18 | 19 | ## Usage 20 | 21 | ### commands.json 22 | 23 | File `commands.json` contain all commands that just needs to display simple data and doesn't need a JS actions. 24 | 25 | For now, there are 4 possible type of steps : 26 | 27 | - list 28 | - text 29 | - code 30 | - table 31 | 32 | #### responseType = list 33 | 34 | To display a bullet list, the `value` field is an array of string. 35 | 36 | ```json 37 | { 38 | "command": "whois adautry", 39 | "responseType": "list", 40 | "value": [ 41 | "A 27 years old full stack developper", 42 | "3 years of experiences", 43 | "Living in Nantes" 44 | ] 45 | } 46 | ``` 47 | 48 | #### responseType = table 49 | 50 | Display a table, this object requires two fields : 51 | 52 | - `headers`: Headers of the array 53 | - `rows`: Array containing rows 54 | 55 | ```json 56 | { 57 | "command": "whereis experiences", 58 | "responseType": "table", 59 | "headers": [ 60 | "Date", 61 | "Client", 62 | "Description", 63 | "Tech" 64 | ], 65 | "rows": [ 66 | [ 67 | "2021", 68 | "La Poste", 69 | "Internal tool to schedule techniciens on interventions.", 70 | "Angular 11, Spring Boot/Batch, Genetic algorithm" 71 | ], 72 | [ 73 | "2020", 74 | "DSI", 75 | "Maintenance of a timesheet internal tool. Development of plugins for our ProjeQtor instance.", 76 | "Symfony, Angular 8" 77 | ] 78 | ] 79 | } 80 | ``` 81 | 82 | #### responseType = text 83 | 84 | Just display text contained in `value`. 85 | 86 | ```json 87 | { 88 | "command": "find . -type f -print | xargs grep \"hobby\"", 89 | "responseType": "text", 90 | "value": "Bonsoir" 91 | } 92 | ``` 93 | 94 | #### responseType = code 95 | 96 | Display code between `pre` tag, `value` is an array of string, each string is a line. 97 | 98 | ```json 99 | { 100 | "command": "curl https://adautry.fr/user/03101994", 101 | "responseType": "code", 102 | "value": [ 103 | "{", 104 | " \"name\":\"Antoine DAUTRY\",", 105 | " \"job\":\"Fullstack developper\",", 106 | " \"experience\":\"3 years\",", 107 | " \"city\":\"Nantes\"", 108 | "}" 109 | ] 110 | } 111 | ``` 112 | 113 | ## Customs commands 114 | 115 | In the `app.js` file you can see multiple arrays that stores commands : 116 | 117 | - `hiddenCommands`: Commands that are not use in autocompletion (easter egg commands for example) 118 | - `customCommands`: Commands that needs a specials JS treatments, in my case `dark`/`light` to swith app theme, `get cv` 119 | to download my resume, ... 120 | - `commandsList`: This is the main array used for autocompletion, it stores `customCommands` **and** commands that are 121 | listed in the `commands.json` file. 122 | 123 | ## Attributions 124 | 125 | - [Image from vector_corp](https://www.freepik.com/free-ai-image/halloween-scene-with-pumpkins-bats-full-moon_72868248.htm#query=haloween&position=4&from_view=search&track=sph&uuid=bedaf5ef-3c64-4822-82eb-3d4f750703f8) 126 | on Freepik -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | resume-terminal: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | # Not on DockerHub 8 | image: antoine1003/resume-terminal 9 | container_name: resume-terminal 10 | ports: 11 | - '80:80' 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cv-terminal", 3 | "version": "2.1.7", 4 | "description": "Nice looking resume.", 5 | "scripts": { 6 | "clean:output": "rimraf dist", 7 | "dev": "npx parcel src/index.html", 8 | "build": "npm run clean:output && npx parcel build src/index.html --no-source-maps" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "canvas-confetti": "^1.5.1", 15 | "dompurify": "^3.0.5", 16 | "fireworks-js": "^1.3.5", 17 | "postcss": "^8.3.11" 18 | }, 19 | "browserslist": [ 20 | "defaults" 21 | ], 22 | "devDependencies": { 23 | "@parcel/packager-raw-url": "^2.0.0", 24 | "@parcel/transformer-sass": "^2.0.0", 25 | "@parcel/transformer-webmanifest": "^2.0.0", 26 | "cssnano": "^5.0.8", 27 | "parcel": "^2.0.0", 28 | "prettier": "2.4.1", 29 | "rimraf": "^5.0.1", 30 | "sass": "^1.43.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoine1003/resume-terminal/7049af292e6573fb45b646f698a1915b6e6cd345/src/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoine1003/resume-terminal/7049af292e6573fb45b646f698a1915b6e6cd345/src/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef Command 3 | * @property {string} command 4 | * @property {string} responseType 5 | * @property {string?} value 6 | * @property {string[]?} headers 7 | * @property {string[]?} rows 8 | */ 9 | 10 | /** 11 | * @type {Command[]} commands 12 | */ 13 | import commands from "./resources/commands.json"; 14 | import { 15 | getCV, 16 | pif, 17 | rmRf, 18 | setDarkMode, 19 | setHalloweenTheme, 20 | showSanta, 21 | showSantaAndRemoveListener 22 | } from "./custom-comands"; 23 | import { stringToDom } from "./utils"; 24 | import { dragElement } from "./draggable"; 25 | import DOMPurify from 'dompurify'; 26 | 27 | // Table containing the orders (useful for the completion of the orders) 28 | let commandsList = []; 29 | commands.forEach((c) => { 30 | commandsList.push(c.command); 31 | }); 32 | 33 | // Commands that require JS processing 34 | const customCommands = ["clear", "dark", "light", "get cv"]; 35 | commandsList = commandsList.concat(customCommands); 36 | 37 | // Eyster eggs' commands not available for autocompletion 38 | const hiddenCommands = ["pif", "rm -rf /", "hohoho", "boo"]; 39 | 40 | // Added the ability to move the window for PCs 41 | if (window.innerWidth > 1024) { 42 | dragElement(document.querySelector(".terminal")); 43 | } 44 | 45 | // Order history table 46 | const commandsHistory = []; 47 | let historyMode = false; 48 | let historyIndex = -1; 49 | const terminalBody = document.querySelector(".terminal__body"); 50 | 51 | // Adding the default line 52 | addNewLine(); 53 | 54 | // December Easter egg, adding snowflakes 55 | const now = new Date(); 56 | if (now.getMonth() === 11) { 57 | let htmlFlakes = ""; 58 | for (let i = 0; i < 6; i++) { 59 | htmlFlakes += `
`; 60 | } 61 | const html = ``; 62 | document.body.append(stringToDom(html)); 63 | } 64 | 65 | // Christmas Easter egg, adding Santa 66 | if (now.getMonth() === 11) { 67 | document.addEventListener('click', showSantaAndRemoveListener); 68 | } 69 | 70 | 71 | // Easter egg for Halloween, adding bats 72 | if (now.getMonth() === 9 && now.getDate() >= 28) { 73 | setHalloweenTheme(); 74 | } 75 | 76 | 77 | // Set to dark mode if the browser theme is dark 78 | if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { 79 | setDarkMode(true); 80 | } 81 | 82 | /** 83 | * Returns the HTML of the response for a given command 84 | * @param {string} command 85 | */ 86 | function getDomForCommand(command) { 87 | const commandObj = commands.find((el) => el.command === command); 88 | let purifiedCommand = DOMPurify.sanitize(command); 89 | purifiedCommand = purifiedCommand.replace(//g, ">"); 90 | 91 | console.log(purifiedCommand) 92 | let html = ""; 93 | if (commandObj === undefined) { 94 | html = `'${ 95 | purifiedCommand.split(" ")[0] 96 | }' is not recognized as an internal command or external command, operable program or batch file. Type the help command to display a list of available commands.`; 97 | } else { 98 | if (commandObj.responseType === "list" && Array.isArray(commandObj.value)) { 99 | html = ""; 102 | } else if (commandObj.responseType === "text") { 103 | html = commandObj.value; 104 | } else if (commandObj.responseType === "table") { 105 | const headers = commandObj.headers; 106 | const rows = commandObj.rows; 107 | const thsHtml = headers.map((h) => `${h}`).join(""); 108 | const tdsHtml = rows 109 | .map((r) => `${r.map((rtd) => `${rtd}`).join("")}`) 110 | .join(""); 111 | html = `${thsHtml}${tdsHtml}
`; 112 | } else if (commandObj.responseType === "code") { 113 | html = `
${commandObj.value.join("\n")}
`; 114 | } 115 | } 116 | 117 | return html; 118 | } 119 | 120 | /** 121 | * Adds a new command input line and disables the previous one. 122 | * @param {string|null} previousUid uid de la ligne précédente. 123 | */ 124 | function addNewLine(previousUid = null) { 125 | const uid = Math.random().toString(36).replace("0.", ""); 126 | // terminal__line 127 | const terminalLineEl = document.createElement("div"); 128 | terminalLineEl.classList.add("terminal__line"); 129 | 130 | // terminal__response 131 | const terminalResponseEl = document.createElement("div"); 132 | terminalResponseEl.classList.add("terminal__response"); 133 | terminalResponseEl.id = `response-${uid}`; 134 | 135 | // input text 136 | const inputEl = document.createElement("input"); 137 | inputEl.type = "text"; 138 | inputEl.id = `input-${uid}`; 139 | inputEl.autocapitalize = "off"; 140 | inputEl.dataset.uid = uid; 141 | inputEl.dataset.active = "1"; // Needed for focus 142 | inputEl.addEventListener("keydown", onCommandInput); 143 | 144 | terminalLineEl.appendChild(inputEl); 145 | if (previousUid) { 146 | const previousInputEl = document.getElementById(previousUid); 147 | if (previousInputEl) { 148 | previousInputEl.setAttribute("disabled", "true"); 149 | previousInputEl.removeEventListener("keydown", onCommandInput); 150 | delete previousInputEl.dataset.active; 151 | } 152 | } 153 | document.getElementById("terminal").appendChild(terminalLineEl); 154 | document.getElementById("terminal").appendChild(terminalResponseEl); 155 | 156 | inputEl.focus(); // Adds the focus as soon as the field is created 157 | } 158 | 159 | /** 160 | * Manages the keydown on the command input. 161 | * @param e 162 | */ 163 | function onCommandInput(e) { 164 | const commandValue = e.target.value.trim().toLowerCase(); 165 | if (e.keyCode === 13) { 166 | // ENTER 167 | if (commandValue !== "") { 168 | historyMode = false; 169 | const idResponse = `response-${e.target.dataset.uid}`; 170 | const responseEl = document.getElementById(idResponse); 171 | let html; 172 | if ( 173 | hiddenCommands.includes(commandValue) || 174 | customCommands.includes(commandValue) 175 | ) { 176 | html = handleCustomCommands(commandValue); 177 | } else { 178 | html = getDomForCommand(commandValue); 179 | } 180 | if (responseEl) { 181 | responseEl.innerHTML = html; 182 | commandsHistory.push(commandValue); 183 | addNewLine(e.target.id); 184 | } 185 | } 186 | } else if (e.keyCode === 9) { 187 | // TAB 188 | e.preventDefault(); 189 | if (commandValue === "") { 190 | this.value = "help"; 191 | } else { 192 | const matchingCommand = commandsList.find((c) => 193 | c.startsWith(commandValue) 194 | ); 195 | if (matchingCommand) { 196 | this.value = matchingCommand; 197 | } 198 | } 199 | historyMode = false; 200 | } else if (e.keyCode === 38 || e.keyCode === 40) { 201 | // UP / DOWN 202 | // History management 203 | if (commandsHistory.length > 0) { 204 | if (historyMode === false) { 205 | historyIndex = commandsHistory.length - 1; 206 | } else { 207 | if (e.keyCode === 38 && historyIndex !== 0) { 208 | // UP 209 | historyIndex--; 210 | } else if ( 211 | e.keyCode === 40 && 212 | historyIndex !== commandsHistory.length - 1 213 | ) { 214 | historyIndex++; 215 | } 216 | } 217 | this.value = commandsHistory[historyIndex]; 218 | } 219 | historyMode = true; 220 | } 221 | } 222 | 223 | /** 224 | * Allows to manage hidden commands (not proposed in the autocompletion) 225 | * @param {string} command 226 | * @returns {string|void} Html to be displayed in the response of the command 227 | */ 228 | function handleCustomCommands(command) { 229 | switch (command) { 230 | case "pif": 231 | pif(); 232 | return "Let's go !"; 233 | case "light": 234 | if (!document.body.classList.contains("dark-mode")) 235 | return "You are already in light mode."; 236 | setDarkMode(false); 237 | return "Your are now in light mode."; 238 | case "dark": 239 | if (document.body.classList.contains("dark-mode")) 240 | return "You are already in dark mode."; 241 | setDarkMode(true); 242 | return "You are now in dark mode."; 243 | case "get cv": 244 | getCV(); 245 | return "The CV will be downloaded."; 246 | case "rm -rf /": 247 | rmRf(); 248 | return "🎆"; 249 | case "clear": 250 | terminalBody.innerHTML = `
`; 251 | return; 252 | case "boo": 253 | setHalloweenTheme(); 254 | return "🎃"; 255 | case "hohoho": 256 | showSanta(); 257 | return "🎅🎁"; 258 | } 259 | } 260 | 261 | // ------------------------------------------------------------------------------------ 262 | // EVENT LISTENNER 263 | // ------------------------------------------------------------------------------------ 264 | 265 | // Added focus on the input even if you click on the body (to keep the cursor) 266 | document.body.addEventListener("click", function (e) { 267 | if (e.target.tagName !== "INPUT") { 268 | const activeInput = document.querySelector("input[data-active]"); 269 | activeInput.focus(); 270 | } 271 | }); 272 | 273 | document.querySelector(".fake-close").addEventListener("click", function (e) { 274 | const terminalEl = document.querySelector(".terminal"); 275 | terminalEl.parentElement.removeChild(terminalEl); 276 | }); 277 | -------------------------------------------------------------------------------- /src/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoine1003/resume-terminal/7049af292e6573fb45b646f698a1915b6e6cd345/src/apple-touch-icon.png -------------------------------------------------------------------------------- /src/custom-comands.js: -------------------------------------------------------------------------------- 1 | import confetti from "canvas-confetti"; 2 | import { Fireworks } from "fireworks-js"; 3 | import { stringToDom } from "./utils"; 4 | 5 | /** 6 | * Affiche des confettis sur la page 7 | */ 8 | export function pif() { 9 | const count = 200; 10 | const defaults = { 11 | origin: { y: 0.7 }, 12 | }; 13 | 14 | function fire(particleRatio, opts) { 15 | confetti( 16 | Object.assign({}, defaults, opts, { 17 | particleCount: Math.floor(count * particleRatio), 18 | }) 19 | ); 20 | } 21 | 22 | fire(0.25, { 23 | spread: 26, 24 | startVelocity: 55, 25 | }); 26 | fire(0.2, { 27 | spread: 60, 28 | }); 29 | fire(0.35, { 30 | spread: 100, 31 | decay: 0.91, 32 | scalar: 0.8, 33 | }); 34 | fire(0.1, { 35 | spread: 120, 36 | startVelocity: 25, 37 | decay: 0.92, 38 | scalar: 1.2, 39 | }); 40 | fire(0.1, { 41 | spread: 120, 42 | startVelocity: 45, 43 | }); 44 | } 45 | 46 | export function setDarkMode(value) { 47 | if (value) { 48 | document.body.classList.add("dark-mode"); 49 | } else { 50 | document.body.classList.remove("dark-mode"); 51 | } 52 | } 53 | 54 | export function getCV() { 55 | const a = document.createElement("a"); 56 | a.href = "https://my-resume.adautry.fr/download-latest"; 57 | a.setAttribute("download", "CV - Antoine DAUTRY.pdf"); 58 | a.click(); 59 | } 60 | 61 | export function rmRf() { 62 | if (document.body.classList.contains("firework")) return; 63 | setDarkMode(true); 64 | document.body.classList.add("firework"); 65 | const fireworks = new Fireworks(document.body, { 66 | mouse: { click: true, move: false, max: 7 }, 67 | }); 68 | fireworks.start(); 69 | } 70 | 71 | export function setHalloweenTheme() { 72 | const isActive = document.querySelector(".halloween-bg"); 73 | if (isActive) return; 74 | // add image 75 | const imageUrl = new URL( 76 | 'images/halloween-bg.jpg', 77 | import.meta.url 78 | ); 79 | const html = `Halloween background`; 80 | document.body.prepend(stringToDom(html)); 81 | document.body.classList.add("halloween"); 82 | setDarkMode(true); 83 | } 84 | 85 | /** 86 | * Shows Santa on the page and remove the listener 87 | * This function is needed to properly remove listener 88 | * with removeEventListener function 89 | */ 90 | export function showSantaAndRemoveListener() { 91 | showSanta(true); 92 | } 93 | 94 | export function showSanta(removeOnClickListener = false) { 95 | if (removeOnClickListener) { 96 | document.removeEventListener('click', showSantaAndRemoveListener); 97 | } 98 | let santaEl = document.getElementById('santa'); 99 | 100 | if (santaEl) 101 | return; 102 | 103 | const imageUrl = new URL( 104 | 'images/santa.gif', 105 | import.meta.url 106 | ); 107 | const html = `Santa with his deers` 108 | document.body.prepend(stringToDom(html)); 109 | santaEl = document.getElementById('santa') 110 | 111 | const santaOptions = { 112 | animationId: requestAnimationFrame(animateSanta), 113 | amountOfPixelsToAnimate: window.innerWidth + 200, 114 | duration: 5000, 115 | imageAngleCorrection: 6.0382, // In radian 116 | angleAtenuation: 4, 117 | topOffset: '5vh' 118 | } 119 | 120 | let right = 0; 121 | let startTime = null; 122 | const jingleBellsSoundUrl = new URL( 123 | 'sound/jingle-bells.mp3', 124 | import.meta.url 125 | ); 126 | 127 | let jingleBellsSound = new Audio(jingleBellsSoundUrl); 128 | jingleBellsSound.play(); 129 | 130 | 131 | function animateSanta(timestamp) { 132 | if (!startTime) { 133 | startTime = timestamp; 134 | } 135 | 136 | const runtime = timestamp - startTime; 137 | const relativeProgress = runtime / santaOptions.duration; 138 | 139 | right = santaOptions.amountOfPixelsToAnimate * Math.min(relativeProgress, 1); 140 | 141 | const { top, radian } = getAnimationData(relativeProgress); 142 | 143 | const angle = (radian + santaOptions.imageAngleCorrection); 144 | 145 | santaEl.style.transform = `translateX(-${right}px) translateY(calc(${santaOptions.topOffset} - ${top * 100}px)) rotate(${angle}rad)`; 146 | 147 | // We want to request another frame when our desired duration isn't met yet 148 | if (runtime < santaOptions.duration) { 149 | requestAnimationFrame(animateSanta); 150 | } else { 151 | santaEl.remove(); 152 | jingleBellsSound.pause(); 153 | cancelAnimationFrame(santaOptions.animationId); 154 | } 155 | } 156 | 157 | /** 158 | * Returns calculated fields needed for animation 159 | * @param {number} progress 160 | * @returns {top: number, radian: number} 161 | */ 162 | function getAnimationData(progress) { 163 | progress = Math.max(0, Math.min(1, progress)); 164 | 165 | // Calculate derivate and get the angle to calculate image rotation 166 | const derivate = -8 * progress + 4; 167 | const radian = Math.atan(derivate) / santaOptions.angleAtenuation 168 | 169 | return { 170 | top: 1 - 4 * (progress - 0.5) ** 2, // Parabol function 171 | radian 172 | }; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/draggable.js: -------------------------------------------------------------------------------- 1 | export function dragElement(elmnt) { 2 | var pos1 = 0, 3 | pos2 = 0, 4 | pos3 = 0, 5 | pos4 = 0; 6 | const element = document.querySelector(".terminal__header"); 7 | if (element) { 8 | // if present, the header is where you move the DIV from: 9 | element.onmousedown = dragMouseDown; 10 | } else { 11 | // otherwise, move the DIV from anywhere inside the DIV: 12 | elmnt.onmousedown = dragMouseDown; 13 | } 14 | 15 | function dragMouseDown(e) { 16 | e = e || window.event; 17 | e.preventDefault(); 18 | // get the mouse cursor position at startup: 19 | pos3 = e.clientX; 20 | pos4 = e.clientY; 21 | document.onmouseup = closeDragElement; 22 | // call a function whenever the cursor moves: 23 | document.onmousemove = elementDrag; 24 | } 25 | 26 | function elementDrag(e) { 27 | e = e || window.event; 28 | e.preventDefault(); 29 | // calculate the new cursor position: 30 | pos1 = pos3 - e.clientX; 31 | pos2 = pos4 - e.clientY; 32 | pos3 = e.clientX; 33 | pos4 = e.clientY; 34 | // set the element's new position: 35 | elmnt.style.top = elmnt.offsetTop - pos2 + "px"; 36 | elmnt.style.left = elmnt.offsetLeft - pos1 + "px"; 37 | } 38 | 39 | function closeDragElement() { 40 | // stop moving when mouse button is released: 41 | document.onmouseup = null; 42 | document.onmousemove = null; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoine1003/resume-terminal/7049af292e6573fb45b646f698a1915b6e6cd345/src/favicon-16x16.png -------------------------------------------------------------------------------- /src/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoine1003/resume-terminal/7049af292e6573fb45b646f698a1915b6e6cd345/src/favicon-32x32.png -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoine1003/resume-terminal/7049af292e6573fb45b646f698a1915b6e6cd345/src/favicon.ico -------------------------------------------------------------------------------- /src/images/halloween-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoine1003/resume-terminal/7049af292e6573fb45b646f698a1915b6e6cd345/src/images/halloween-bg.jpg -------------------------------------------------------------------------------- /src/images/santa.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoine1003/resume-terminal/7049af292e6573fb45b646f698a1915b6e6cd345/src/images/santa.gif -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | CV - Antoine DAUTRY 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
 ______     __   __      ______   ______     ______     __    __     __     __   __     ______     __
36 | /\  ___\   /\ \ / /     /\__  _\ /\  ___\   /\  == \   /\ "-./  \   /\ \   /\ "-.\ \   /\  __ \   /\ \
37 | \ \ \____  \ \ \'/      \/_/\ \/ \ \  __\   \ \  __<   \ \ \-./\ \  \ \ \  \ \ \-.  \  \ \  __ \  \ \ \____
38 |  \ \_____\  \ \__|         \ \_\  \ \_____\  \ \_\ \_\  \ \_\ \ \_\  \ \_\  \ \_\\"\_\  \ \_\ \_\  \ \_____\
39 |   \/_____/   \/_/           \/_/   \/_____/   \/_/ /_/   \/_/  \/_/   \/_/   \/_/ \/_/   \/_/\/_/   \/_____/
40 |
Antoine DAUTRY
41 |

Welcome to my CV! To view the available commands type help. To validate each command press Enter, you can use the Tab key to help you complete a command.

42 |
43 |
44 |
45 |
46 | 47 |
48 | 49 | 50 | 51 |
52 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/resources/commands.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "command": "help", 4 | "responseType": "list", 5 | "value": [ 6 | "about : Displays information about me", 7 | "clear : Clear terminal", 8 | "experiences : Display the list of my professional experiences", 9 | "get cv : Download CV", 10 | "help : Display help", 11 | "hobbies : Display the list of my hobbies", 12 | "projects : Display the list of my personal projects", 13 | "dark/light : Change theme", 14 | "You can use the TAB key to complete a command", 15 | "You can find old commands with the up and down arrows." 16 | ] 17 | }, 18 | { 19 | "command": "about", 20 | "responseType": "code", 21 | "value": [ 22 | "{", 23 | " \"name\" : \"Antoine DAUTRY\",", 24 | " \"poste\" : \"Fullstack developer\",", 25 | " \"experience\" : \"4 years\",", 26 | " \"city\" : \"Nantes, France\"", 27 | "}" 28 | ] 29 | }, 30 | { 31 | "command": "experiences", 32 | "responseType": "table", 33 | "headers": [ 34 | "Date", 35 | "Client", 36 | "Description", 37 | "Tech" 38 | ], 39 | "rows": [ 40 | [ 41 | "07/2023
Now", 42 | "Airbus Helicopters", 43 | "Setting up a website to provide tools for several
internal departments", 44 | "Angular 16
Symfony 6
EventSource" 45 | ], 46 | [ 47 | "11/2021
06/2023", 48 | "Elephant Tech
Ministry of Justice", 49 | "Establishment of a platform linking the various actors
in the reintegration of former prisoners.", 50 | "Angular 11
Spring Boot
Axelor" 51 | ], 52 | [ 53 | "03/2021
11/2021", 54 | "SII
La Poste", 55 | "La Poste's internal tool for allocating technicians to different
missions.", 56 | "Angular 11
Spring Boot
Spring Batch" 57 | ], 58 | [ 59 | "02/2020
03/2021", 60 | "SII
I.T. Dept", 61 | "Maintenance of an internal timesheet tool.
Development of plugins for our version of ProjeQtor.
Conversion of an old webapp to Angular 9.", 62 | "Symfony
Angular 9" 63 | ], 64 | [ 65 | "11/2019
02/2020", 66 | "SII
Poste IMMO", 67 | "Work on a process management tool to manage the evolution of a work request.", 68 | "Symfony 3.4
AngularJS
Processmaker" 69 | ] 70 | ] 71 | }, 72 | { 73 | "command": "hobbies", 74 | "responseType": "list", 75 | "value": [ 76 | "Music: Piano, Guitar", 77 | "Programmation: JS, Angular, PHP", 78 | "Other: Cinema, Aeronautics, Photography" 79 | ] 80 | }, 81 | { 82 | "command": "projects", 83 | "responseType": "table", 84 | "headers": [ 85 | "Name", 86 | "Description", 87 | "Tech", 88 | "Links" 89 | ], 90 | "rows": [ 91 | [ 92 | "Form to Resume - Web
(2023)", 93 | "I set up a private web page where I can manually change data from my resume via a form. After validating changes, this will generate a PDF file that will be available on the public link.", 94 | "Angular 16, PHP 8.2", 95 | "Links" 96 | ], 97 | [ 98 | "Chartsfinder - Web
(2021)", 99 | "Web application to quickly find aeronautical maps. A C# version already existed but I preferred to update it with a web version which is easier to use.", 100 | "Angular 11, PHP 7.4", 101 | "Links" 102 | ], 103 | [ 104 | "Personal website
(2021)", 105 | "Personal website allowing me to show my projects and deploy a new version of the software.
There is even a hidden game...", 106 | "Symfony 5", 107 | "Link" 108 | ], 109 | [ 110 | "Chartsfinder - Software
(2020)", 111 | "Software to quickly retrieve aeronautical charts.", 112 | "C# WPF", 113 | "Link" 114 | ] 115 | ] 116 | } 117 | ] 118 | -------------------------------------------------------------------------------- /src/scss/_halloween.scss: -------------------------------------------------------------------------------- 1 | .halloween-bg { 2 | position: fixed; 3 | top: 0; 4 | height: 100vh; 5 | width: 100vw; 6 | overflow: hidden; 7 | object-fit: cover; 8 | object-position: top; 9 | opacity: .5; 10 | filter: saturate(1.5) brightness(1.2); 11 | } 12 | 13 | body.halloween .terminal .terminal__body { 14 | background: #00000036; 15 | box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); 16 | backdrop-filter: blur(5px); 17 | } 18 | 19 | body:not(.dark-mode) .halloween-bg { 20 | opacity: .8; 21 | } -------------------------------------------------------------------------------- /src/scss/_santa.scss: -------------------------------------------------------------------------------- 1 | #santa { 2 | width: 150px; 3 | position: absolute; 4 | top: 50px; 5 | right: -200px; 6 | z-index: 1; 7 | } -------------------------------------------------------------------------------- /src/scss/_snowflakes.scss: -------------------------------------------------------------------------------- 1 | .snowflake { 2 | color: #fff; 3 | font-size: 1em; 4 | font-family: Arial, sans-serif; 5 | text-shadow: 0 0 5px #000; 6 | } 7 | 8 | @-webkit-keyframes snowflakes-fall { 9 | 0% { 10 | top: -10% 11 | } 12 | 100% { 13 | top: 100% 14 | } 15 | } 16 | 17 | @-webkit-keyframes snowflakes-shake { 18 | 0%, 100% { 19 | -webkit-transform: translateX(0); 20 | transform: translateX(0) 21 | } 22 | 50% { 23 | -webkit-transform: translateX(80px); 24 | transform: translateX(80px) 25 | } 26 | } 27 | 28 | @keyframes snowflakes-fall { 29 | 0% { 30 | top: -10% 31 | } 32 | 100% { 33 | top: 100% 34 | } 35 | } 36 | 37 | @keyframes snowflakes-shake { 38 | 0%, 100% { 39 | transform: translateX(0) 40 | } 41 | 50% { 42 | transform: translateX(80px) 43 | } 44 | } 45 | 46 | .snowflake { 47 | position: fixed; 48 | top: -10%; 49 | z-index: 9999; 50 | -webkit-user-select: none; 51 | -moz-user-select: none; 52 | -ms-user-select: none; 53 | user-select: none; 54 | cursor: default; 55 | -webkit-animation-name: snowflakes-fall, snowflakes-shake; 56 | -webkit-animation-duration: 10s, 3s; 57 | -webkit-animation-timing-function: linear, ease-in-out; 58 | -webkit-animation-iteration-count: infinite, infinite; 59 | -webkit-animation-play-state: running, running; 60 | animation-name: snowflakes-fall, snowflakes-shake; 61 | animation-duration: 10s, 3s; 62 | animation-timing-function: linear, ease-in-out; 63 | animation-iteration-count: infinite, infinite; 64 | animation-play-state: running, running 65 | } 66 | 67 | .snowflake:nth-of-type(0) { 68 | left: 1%; 69 | -webkit-animation-delay: 0s, 0s; 70 | animation-delay: 0s, 0s 71 | } 72 | 73 | .snowflake:nth-of-type(1) { 74 | left: 10%; 75 | -webkit-animation-delay: 1s, 1s; 76 | animation-delay: 1s, 1s 77 | } 78 | 79 | .snowflake:nth-of-type(2) { 80 | left: 20%; 81 | -webkit-animation-delay: 6s, .5s; 82 | animation-delay: 6s, .5s 83 | } 84 | 85 | .snowflake:nth-of-type(3) { 86 | left: 30%; 87 | -webkit-animation-delay: 4s, 2s; 88 | animation-delay: 4s, 2s 89 | } 90 | 91 | .snowflake:nth-of-type(4) { 92 | left: 40%; 93 | -webkit-animation-delay: 2s, 2s; 94 | animation-delay: 2s, 2s 95 | } 96 | 97 | .snowflake:nth-of-type(5) { 98 | left: 50%; 99 | -webkit-animation-delay: 8s, 3s; 100 | animation-delay: 8s, 3s 101 | } 102 | 103 | .snowflake:nth-of-type(6) { 104 | left: 60%; 105 | -webkit-animation-delay: 6s, 2s; 106 | animation-delay: 6s, 2s 107 | } 108 | 109 | .snowflake:nth-of-type(7) { 110 | left: 70%; 111 | -webkit-animation-delay: 2.5s, 1s; 112 | animation-delay: 2.5s, 1s 113 | } 114 | 115 | .snowflake:nth-of-type(8) { 116 | left: 80%; 117 | -webkit-animation-delay: 1s, 0s; 118 | animation-delay: 1s, 0s 119 | } 120 | 121 | .snowflake:nth-of-type(9) { 122 | left: 90%; 123 | -webkit-animation-delay: 3s, 1.5s; 124 | animation-delay: 3s, 1.5s 125 | } 126 | 127 | .snowflake:nth-of-type(10) { 128 | left: 25%; 129 | -webkit-animation-delay: 2s, 0s; 130 | animation-delay: 2s, 0s 131 | } 132 | 133 | .snowflake:nth-of-type(11) { 134 | left: 65%; 135 | -webkit-animation-delay: 4s, 2.5s; 136 | animation-delay: 4s, 2.5s 137 | } 138 | -------------------------------------------------------------------------------- /src/scss/style.scss: -------------------------------------------------------------------------------- 1 | $border-radius: 5px; 2 | 3 | :root { 4 | --text-color: #fff; 5 | --text-accent-color: darksalmon; 6 | --link-color: darkorange; 7 | --bg-1: #f27121; 8 | --bg-2: #e94057; 9 | --bg-3: #8a2387; 10 | --bg-1-social: #f3a183; 11 | --bg-2-social: #ec6f66; 12 | --username-color: cadetblue; 13 | --terminal-bg: rgba(56, 4, 40, 0.9); 14 | --terminal-header-bg: #bbb; 15 | } 16 | 17 | body { 18 | overflow: hidden; 19 | 20 | &.dark-mode { 21 | --text-accent-color: #ffca85; 22 | --link-color: burlywood; 23 | --bg-1: #211F20; 24 | --bg-2: #292D34; 25 | --bg-3: #213030; 26 | --bg-1-social: #414141; 27 | --bg-2-social: #485461; 28 | --username-color: #858585; 29 | --terminal-bg: rgb(0 0 0 / 90%); 30 | --terminal-header-bg: #585252; 31 | 32 | &.firework { 33 | --terminal-bg: rgb(0 0 0 / 15%); 34 | } 35 | } 36 | 37 | box-sizing: border-box; 38 | margin: 0; 39 | display: flex; 40 | justify-content: space-around; 41 | align-items: center; 42 | flex-direction: column; 43 | height: 100vh; 44 | background: var(--bg-3); /* fallback for old browsers */ 45 | background: -webkit-linear-gradient( 46 | to right, 47 | var(--bg-1), 48 | var(--bg-2), 49 | var(--bg-3) 50 | ); /* Chrome 10-25, Safari 5.1-6 */ 51 | background: linear-gradient( 52 | to right, 53 | var(--bg-1), 54 | var(--bg-2), 55 | var(--bg-3) 56 | ); /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */ 57 | } 58 | 59 | ul { 60 | margin: 0; 61 | } 62 | 63 | .terminal { 64 | position: absolute; 65 | resize: both; 66 | overflow: hidden; 67 | height: 450px; 68 | width: min(900px, 90vw); 69 | 70 | &::-webkit-resizer { 71 | background: transparent; 72 | } 73 | 74 | .terminal__header { 75 | height: 25px; 76 | padding: 0 8px; 77 | background-color: var(--terminal-header-bg); 78 | margin: 0 auto; 79 | border-top-right-radius: $border-radius; 80 | border-top-left-radius: $border-radius; 81 | cursor: move; 82 | 83 | .fake-button { 84 | height: 10px; 85 | width: 10px; 86 | border-radius: 50%; 87 | border: 1px solid #000; 88 | position: relative; 89 | top: 6px; 90 | left: 6px; 91 | display: inline-block; 92 | cursor: pointer; 93 | 94 | &.fake-close { 95 | left: 6px; 96 | background-color: #ff3b47; 97 | border-color: #9d252b; 98 | } 99 | 100 | &.fake-minimize { 101 | left: 11px; 102 | background-color: #ffc100; 103 | border-color: #9d802c; 104 | } 105 | 106 | &.fake-zoom { 107 | left: 16px; 108 | background-color: #00d742; 109 | border-color: #049931; 110 | } 111 | } 112 | } 113 | 114 | .terminal__body { 115 | font-family: "Ubuntu Mono", monospace; 116 | background: var(--terminal-bg); 117 | color: var(--text-color); 118 | padding: 8px; 119 | overflow-y: scroll; 120 | overflow-x: hidden; 121 | box-shadow: rgba(0, 0, 0, 0.2) 0px 12px 28px 0px, 122 | rgba(0, 0, 0, 0.1) 0px 2px 4px 0px, 123 | rgba(255, 255, 255, 0.05) 0px 0px 0px 1px inset; 124 | border-bottom-right-radius: $border-radius; 125 | border-bottom-left-radius: $border-radius; 126 | height: calc(100% - 41px); 127 | 128 | /* width */ 129 | &::-webkit-scrollbar { 130 | width: 5px; 131 | } 132 | 133 | /* Track */ 134 | &::-webkit-scrollbar-track { 135 | background: transparent; 136 | } 137 | 138 | /* Handle */ 139 | &::-webkit-scrollbar-thumb { 140 | background: var(--text-accent-color); 141 | } 142 | 143 | /* Handle on hover */ 144 | &::-webkit-scrollbar-thumb:hover { 145 | background: var(--text-accent-color); 146 | } 147 | 148 | code { 149 | color: var(--text-accent-color); 150 | font-size: 14px; 151 | } 152 | 153 | .terminal__banner { 154 | display: flex; 155 | flex-direction: column; 156 | justify-content: center; 157 | color: var(--text-color); 158 | 159 | .terminal__author { 160 | text-align: right; 161 | } 162 | } 163 | 164 | .terminal__line { 165 | margin-bottom: 8px; 166 | 167 | &::before { 168 | content: "Antoine DAUTRY ~$ "; 169 | color: var(--username-color); 170 | } 171 | 172 | input[type="text"] { 173 | background: none; 174 | border: none; 175 | font-family: "Ubuntu Mono", monospace; 176 | color: var(--text-color); 177 | outline: none; 178 | font-size: 15px; 179 | width: calc(100% - 150px); 180 | } 181 | } 182 | 183 | .terminal__response { 184 | margin: 8px 0 16px 0; 185 | 186 | table { 187 | border: 1px dashed; 188 | padding: 4px; 189 | width: 100%; 190 | 191 | a { 192 | text-decoration: none; 193 | color: darkorange; 194 | } 195 | 196 | thead { 197 | th { 198 | font-weight: normal; 199 | color: cadetblue; 200 | border-bottom: 1px solid white; 201 | padding-bottom: 4px; 202 | } 203 | } 204 | 205 | tbody { 206 | td { 207 | padding: 4px; 208 | } 209 | 210 | tr:not(:last-child) { 211 | td { 212 | border-bottom: 1px solid white; 213 | } 214 | } 215 | } 216 | } 217 | } 218 | } 219 | } 220 | 221 | .socials { 222 | position: absolute; 223 | right: 16px; 224 | bottom: 16px; 225 | display: flex; 226 | gap: 16px; 227 | 228 | a { 229 | border-radius: 50%; 230 | background: var(--bg-2-social); /* fallback for old browsers */ 231 | background: -webkit-linear-gradient( 232 | to left, 233 | var(--bg-1-social), 234 | var(--bg-2-social) 235 | ); /* Chrome 10-25, Safari 5.1-6 */ 236 | background: linear-gradient( 237 | to left, 238 | var(--bg-1-social), 239 | var(--bg-2-social) 240 | ); /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */ 241 | box-shadow: rgba(99, 99, 99, 0.2) 0px 2px 8px 0px; 242 | width: 4em; 243 | height: 4em; 244 | display: flex; 245 | justify-content: center; 246 | align-items: center; 247 | text-decoration: none; 248 | 249 | &:hover { 250 | background: var(--bg-2-social); /* fallback for old browsers */ 251 | background: -webkit-linear-gradient( 252 | to right, 253 | var(--bg-1-social), 254 | var(--bg-2-social) 255 | ); /* Chrome 10-25, Safari 5.1-6 */ 256 | background: linear-gradient( 257 | to right, 258 | var(--bg-1-social), 259 | var(--bg-2-social) 260 | ); /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */ 261 | box-shadow: rgba(99, 99, 99, 0.2) 0px 2px 8px 0px; 262 | width: 4em; 263 | height: 4em; 264 | display: flex; 265 | justify-content: center; 266 | align-items: center; 267 | text-decoration: none; 268 | } 269 | 270 | i { 271 | color: white; 272 | font-size: 2em; 273 | } 274 | } 275 | } 276 | 277 | #banner-github { 278 | position: absolute; 279 | top: 0; 280 | right: 0; 281 | } 282 | 283 | @media (max-width: 880px) { 284 | .terminal .terminal__body { 285 | .terminal__banner { 286 | pre { 287 | font-size: 10px; 288 | } 289 | } 290 | } 291 | } 292 | 293 | @media (max-width: 640px) { 294 | body { 295 | align-items: center; 296 | flex-direction: column; 297 | justify-content: space-evenly; 298 | } 299 | canvas { 300 | position: fixed; 301 | top: 0; 302 | bottom: 0; 303 | left: 0; 304 | right: 0; 305 | z-index: -1; 306 | } 307 | 308 | .terminal { 309 | position: unset; 310 | width: unset; 311 | height: unset; 312 | resize: none; 313 | z-index: 2; 314 | 315 | .terminal__body { 316 | max-width: unset; 317 | width: 90vw; 318 | height: 70vh; 319 | 320 | .terminal__banner { 321 | pre { 322 | font-size: 5px; 323 | } 324 | } 325 | } 326 | } 327 | .socials { 328 | font-size: 13px; 329 | position: relative; 330 | bottom: unset; 331 | right: unset; 332 | } 333 | #banner-github { 334 | img { 335 | width: 100px; 336 | height: 100px; 337 | } 338 | } 339 | #version { 340 | top: 38px; 341 | right: 38px; 342 | font-size: 13px; 343 | } 344 | } 345 | 346 | @import "snowflakes"; 347 | @import "halloween"; 348 | @import "santa"; 349 | -------------------------------------------------------------------------------- /src/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name":"Resume Antoine DAUTRY", 3 | "short_name":"Resume", 4 | "icons":[ 5 | { 6 | "src":"./android-chrome-192x192.png", 7 | "sizes":"192x192", 8 | "type":"image/png" 9 | }, 10 | { 11 | "src":"./android-chrome-512x512.png", 12 | "sizes":"512x512", 13 | "type":"image/png" 14 | } 15 | ], 16 | "theme_color":"#ffffff", 17 | "background_color":"#ffffff", 18 | "display":"standalone" 19 | } 20 | -------------------------------------------------------------------------------- /src/sound/jingle-bells.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoine1003/resume-terminal/7049af292e6573fb45b646f698a1915b6e6cd345/src/sound/jingle-bells.mp3 -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert HTML to DOM object 3 | * @param html 4 | * @returns {DocumentFragment} 5 | */ 6 | export function stringToDom(html) { 7 | return document.createRange().createContextualFragment(html); 8 | } --------------------------------------------------------------------------------