Markdown on !!

This commit is contained in:
Lensors 2025-04-28 18:32:16 +02:00
parent 406e69e5f4
commit 01d08ab44a
21 changed files with 1218 additions and 294 deletions

View File

@ -1,49 +1,63 @@
#Structure Générale de metadata
{
"type": "TITRE",
"metadata": {
... // dépend du type
}
"type": "TITRE",
"metadata": {
... // dépend du type
}
}
#Détail par type
##TEXTE
{
"type": "TEXTE",
"metadata": {}
"type": "TEXTE",
"metadata": {}
}
##TITRE
{
"type": "TITRE",
"metadata": {
"level": 1 // ou 2, 3...
}
"type": "TITRE",
"metadata": {
"level": 1 // ou 2, 3...
}
}
##LISTE
{
"type": "LISTE",
"metadata": {
"style": "bullet" // ou "numbered"
}
"type": "LISTE",
"metadata": {
"style": "bullet" // ou "numbered"
}
}
##CODE
{
"type": "CODE",
"metadata": {
"language": "javascript", // ou "python", "html", etc.
}
"type": "CODE",
"metadata": {
"language": "javascript", // ou "python", "html", etc.
}
}
##PAGE
{
"type": "PAGE",
"metadata": {
"pageId": "123", // ID de la page cible
"title": "Nom de la page liée"
}
"type": "PAGE",
"metadata": {
"pageId": "123", // ID de la page cible
"title": "Nom de la page liée",
"from" : "125" // ID de la page
}
}
##SEPARATEUR
{
"type": "SEPARATEUR",
"metadata": {}
}
##CITATION
{
"type": "CITATION",
"metadata": {
"type": 'danger', 'info' ou 'normal'
}
}

View File

@ -1,3 +1,6 @@
CREATE DATABASE IF NOT EXISTS projet;
USE projet;
DROP TABLE IF EXISTS bloc;
DROP TABLE IF EXISTS partage;
DROP TABLE IF EXISTS page;
@ -18,19 +21,21 @@ CREATE TABLE page (
date_modification DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
auteur_id INT NOT NULL,
droits ENUM('LECTURE', 'ECRITURE', 'ADMIN') NOT NULL,
FOREIGN KEY (auteur_id) REFERENCES utilisateur(id) ON DELETE CASCADE
page_parent_id INT DEFAULT NULL,
FOREIGN KEY (auteur_id) REFERENCES utilisateur(id) ON DELETE CASCADE,
FOREIGN KEY (page_parent_id) REFERENCES page(id) ON DELETE SET NULL
);
CREATE TABLE bloc (
id INT PRIMARY KEY AUTO_INCREMENT,
type ENUM('TEXTE', 'LISTE', 'TITRE', 'CODE', 'PAGE') NOT NULL DEFAULT 'TEXTE',
type ENUM('TEXTE', 'LISTE', 'TITRE', 'CODE', 'PAGE', 'SEPARATEUR', 'CITATION') NOT NULL DEFAULT 'TEXTE',
contenu TEXT,
date_creation DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
date_modification DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
page_id INT NOT NULL,
ordre INT NOT NULL,
auteur_id INT NOT NULL,
metadata JSON NOT NULL DEFAULT '{}',
metadata JSON NOT NULL,
FOREIGN KEY (page_id) REFERENCES page(id) ON DELETE CASCADE,
FOREIGN KEY (auteur_id) REFERENCES utilisateur(id) ON DELETE CASCADE
);

View File

@ -23,7 +23,9 @@ public class Bloc extends ParamBD {
LISTE,
TITRE,
CODE,
PAGE
PAGE,
SEPARATEUR,
CITATION
}
public Bloc() {

View File

@ -0,0 +1,34 @@
package projet;
public interface BlocRenderer {
String renderTexte(String contenu);
String renderListe(String contenu);
String renderTitre(String contenu);
String renderCode(String contenu);
String renderPage(String contenu);
String renderSeparateur();
String renderCitation(String contenu);
default String render(Bloc bloc) {
switch (bloc.getType()) {
case TEXTE:
return renderTexte(bloc.getContenu());
case LISTE:
return renderListe(bloc.getContenu());
case TITRE:
return renderTitre(bloc.getContenu());
case CODE:
return renderCode(bloc.getContenu());
case PAGE:
return renderPage(bloc.getContenu());
case SEPARATEUR:
return renderSeparateur();
case CITATION:
return renderCitation(bloc.getContenu());
default:
throw new IllegalArgumentException("Type de bloc inconnu : " + bloc.getType());
}
}
}

View File

@ -0,0 +1,60 @@
package projet;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.time.LocalDate;
@WebServlet("/NouvellePageDirect")
public class NouvellePageDirect extends HttpServlet {
private static final long serialVersionUID = 1L;
public NouvellePageDirect() {
super();
}
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
HttpSession session = request.getSession();
Utilisateur u = (Utilisateur) session.getAttribute("utilisateur");
if(u != null) {
response.sendRedirect("AfficherPage");
}else {
response.sendRedirect("/Projet/");
}
}
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
HttpSession session = request.getSession();
Utilisateur u = (Utilisateur) session.getAttribute("utilisateur");
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
if(u != null) {
request.setCharacterEncoding("UTF-8");
String titre = request.getParameter("titre");
String pageIdStr = request.getParameter("pageId");
if (titre == null || titre.trim().isEmpty()) {
response.getWriter().write("{\"error\": \"Titre vide\"}");
} else {
int pageId = Integer.parseInt(pageIdStr);
int id = Page.ajouterPage(u.getId(), titre, LocalDate.now(), pageId);
if (id != -1) {
response.getWriter().write("{\"id\": " + id + "," + "\"pageId\": " + pageId + "}");
} else {
response.getWriter().write("{\"error\": \"Erreur de création\"}");
}
}
} else {
response.getWriter().write("{\"error\": \"Utilisateur non connecté\"}");
}
}
}

View File

