Résume/CV Terminal
Contexte
Ca faisait un petit moment que je cherchais à réaliser un projet sympa qui soit en lien avec mon activité de développeur. J'ai donc penser à une façon original de représenter mon CV, le format sous forme de terminal m'a rapidement semblé répondre à ces critères. L'ensemble du code est disponible sur github.
Fonctionnement
Général
J'avais dans un premier temps réalisé une première version de ce projet sous la forme d'un terminal dans lequel les commandes se jouaient automatiquement. Après réflexion je me suis dit que ça serait plus sympa d'ajouter un peu d'interaction pour pousser plus loin le réalisme du terminal, c'est chose faite !
Dans ce projet, j'ai découpé les commandes en deux types. Les premières celles que je vais appeler "classiques" sont celles qui se situent dans le fichier commands.json
j'associe à chaque commande un retour à afficher en réponse de la commande.
Il y a au moment ou j'écris ces lignes 4 types de retours possibles :
- list: Une liste qui sera affichée sous forme de puces
- text: Qui sera simplement affiché comme du texte
- code: Qui sera affiché avec un format monospace
- table: Qui affiche un tableau
Par exemple, ici pour la commande a-propos
j'indique que ce qui va être affiché est de type code: ça sera mis entre des balises pre
(on voit ça plus loin).
// Extrait du fichier commands.json
{
"command":"a-propos",
"responseType":"code",
"value":[
"{",
" \"nom\" : \"Antoine DAUTRY\",",
" \"poste\" : \"Développeur Fullstack\",",
" \"experience\" : \"2 ans\",",
" \"ville\" : \"Nantes, France\"",
"}"
]
}
Le deuxième type de commande que j'appelle "custom" sont des commandes qui nécessitent un traitement javascript par exemple pour la commande clear
je dois en JS supprimer les div en trop.
Je vous propose de voir plus en détail dans la partie technique comment ça marche.
Technique
Pour ce projet j'utilise du Javascript et parcel pour pouvoir générer le code final.
Côté html j'ai quelques éléments important : .terminal__body
contient toute le corps du terminal et la partie #terminal
elle va me servir à injecter les nouvelles lignes, c'est donc dans cette div que tout se passe.
<div class="terminal">
<div class="terminal__header">
<div class="fake-button fake-close"></div>
<div class="fake-button fake-minimize"></div>
<div class="fake-button fake-zoom"></div>
</div>
<div class="terminal__body">
<div class="terminal__banner">
<!-- Ici il y a la bannière, je la retire pour faire un peu de place :D -->
</div>
<div id="terminal"></div>
</div>
</div>
Dans mon JS à chaque nouvelle ligne générée je vais créer une div.terminal__line
qui va contenir la ligne où il y a l'input et une div.terminal__response
qui contiendra la réponse associée, j'injecte tout ça dans la div#terminal
.
Récupération des commandes
C'est dans le fichier app.js
que tout se passe, dans un premier temps je vais récupérer l'ensemble des commandes disponibles:
// Tableau contenant les commandes (utile pour la complétion des commandes)
let commandsList = [];
// Je récupère toutes les commandes qui sont dans le fichier commands.json
commands.forEach((c) => {
commandsList.push(c.command);
});
// Commandes qui nécessitent un traitement JS
const customCommands = ["clear", "dark", "light", "get cv"];
commandsList = commandsList.concat(customCommands);
// Commandes 'easter eggs' non disponibles à l'autocomplétion
const hiddenCommands = ["pif"];
Ajout des lignes
Au chargement de la page je dois générer une première ligne que j'injecte dans la div#terminal
. Pour faire ça, j'ai créé une fonction qui porte bien son nom: addNewLine
. C'est une des deux fonctions principales de ce projet.
J'ai du me poser une question:
Comment je me débrouille pour que quand un utilisateur tape une commande la réponse soit placée dans la div.terminal__response
associée?
Pour résoudre ce problème j'ai opté pour l'uid.
function addNewLine(previousUid = null) {
// Je génère ici un uid qui sera associé à mon input
const uid = Math.random().toString(36).replace("0.", "");
// Je crée l'élément div.terminal__line
const terminalLineEl = document.createElement("div");
terminalLineEl.classList.add("terminal__line");
// l'élément div.terminal__response
const terminalResponseEl = document.createElement("div");
terminalResponseEl.classList.add("terminal__response");
// Le donne comme id à ma réponse 'reponse-' puis j'ajoute l'uid qui me permettera de le retrouver facilement
terminalResponseEl.id = `response-${uid}`;
// input text
const inputEl = document.createElement("input");
inputEl.type = "text";
inputEl.id = `input-${uid}`;
inputEl.autocapitalize = "off";
// J'ajoute l'uid dans le dataset de l'input
// Je sais donc que si une commande est tappée dans l'input avec un dataset-uid à 123
// Je dois mettre la réponse dans la div#response-123
inputEl.dataset.uid = uid;
// Utile pour le focus, quand l'utilisateur clique n'importe où sur la page ça va mettre
// le focus sur l'élément qui a le dataset active
inputEl.dataset.active = "1";
inputEl.addEventListener("keydown", onCommandInput); // J'ajoute un listenner pour écouter quand l'utilisateur tape
terminalLineEl.appendChild(inputEl);
if (previousUid) {
const previousInputEl = document.getElementById(previousUid);
if (previousInputEl) {
// Si il y a une ligne précédente je dois :
// - Désactiver l'input
previousInputEl.setAttribute("disabled", "true");
// - Lui retirer le listenner
previousInputEl.removeEventListener("keydown", onCommandInput);
// - Supprimer le active, ce n'est plus ce champ qui dois être focus
delete previousInputEl.dataset.active;
}
}
document.getElementById("terminal").appendChild(terminalLineEl);
document.getElementById("terminal").appendChild(terminalResponseEl);
inputEl.focus(); // Ajoute le focus dès la création du champs
}
Gestion des commandes
Ja gestion des commandes va se passer dans la fonction qui écoute l'input: onCommandInput
function onCommandInput(e) {
const commandValue = e.target.value.trim().toLowerCase();
if (e.keyCode === 13) {
// Si l'utilisateur appuie sur Entrer
if (commandValue !== "") {
historyMode = false;
// On récupère déjà l'id de la réponse correspondant à l'input en question
const idResponse = `response-${e.target.dataset.uid}`;
const responseEl = document.getElementById(idResponse);
if (commandValue === "clear") {
// Ici je gère directement le clear mais je pourrai/vais le faire dans la fonction
// handleCustomCommands
terminalBody.innerHTML = `<div id="terminal"></div>`;
addNewLine();
return;
}
let html;
if (hiddenCommands.includes(commandValue) || customCommands.includes(commandValue)) {
// Ici la commande nécessite un traitement JS qui va être traitée par la fonction handleCustomCommands
html = handleCustomCommands(commandValue);
} else {
// La commande ne nécessite pas de JS, donc on va récupérer le html correspondant dans la fonction getDomForCommand
// J'explique en dessous son fonctionnement
html = getDomForCommand(commandValue);
}
if (responseEl) {
responseEl.innerHTML = html; // Ajout de la réponse dans la div associée
commandsHistory.push(commandValue); // Ajout de la commande à l'historique
addNewLine(e.target.id); // Ajoute d'une nouvelle ligne
}
}
}
// [...]
}
Comme je l'ai expliqué plus haut, le fichier commands.json
va contenir la liste des commandes disponibles et la valeur à afficher. Chaque valeur a un type particulier et doit être convertit en HTML en fonction de ce même type. Tout ça se passe dans la fonction getDomForCommand
.
function getDomForCommand(command) {
// On récupère l'objet JSON associé à cette commande
const commandObj = commands.find((el) => el.command === command);
let html = "";
if (commandObj === undefined) {
// Si l'objet est vide c'est qu'il n'y a pas de commande et donc => message d'erreur.
html = `'${
command.split(" ")[0]
}' n’est pas reconnu en tant que commande interne ou externe, un programme exécutable ou un fichier de commandes. Tapez la commande <code>help</code> pour afficher la liste des commandes disponibles.`;
} else {
if (commandObj.responseType === "list" && Array.isArray(commandObj.value)) {
// Si c'est une liste on met les valeurs entre des balises ul
html = "<ul>";
html += commandObj.value.map((s) => `<li>${s}</li>`).join("");
html += "</ul>";
} else if (commandObj.responseType === "text") {
html = commandObj.value;
} else if (commandObj.responseType === "table") {
// Si c'est un tableau on met les valeurs entre des balises table et on construit le tableau
const headers = commandObj.headers;
const rows = commandObj.rows;
const thsHtml = headers.map((h) => `<th>${h}</th>`).join("");
const tdsHtml = rows
.map((r) => `<tr>${r.map((rtd) => `<td>${rtd}</td>`).join("")}</tr>`)
.join("");
html = `<table><thead><tr>${thsHtml}</tr></thead><tbody>${tdsHtml}</tbody></table>`;
} else if (commandObj.responseType === "code") {
// Et pour le code on met tout entre des balises pre
html = `<pre>${commandObj.value.join("\n")}</pre>`;
}
}
return html;
}
Voilà pour le fonctionnement global de ce petit projet 😀