@ -144,6 +144,42 @@ public class Page extends ParamBD {
return idGenere;
}
protected static int ajouterPage(int idU, String t, LocalDate dl, int idParent) {
int idGenere = -1;
try {
Connection connexion = DriverManager.getConnection(bdURL, bdLogin, bdPassword);
String sql = " INSERT INTO page(titre, date_creation, date_modification, auteur_id, droits, page_parent_id)"
+ " VALUES (?, ?, ?, ?, ?, ?)"
+ ";";
PreparedStatement pst = connexion.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
pst.setString(1, t);
pst.setDate(2, Date.valueOf(dl));
pst.setDate(3, Date.valueOf(dl));
pst.setInt(4, idU);
pst.setString(5, "ADMIN");
pst.setInt(6, idParent);
pst.executeUpdate();
ResultSet rs = pst.getGeneratedKeys();
if (rs.next()) {
idGenere = rs.getInt(1);
}
rs.close();
pst.close();
connexion.close();
} catch (SQLException e) {
e.printStackTrace();
}
if (idGenere != -1) {
Bloc.ajouterBlocVide(idU, idGenere);
}
return idGenere;
}
protected static Page getPageById(int idU, int id) {
Page page = new Page();
String titre = null;

View File

@ -7,71 +7,70 @@
</jsp:include>
<div class="columns">
<div class="columns">
<!-- La colonne pour le menu des pages -->
<div class="column is-one-fifth">
<jsp:include page="MenuPages.jsp" />
</div>
<!-- La colonne pour le menu des pages -->
<div class="column is-one-fifth">
<jsp:include page="MenuPages.jsp" />
</div>
<!-- La colonne pour la page choisie -->
<div class="column is-half">
<c:choose>
<c:when test="${not empty page.titre}">
<div class="is-flex is-justify-content-space-between is-align-items-center mb-4">
<h2 class="block">${page.titre}</h2>
<div class="dropdown is-right">
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu3">
<span><i class="fa-solid fa-share-nodes"></i></span>
<span class="icon is-small">
<i class="fas fa-angle-down" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu3" role="menu">
<div class="dropdown-content">
<c:forEach var="u" items="${listeUtilisateurs}">
<c:if test="${u.id != utilisateur.id}">
<a href="Partage?idP=${u.id}&idPage=${page.id}" class="dropdown-item">${u.login}</a>
</c:if>
</c:forEach>
</div>
</div>
</div>
<!-- La colonne pour la page choisie -->
<div class="column is-half">
<c:choose>
<c:when test="${not empty page.titre}">
<div class="is-flex is-justify-content-space-between is-align-items-center mb-4">
<h2 class="block">${page.titre}</h2>
<div class="dropdown is-right">
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu3">
<span><i class="fa-solid fa-share-nodes"></i></span>
<span class="icon is-small">
<i class="fas fa-angle-down" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu3" role="menu">
<div class="dropdown-content">
<c:forEach var="u" items="${listeUtilisateurs}">
<c:if test="${u.id != utilisateur.id}">
<a href="Partage?idP=${u.id}&idPage=${page.id}" class="dropdown-item">${u.login}</a>
</c:if>
</c:forEach>
</div>
</div>
</div>
</div>
<div class="block" id="md">
<c:forEach var="bloc" items="${page.listeBlocs}">
<div class="field is-grouped is-align-items-flex-start bloc-container" draggable="true">
<div class="control is-expanded">
<div
class="is-primary editor"
contenteditable="true"
data-id="${bloc.id}"
data-ordre="${bloc.ordre}"
data-type="${bloc.type}"
data-metadata='${bloc.metadata}'
>${bloc.contenu}</div>
</div>
<div class="control">
<button class="delete is-danger delete-bloc-btn" data-id="${bloc.id}"></button>
</div>
</div>
</c:forEach>
</div>
</c:when>
<c:otherwise>
<p>Pas encore de page choisie.</p>
</c:otherwise>
</c:choose>
</div>
<!-- La colonne pour le Tchat -->
<div class="column is-one-quarter">
<jsp:include page="Tchat.jsp" />
</div>
</div>
<div class="block" id="md">
<c:forEach var="bloc" items="${page.listeBlocs}">
<div class="field is-grouped is-align-items-flex-start bloc-container">
<div class="control is-expanded">
<div
class="textarea is-primary editor"
contenteditable="true"
rows="1"
data-id="${bloc.id}"
data-ordre="${bloc.ordre}"
data-type="${bloc.type}"
metadata="${bloc.metadata}"
>${bloc.contenu}</div>
</div>
<div class="control">
<button class="delete is-danger delete-bloc-btn" data-id="${bloc.id}"></button>
</div>
</div>
</c:forEach>
</div>
</c:when>
<c:otherwise>
<p>Pas encore de page choisie.</p>
</c:otherwise>
</c:choose>
</div>
<!-- La colonne pour le Tchat -->
<div class="column is-one-quarter">
<jsp:include page="Tchat.jsp" />
</div>
</div>
<jsp:include page="Footer.jsp" />

View File

@ -8,6 +8,6 @@
</a>
</footer>
</main>
<script src="script.js?v=<%= System.currentTimeMillis() %>"></script>
<script type="module" src="js/main.js"></script>
</body>
</html>

View File

@ -8,6 +8,9 @@
<link href="bulma.css" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
<script src="https://kit.fontawesome.com/39474be7e2.js" crossorigin="anonymous"></script>
<script type="text/javascript" async
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js">
</script>
</head>
<body>
<nav class="navbar has-shadow is-white" aria-label="main navigation">

View File

@ -2,34 +2,36 @@
pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<h2 class="block">Menu des pages</h2>
<aside class="menu">
<ul class="menu-list" id="menuPages">
<c:forEach var="page" items="${listePages}">
<li>
<button class="delete delete-page-btn is-pulled-right has-background-danger" data-id="${page.id}"></button>
<a href="AfficherPage?id=${page.id}">${page.titre}</a>
<h2 class="block">Menu des pages</h2>
<aside class="menu">
<ul class="menu-list" id="menuPages">
<c:forEach var="page" items="${listePages}">
<li>
<button class="delete delete-page-btn is-pulled-right has-background-danger" data-id="${page.id}"></button>
<a href="AfficherPage?id=${page.id}">${page.titre}</a>
</li>
</c:forEach>
<hr>
<c:forEach var="pagePartagees" items="${listePagesPartagees}">
<li>
<i class="fa-solid fa-share-nodes is-pulled-right"></i>
<a href="AfficherPage?id=${pagePartagees.id}">${pagePartagees.titre}</a>
</li>
</c:forEach>
<li>
<form action="NouvellePage" method="POST">
<input
class="input is-primary is-small"
type="text"
name="titre"
id="titrePage"
placeholder="Nom de votre nouvelle page"
onkeydown="if(event.key === 'Enter') this.form.submit();"
>
</form>
</li>
</ul>
</aside>
</li>
</c:forEach>
</ul>
<hr>
<ul class="menu-list">
<c:forEach var="pagePartagees" items="${listePagesPartagees}">
<li>
<i class="fa-solid fa-share-nodes is-pulled-right"></i>
<a href="AfficherPage?id=${pagePartagees.id}">${pagePartagees.titre}</a>
</li>
</c:forEach>
<li>
<form action="NouvellePage" method="POST">
<input
class="input is-primary is-small"
type="text"
name="titre"
id="titrePage"
placeholder="Nom de votre nouvelle page"
onkeydown="if(event.key === 'Enter') this.form.submit();"
>
</form>
</li>
</ul>
</aside>

View File

@ -2,21 +2,21 @@
pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<div class="tchat-container">
<h2 class="block">Tchat</h2>
<div class="messages-container" id="Messages" style="height: 60vh;">
<c:forEach var="message" items="${listeMessages}">
<p>
${message.login} : ${message.contenu}
</p>
</c:forEach>
</div>
<div class="block">
<input
class="input is-primary is-small"
type="text"
id="tchat"
placeholder="Tchatez ici"
name="contenu">
</div>
</div>
<div class="tchat-container">
<h2 class="block">Tchat</h2>
<div class="messages-container" id="Messages" style="height: 60vh;">
<c:forEach var="message" items="${listeMessages}">
<p>
${message.login} : ${message.contenu}
</p>
</c:forEach>
</div>
<div class="block">
<input
class="input is-primary is-small"
type="text"
id="tchat"
placeholder="Tchatez ici"
name="contenu">
</div>
</div>

View File

@ -0,0 +1,433 @@
import { autoResize, placeCursorAtEnd } from './utils.js';
import { createNewPage } from './page.js';
export function initBlocs() {
document.querySelectorAll('#md [contenteditable="true"]').forEach(bloc => {
addBlocEvent(bloc);
renderBlocStyle(bloc);
autoResize(bloc);
});
document.querySelectorAll('.editor a').forEach(link => {
link.addEventListener('click', event => {
event.preventDefault();
window.location.href = link.href;
});
});
document.querySelectorAll('.delete-bloc-btn').forEach(t => {
addDeleteBloc(t);
});
formatEditorContent();
document.querySelectorAll('#md [contenteditable="true"]').forEach(bloc => {
autoResize(bloc);
});
}
function formatEditorContent() {
document.querySelectorAll('.editor').forEach(function(element) {
let content = element.innerHTML;
content = content.replace(/\n/g, '<br />');
content = content.replace(/`(.*?)`/g, '<code>$1</code>&nbsp;');
content = content.replace(/\*\*(.*?)\*\*/g, '<b>$1</b>&nbsp;');
content = content.replace(/\*(.*?)\*/g, '<i>$1</i>&nbsp;');
element.innerHTML = content;
// Appliquer le rendu MathJax après modification du contenu
MathJax.typeset();
autoResize(element);
});
}
export function addBlocEvent(bloc) {
bloc.addEventListener('keydown', async event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
const texte = bloc.innerText.trim();
if (texte.startsWith("/")) {
// Gérer les instructions commençant par /
await handleSlashCommand(bloc, texte);
} else {
// Gérer les titres et citations markdown
await handleMarkdownSyntax(bloc, texte);
}
sauvegarderBloc(bloc);
ajouterBlocVideSiBesoin();
formatEditorContent();
focusDernierBloc();
}
});
bloc.addEventListener('input', () => autoResize(bloc));
}
export function sauvegarderBloc(bloc) {
const blocId = bloc.getAttribute('data-id');
const type = bloc.getAttribute('data-type');
const metadata = bloc.getAttribute('data-metadata');
const params = new URLSearchParams();
let contenuTexte = bloc.innerHTML
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<b>/gi, '**')
.replace(/<\/b>/gi, '**')
.replace(/<i>/gi, '*')
.replace(/<\/i>/gi, '*')
.replace(/<code>/gi, '`')
.replace(/<\/code>/gi, '`');
params.append("contenu", contenuTexte);
params.append("blocId", blocId);
params.append("metadata", metadata);
params.append("type", type);
fetch("/Projet/ModifBloc", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params
}).catch(() => console.error("Erreur lors de la mise à jour du bloc."));
}
export function addDeleteBloc(button) {
button.addEventListener('click', function() {
const blocId = button.dataset.id;
const blocContainer = button.closest('.bloc-container');
const bloc = blocContainer.querySelector('[contenteditable="true"]');
if (bloc.dataset.type != "SEPARATEUR" && bloc.innerText === "") {
return;
}
if (confirm("Voulez-vous vraiment supprimer ce bloc ?")) {
const params = new URLSearchParams();
params.append("blocId", blocId);
fetch("/Projet/SupprimerBloc", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: params
}).then(response => {
if (response.ok) {
blocContainer.remove(); // Supprime visuellement le bloc
ajouterBlocVideSiBesoin();
} else {
console.error("Erreur lors de la suppression du bloc.");
}
});
}
});
}
export function ajouterBlocVideSiBesoin() {
const allBlocs = document.querySelectorAll('#md [contenteditable="true"]');
const pageId = new URLSearchParams(window.location.search).get("id");
if (pageId === null) return;
const message = document.querySelector('.column.is-half p');
if (message && message.textContent.trim() === "Pas encore de page choisie.") {
return; // Arrête la fonction ici (se passe après avoir supprimé une page)
}
if (allBlocs.length === 0 || allBlocs[allBlocs.length - 1].innerText.trim() !== "") {
const params = new URLSearchParams();
params.append("contenu", ""); // Bloc vide
params.append("type", "TEXTE"); // Type par défaut : TEXTE
params.append("metadata", "{}");
params.append("ordre", allBlocs.length);
params.append("pageId", new URLSearchParams(window.location.search).get("id"));
fetch("/Projet/NouveauBloc", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: params.toString()
}).then(response => response.json())
.then(data => {
if (data && data.idGenere) {
const idGenere = data.idGenere;
const container = document.createElement('div');
container.classList.add('field', 'is-grouped', 'is-align-items-flex-start', 'bloc-container');
const control = document.createElement('div');
control.classList.add('control', 'is-expanded');
const newBloc = document.createElement('div');
newBloc.classList.add('is-primary', 'editor');
newBloc.setAttribute('contenteditable', 'true');
newBloc.setAttribute('data-type', 'TEXTE');
newBloc.setAttribute('data-id', idGenere);
newBloc.setAttribute('data-metadata', '{}');
control.appendChild(newBloc);
container.appendChild(control);
const delBtnWrapper = document.createElement('div');
delBtnWrapper.classList.add('control');
const delBtn = document.createElement('button');
delBtn.classList.add('delete', 'is-danger', 'delete-bloc-btn');
delBtn.setAttribute('data-id', idGenere);
delBtnWrapper.appendChild(delBtn);
container.appendChild(delBtnWrapper);
document.getElementById('md').appendChild(container);
addDeleteBloc(delBtn);
addBlocEvent(newBloc);
newBloc.focus();
}
});
}
}
export function focusDernierBloc() {
const blocs = document.querySelectorAll('.editor'); // Remplace `.bloc` par ta classe de bloc
const dernierBloc = blocs[blocs.length - 1];
if (dernierBloc) {
dernierBloc.focus();
}
}
function handleSlashCommand(bloc, texte) {
return new Promise((resolve) => {
const parts = texte.trim().substring(1).split(" ");
const command = parts[0].toLowerCase();
const param = parts.slice(1).join(" ");
switch (command) {
case "page":
const pageId = new URLSearchParams(window.location.search).get("id");
createNewPage(param || "Nouvelle page", pageId).then(data => {
if (data && data.id) {
const lien = `/Projet/AfficherPage?id=${data.id}`;
bloc.innerHTML = `<a href="${lien}">${param || "Nouvelle page"}</a>`;
applyBlocType(bloc, "PAGE", { pageId: data.id, title: param, from: pageId });
autoResize(bloc);
resolve();
} else {
console.error("Erreur de création de page");
resolve();
}
}).catch(error => {
console.error("Erreur lors de la création de la page :", error);
resolve();
});
break;
case "h1":
case "h2":
case "h3":
applyBlocType(bloc, "TITRE", { level: parseInt(command[1] || "1") });
bloc.textContent = param;
autoResize(bloc);
resolve();
break;
case "ul":
applyBlocType(bloc, "LISTE", { style: "bullet" });
bloc.textContent = '';
autoResize(bloc);
resolve();
break;
case "ol":
applyBlocType(bloc, "LISTE", { style: "numbered" });
bloc.textContent = '';
autoResize(bloc);
resolve();
break;
case "code":
applyBlocType(bloc, "CODE", { language: param || "plaintext" });
bloc.textContent = '';
autoResize(bloc);
resolve();
break;
case "hr":
applyBlocType(bloc, "SEPARATEUR", {}); // ou un type adapté selon ta logique
bloc.innerText = ' ';
autoResize(bloc);
resolve(true);
break;
default:
console.log(`Commande non reconnue: ${command}`);
resolve();
break;
}
bloc.focus();
placeCursorAtEnd(bloc);
});
}
const citationTypes = {
'>!': 'danger',
'>i': 'info',
/* '>w': 'warning',
'>s': 'success',*/
'>': 'normal' // doit rester en dernier
};
function detectCitation(texte) {
for (const prefix in citationTypes) {
if (texte.startsWith(prefix)) {
return {
type: citationTypes[prefix],
content: texte.replace(new RegExp(`^${prefix}\\s*`), '')
};
}
}
return null;
}
async function handleMarkdownSyntax(bloc, texte) {
// Vérifier si c'est un titre
if (texte.startsWith('#')) {
const level = texte.split(' ')[0].length; // Compter le nombre de # pour le niveau du titre
if (level >= 1 && level <= 6) {
applyBlocType(bloc, "TITRE", { level: level });
bloc.textContent = texte.replace(/^#+\s*/, '');
autoResize(bloc);
}
}
// Vérifier si c'est une citation
else if (texte.startsWith('>')) {
const citation = detectCitation(texte);
applyBlocType(bloc, "CITATION", { type: citation.type });
bloc.textContent = citation.content;
autoResize(bloc);
}
// Vérifier si c'est un bloc de code
else if (texte.startsWith('```')) {
applyBlocType(bloc, "CODE", {});
bloc.textContent = texte.replace(/^```(.*)$/, '');
autoResize(bloc);
}
// Vérifier si c'est un séparateur horizontal (Markdown)
else if (texte.match(/^(\*{3,}|\-{3,}|_{3,})$/)) {
applyBlocType(bloc, "SEPARATEUR", {});
bloc.textContent = '';
autoResize(bloc);
}
}
function getBlocDataMetadata(bloc) {
try {
return JSON.parse(bloc.getAttribute('data-metadata') || "{}");
} catch (e) {
return {};
}
}
function setBlocMetadata(bloc, metadata) {
const metadataStr = JSON.stringify(metadata);
bloc.dataset.metadata = metadataStr;
bloc.setAttribute('data-metadata', metadataStr);
}
export function renderBlocStyle(bloc) {
if (!bloc || !bloc.dataset) return;
// Nettoyage des anciennes classes
bloc.classList.remove('is-title', 'is-title-1', 'is-title-2', 'is-title-3');
bloc.classList.remove('is-quote', 'is-quote-info', 'is-quote-warning', 'is-quote-normal');
bloc.classList.remove('is-code-block', 'is-list', 'is-page-link', 'is-separator');
bloc.style.whiteSpace = "normal"; // reset eventuel
const type = bloc.dataset.type || 'TEXTE';
switch (type) {
case 'TEXTE':
bloc.classList.add('is-text');
break;
case 'TITRE':
const metadata = getBlocDataMetadata(bloc);
const level = metadata.level || '1';
bloc.classList.add('is-title', `is-title-${level}`);
break;
case 'LISTE':
bloc.classList.add('is-list');
break;
case 'CODE':
bloc.classList.add('is-code-block');
bloc.style.whiteSpace = "pre";
bloc.dataset.language || 'plaintext';
break;
case 'PAGE':
bloc.classList.add('is-page-link');
break;
case 'SEPARATEUR':
bloc.classList.add('is-separator');
bloc.contentEditable = "false";
break;
case "CITATION":
const metadataCitation = getBlocDataMetadata(bloc);
const citationType = metadataCitation.type || 'normal';
bloc.classList.add('is-quote', `is-quote-${citationType}`);
break;
default:
bloc.classList.add('is-text');
break;
}
autoResize(bloc);
}
function applyBlocType(bloc, type, extra = {}) {
bloc.dataset.type = type;
let metadata = {};
switch (type) {
case "TEXTE":
metadata = {};
break;
case "TITRE":
metadata = { level: extra.level || 1 };
break;
case "LISTE":
metadata = { style: extra.style || "bullet" };
break;
case "CODE":
metadata = { language: extra.language || "plaintext", theme: "light" };
break;
case "PAGE":
metadata = {
pageId: extra.pageId || "nouvelle-page-id",
title: extra.title || "Nouvelle page"
};
break;
case "SEPARATEUR":
metadata = {};
break;
case "CITATION":
metadata = {
type: extra.type || "normal"
};
break;
default:
console.warn("Type de bloc inconnu :", type);
}
setBlocMetadata(bloc, metadata);
renderBlocStyle(bloc);
}

View File

@ -0,0 +1,114 @@
// dragDropModule.js
// Initialisation de l'élément drag et drop
let draggedBloc = null;
let dropIndicator = null;
// Ajout des événements pour les blocs
export function initializeDragDrop() {
document.querySelectorAll('.bloc-container').forEach(bloc => {
bloc.addEventListener('dragstart', function (e) {
draggedBloc = bloc;
bloc.classList.add('dragging');
// Créer une copie du bloc qui suivra la souris
const clone = bloc.cloneNode(true);
clone.classList.add('dragging-clone');
document.body.appendChild(clone);
draggedBloc.style.visibility = 'hidden'; // Rendre le bloc original invisible
// Mise à jour de la position du clone pendant le déplacement
document.addEventListener('mousemove', handleMouseMove);
});
bloc.addEventListener('dragend', function () {
bloc.classList.remove('dragging');
if (dropIndicator) {
dropIndicator.remove();
dropIndicator = null;
}
if (draggedBloc) {
draggedBloc.style.visibility = ''; // Rendre le bloc original visible à la fin
}
document.removeEventListener('mousemove', handleMouseMove);
});
});
// Gérer l'événement de dragover sur le document
document.addEventListener('dragover', function (e) {
e.preventDefault();
const blocContainers = Array.from(document.querySelectorAll('.bloc-container'));
const afterElement = getDragAfterElement(blocContainers, e.clientY);
if (afterElement) {
afterElement.parentNode.insertBefore(dropIndicator, afterElement);
} else {
const lastBlocContainer = document.querySelector('.bloc-container:last-child');
if (lastBlocContainer) {
lastBlocContainer.after(dropIndicator);
}
}
});
// Gérer l'événement de drop sur le document
document.addEventListener('drop', function (e) {
e.preventDefault();
if (!dropIndicator || !draggedBloc) return;
if (dropIndicator.parentNode) {
dropIndicator.parentNode.insertBefore(draggedBloc, dropIndicator);
}
// Sauvegarder la nouvelle position des blocs
saveBlocOrder();
dropIndicator.remove();
dropIndicator = null;
draggedBloc = null;
});
}
// Mise à jour de la position du clone pendant le déplacement de la souris
function handleMouseMove(e) {
const draggedClone = document.querySelector('.dragging-clone');
if (draggedClone) {
draggedClone.style.position = 'absolute';
draggedClone.style.left = `${e.pageX}px`;
draggedClone.style.top = `${e.pageY}px`;
}
}
// Trouver où insérer l'élément pendant le drag
function getDragAfterElement(containers, mouseY) {
return containers.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = mouseY - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
// Fonction pour sauvegarder l'ordre des blocs dans la base de données
function saveBlocOrder() {
const blocIds = Array.from(document.querySelectorAll('.bloc-container')).map(bloc => bloc.dataset.id);
const params = new URLSearchParams();
params.append('order', blocIds.join(','));
fetch('/Projet/UpdateOrder', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params
}).then(response => {
if (!response.ok) {
console.error('Erreur lors de la sauvegarde de lordre');
}
});
}

View File

@ -0,0 +1,16 @@
export function initDropdowns() {
const dropdowns = document.querySelectorAll('.dropdown');
dropdowns.forEach(dropdown => {
const trigger = dropdown.querySelector('.dropdown-trigger button');
trigger.addEventListener('click', event => {
event.stopPropagation();
dropdown.classList.toggle('is-active');
});
});
document.addEventListener('click', () => {
dropdowns.forEach(dropdown => dropdown.classList.remove('is-active'));
});
}

View File

@ -0,0 +1,26 @@
import { initBlocs, ajouterBlocVideSiBesoin, focusDernierBloc } from './bloc.js';
import { initTchat } from './tchat.js';
import { initPages } from './page.js';
import { initDropdowns } from './dropdown.js';
import { initSocketBloc } from './socket-bloc.js';
import { initializeDragDrop } from './drag-and-drop.js';
window.addEventListener('DOMContentLoaded', () => {
initBlocs();
initDropdowns();
initTchat();
initPages();
initSocketBloc();
ajouterBlocVideSiBesoin();
focusDernierBloc();
document.body.addEventListener('click', function(event) {
// Vérifie si l'élément cliqué est un lien dans un .editor
if (event.target.tagName.toLowerCase() === 'a' && event.target.closest('.editor')) {
event.preventDefault(); // Empêcher l'action par défaut
const url = event.target.href;
console.log(`Redirection vers ${url}`); // Affiche l'URL dans la console
window.location.href = url; // Rediriger manuellement
}
});
});

View File

@ -0,0 +1,38 @@
export function initPages() {
document.querySelectorAll('.delete-page-btn').forEach(button => {
button.addEventListener('click', () => {
const pageId = button.dataset.id;
if (confirm("Voulez-vous vraiment supprimer cette page ?")) {
const params = new URLSearchParams();
params.append("pageId", pageId);
fetch("/Projet/SupprimerPage", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params
}).then(response => {
if (response.ok) {
location.reload();
} else {
console.error("Erreur lors de la suppression de la page.");
}
});
}
});
});
}
export function createNewPage(titre, pageId) {
const params = new URLSearchParams();
params.append("titre", titre);
params.append("pageId", pageId);
return fetch("/Projet/NouvellePageDirect", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: params.toString()
})
.then(response => response.json());
}

View File

@ -0,0 +1,37 @@
export function initSocketBloc() {
const params = new URLSearchParams(window.location.search);
const pageId = parseInt(params.get("id"));
if (!pageId) return;
const socketBloc = new WebSocket("ws://" + window.location.host + `/Projet/ws/bloc?pageId=${pageId}`);
socketBloc.onmessage = event => {
const data = JSON.parse(event.data);
if (data.action === "update") {
const blocElement = document.querySelector(`.editor[data-id='${data.blocId}']`);
if (blocElement) {
blocElement.textContent = data.content;
blocElement.setAttribute('data-metadata', data.metadata);
}
}
};
document.querySelectorAll('.editor').forEach(bloc => {
bloc.addEventListener('input', event => {
const blocId = event.target.getAttribute('data-id');
const content = event.target.textContent;
const type = event.target.getAttribute('data-type');
const metadata = event.target.getAttribute('data-metadata');
const modif = {
action: "update",
blocId,
content,
metadata,
type
};
socketBloc.send(JSON.stringify(modif));
});
});
}

View File

@ -0,0 +1,24 @@
export function initTchat() {
const socketTchat = new WebSocket("ws://" + window.location.host + "/Projet/ws/tchat");
socketTchat.onmessage = event => {
const container = document.querySelector(".messages-container");
if (container) {
const p = document.createElement("p");
p.textContent = event.data;
container.appendChild(p);
container.scrollTop = container.scrollHeight;
}
};
const input = document.querySelector("input[name='contenu']");
if (input) {
input.addEventListener("keydown", event => {
const login = document.getElementById("user-login").textContent;
if (event.key === "Enter" && input.value.trim() !== "") {
socketTchat.send(`${login} : ${input.value}`);
input.value = "";
}
});
}
}

View File

@ -0,0 +1,17 @@
export function autoResize(bloc) {
bloc.style.height = 'auto';
bloc.style.height = bloc.scrollHeight + 'px';
}
export function placeCursorAtEnd(element) {
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(element);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
element.scrollIntoView({ behavior: "smooth", block: "end" });
}

View File

@ -28,62 +28,70 @@
});
});
document.querySelectorAll('.editor').forEach(function(bloc) {
renderBlocStyle(bloc);
});
ajouterBlocVideSiBesoin();
});
// Fonction pour ajouter un nouvel événement à chaque bloc
function addBlocEvent(bloc) {
bloc.addEventListener('keydown', async function(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
bloc.addEventListener('keydown', function(event) {
const currentBloc = event.target;
const texte = currentBloc.innerText.trim();
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
if (texte.startsWith("/")) {
const commandeGeree = await handleSlashCommand(currentBloc, texte);
}
const currentBloc = event.target;
const texte = currentBloc.innerText.trim();
// Sinon, sauvegarde normale
sauvegarderBloc(currentBloc);
ajouterBlocVideSiBesoin();
}
});
if (texte.startsWith("/")) {
// Gérer les commandes
handleSlashCommand(currentBloc, texte);
}
const blocId = currentBloc.getAttribute('data-id'); // Récupère l'ID du bloc
const type = currentBloc.getAttribute('data-type');
const metadata = currentBloc.getAttribute('metadata');
const params = new URLSearchParams();
let contenuTexte = currentBloc.innerHTML
.replace(/<br\s*\/?>/gi, '\n') // Remplace les <br> par \n
.replace(/<b>/gi, '**') // Remplace <b> par **
.replace(/<\/b>/gi, '**') // Remplace </b> par **
.replace(/<i>/gi, '*') // Remplace <i> par *
.replace(/<\/i>/gi, '*'); // Remplace </i> par *
params.append("contenu", contenuTexte);
params.append("blocId", blocId);
params.append("metadata", metadata);
params.append("type", type);
fetch("/Projet/ModifBloc", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: params
}).then(response => {
if (!response.ok) {
console.error("Erreur lors de la mise à jour du bloc.");
}
});
ajouterBlocVideSiBesoin();
}
});
bloc.addEventListener('input', function () {
autoResize(bloc);
});
bloc.addEventListener('input', function () {
autoResize(bloc);
});
}
function sauvegarderBloc(bloc) {
const blocId = bloc.getAttribute('data-id');
const type = bloc.getAttribute('data-type');
const metadata = bloc.getAttribute('data-metadata');
const params = new URLSearchParams();
let contenuTexte = bloc.innerHTML
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<b>/gi, '**')
.replace(/<\/b>/gi, '**')
.replace(/<i>/gi, '*')
.replace(/<\/i>/gi, '*');
params.append("contenu", contenuTexte);
params.append("blocId", blocId);
params.append("metadata", metadata);
params.append("type", type);
fetch("/Projet/ModifBloc", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: params
}).then(response => {
if (!response.ok) {
console.error("Erreur lors de la mise à jour du bloc.");
}
});
}
function autoResize(bloc) {
bloc.style.height = 'auto';
bloc.style.height = bloc.scrollHeight + 'px';
@ -109,6 +117,7 @@
const params = new URLSearchParams();
params.append("contenu", ""); // Bloc vide
params.append("type", "TEXTE"); // Type par défaut : TEXTE
params.append("metadata", "{}");
params.append("ordre", allBlocs.length);
params.append("pageId", new URLSearchParams(window.location.search).get("id"));
@ -135,7 +144,7 @@
newBloc.setAttribute('rows', '1');
newBloc.setAttribute('data-type', 'TEXTE');
newBloc.setAttribute('data-id', idGenere);
newBloc.setAttribute('metadata', '{}');
newBloc.setAttribute('data-metadata', '{}');
control.appendChild(newBloc);
container.appendChild(control);
@ -171,35 +180,35 @@
function addDeleteBloc(button) {
button.addEventListener('click', function () {
const blocId = button.dataset.id;
const blocContainer = button.closest('.bloc-container');
const bloc = blocContainer.querySelector('[contenteditable="true"]');
button.addEventListener('click', function () {
const blocId = button.dataset.id;
const blocContainer = button.closest('.bloc-container');
const bloc = blocContainer.querySelector('[contenteditable="true"], [contenteditable="false"]');
if (bloc.innerText.trim() === "") {
return; // Empêche la suppression si le bloc est vide
}
if (bloc.dataset.type != "SEPARATEUR" && bloc.innerText === "") {
return;
}
if (confirm("Voulez-vous vraiment supprimer ce bloc ?")) {
const params = new URLSearchParams();
params.append("blocId", blocId);
if (confirm("Voulez-vous vraiment supprimer ce bloc ?")) {
const params = new URLSearchParams();
params.append("blocId", blocId);
fetch("/Projet/SupprimerBloc", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: params
}).then(response => {
if (response.ok) {
blocContainer.remove(); // Supprime visuellement le bloc
ajouterBlocVideSiBesoin();
} else {
console.error("Erreur lors de la suppression du bloc.");
}
});
}
});
fetch("/Projet/SupprimerBloc", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: params
}).then(response => {
if (response.ok) {
blocContainer.remove(); // Supprime visuellement le bloc
ajouterBlocVideSiBesoin();
} else {
console.error("Erreur lors de la suppression du bloc.");
}
});
}
});
}
document.querySelectorAll('.delete-bloc-btn').forEach(t => {
@ -239,11 +248,13 @@
const socketTchat = new WebSocket("ws://" + window.location.host + "/Projet/ws/tchat");
socketTchat.onmessage = function(event) {
const container = document.querySelector(".messages-container");
const p = document.createElement("p");
p.textContent = event.data;
container.appendChild(p);
container.scrollTop = container.scrollHeight;
const container = document.querySelector(".messages-container");
if (container) {
const p = document.createElement("p");
p.textContent = event.data;
container.appendChild(p);
container.scrollTop = container.scrollHeight;
}
};
const input = document.querySelector("input[name='contenu']");
@ -274,7 +285,7 @@
const blocElement = document.querySelector(`.editor[data-id='${data.blocId}']`);
if (blocElement) {
blocElement.textContent = data.content;
blocElement.setAttribute('metadata', data.metadata);
blocElement.setAttribute('data-metadata', data.metadata);
}
}
};
@ -284,7 +295,7 @@
const blocId = event.target.getAttribute('data-id');
const content = event.target.textContent; // Récupère le contenu modifié du bloc
const type = event.target.getAttribute('data-type');
const metadata = event.target.getAttribute('metadata'); // Récupère le metadata du bloc
const metadata = event.target.getAttribute('data-metadata'); // Récupère le metadata du bloc
const modif = {
action: "update",
@ -310,16 +321,18 @@
// pour modifier le rendu des blocs
function getBlocMetadata(bloc) {
function getBlocDataMetadata(bloc) {
try {
return JSON.parse(bloc.dataset.metadata || "{}");
return JSON.parse(bloc.getAttribute('data-metadata') || "{}");
} catch (e) {
return {};
return {};
}
}
function setBlocMetadata(bloc, newMetadata) {
bloc.dataset.metadata = JSON.stringify(newMetadata);
function setBlocMetadata(bloc, metadata) {
const metadataStr = JSON.stringify(metadata);
bloc.dataset.metadata = metadataStr;
bloc.setAttribute('data-metadata', metadataStr);
}
function applyBlocType(bloc, type, extra = {}) {
@ -346,6 +359,8 @@
title: extra.title || "Nouvelle page"
};
break;
case "SEPARATEUR":
metadata = {};
default:
console.warn("Type de bloc inconnu :", type);
}
@ -359,8 +374,8 @@
// Nettoyage des anciennes classes
bloc.classList.remove('is-title', 'is-title-1', 'is-title-2', 'is-title-3');
bloc.classList.remove('is-code-block', 'is-list', 'is-toggle');
bloc.style.whiteSpace = "normal"; // reset si code
bloc.classList.remove('is-code-block', 'is-list', 'is-page-link', 'is-separator');
bloc.style.whiteSpace = "normal"; // reset eventuel
const type = bloc.dataset.type || 'TEXTE';
@ -370,9 +385,10 @@
break;
case 'TITRE':
const level = bloc.dataset.level || '1';
bloc.classList.add('is-title', `is-title-${level}`);
break;
const metadata = getBlocDataMetadata(bloc);
const level = metadata.level || '1';
bloc.classList.add('is-title', `is-title-${level}`);
break;
case 'LISTE':
bloc.classList.add('is-list');
@ -385,12 +401,12 @@
break;
case 'PAGE':
bloc.classList.add('is-page-block');
bloc.classList.add('is-page-link');
break;
case 'TOGGLE':
bloc.classList.add('is-toggle');
break;
case 'SEPARATEUR':
bloc.classList.add('is-separator');
bloc.contentEditable = "false";
default:
bloc.classList.add('is-text');
@ -400,50 +416,76 @@
function handleSlashCommand(bloc, texte) {
const parts = texte.substring(1).split(" "); // Supprimer le "/" et diviser
const command = parts[0].toLowerCase();
const param = parts.slice(1).join(" "); // Récupère les paramètres (par exemple "js" pour /code js)
return new Promise((resolve) => {
const parts = texte.trim().substring(1).split(" ");
const command = parts[0].toLowerCase();
const param = parts.slice(1).join(" ");
switch (command) {
case "page":
createNewPage(param || "Nouvelle page");
bloc.textContent = param || "Nouvelle page"; // On vide le bloc après transformation
autoResize(bloc);
break;
switch (command) {
case "page":
createNewPage(param || "Nouvelle page").then(data => {
if (data && data.id) {
const lien = `/Projet/AfficherPage?id=${data.id}`;
bloc.innerHTML = `<a href="${lien}">${param || "Nouvelle page"}</a>`;
applyBlocType(bloc, "PAGE", { pageId: data.id, title: param });
autoResize(bloc);
resolve();
} else {
console.error("Erreur de création de page");
resolve();
}
}).catch(error => {
console.error("Erreur lors de la création de la page :", error);
resolve();
});
break;
case "h1":
case "h2":
case "h3":
applyBlocType(bloc, "TITRE", { level: parseInt(command[1] || "1") });
bloc.textContent = param; // On vide le bloc après transformation
autoResize(bloc);
break;
case "h1":
case "h2":
case "h3":
applyBlocType(bloc, "TITRE", { level: parseInt(command[1] || "1") });
bloc.textContent = param;
autoResize(bloc);
resolve();
break;
case "ul":
applyBlocType(bloc, "LISTE", { style: "bullet" });
bloc.textContent = ''; // On vide le bloc après transformation
autoResize(bloc);
break;
case "ul":
applyBlocType(bloc, "LISTE", { style: "bullet" });
bloc.textContent = '';
autoResize(bloc);
resolve();
break;
case "ol":
applyBlocType(bloc, "LISTE", { style: "numbered" });
bloc.textContent = ''; // On vide le bloc après transformation
autoResize(bloc);
break;
case "ol":
applyBlocType(bloc, "LISTE", { style: "numbered" });
bloc.textContent = '';
autoResize(bloc);
resolve();
break;
case "code":
applyBlocType(bloc, "CODE", { language: param || "plaintext" });
bloc.textContent = ''; // On vide le bloc après transformation
autoResize(bloc);
break;
case "code":
applyBlocType(bloc, "CODE", { language: param || "plaintext" });
bloc.textContent = '';
autoResize(bloc);
resolve();
break;
default:
console.log(`Commande non reconnue: ${command}`);
break;
}
case "hr":
applyBlocType(bloc, "SEPARATEUR", {}); // ou un type adapté selon ta logique
bloc.innerText = ' ';
autoResize(bloc);
resolve(true);
break;
bloc.focus();
placeCursorAtEnd(bloc);
default:
console.log(`Commande non reconnue: ${command}`);
resolve();
break;
}
bloc.focus();
placeCursorAtEnd(bloc);
});
}
function placeCursorAtEnd(element) {
@ -463,25 +505,14 @@
const params = new URLSearchParams();
params.append("titre", titre);
fetch("/Projet/NouvellePage", {
return fetch("/Projet/NouvellePageDirect", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: params.toString()
})
.then(response => response.json())
.then(data => {
if (data && data.pageId) {
// Rediriger vers la nouvelle page par exemple
window.location.href = "/Projet/Page?id=" + data.pageId;
} else {
console.error("Erreur lors de la création de la page.");
}
})
.catch(err => {
console.error("Erreur réseau :", err);
});
.then(response => response.json());
}

View File

@ -7,6 +7,7 @@
border: none;
outline: none;
}
.tchat-container {
display: block;
padding: 1rem;
@ -27,7 +28,7 @@
/* pour les blocs */
.is-title-1 { font-size: 2em; font-weight: bold; }
.is-title-2 { font-size: 1.5em; font-weight: bold; }
.is-title-3 { font-size: 1.2em; font-weight: bold; }
.is-title-3 { font-size: 1em; font-weight: bold; }
.is-code-block {
font-family: monospace;
@ -41,7 +42,39 @@
color: #666;
}
.is-toggle::before {
content: "▶ ";
cursor: pointer;
.is-separator {
border-top: 1px solid #9a9996;
height: 1px;
width: 100%;
background: none;
pointer-events: none;
}
.is-page-link:hover {
background-color: #e1e7f0;
text-decoration: underline;
}
.is-quote {
padding-left: 10px;
border-left: 2px solid #ccc;
margin-left: 10px;
color: #555;
}
.is-quote-normal {
border-left-color: #ccc;
color: #555;
}
.is-quote-danger {
border-left-color: #e74c3c;
background-color: #fdecea;
color: #c0392b;
}
.is-quote-info {
border-left-color: #3498db;
background-color: #eaf6fb;
color: #2980b9;
}