diff --git a/.idea/dataSources.local.xml b/.idea/dataSources.local.xml index cdc31de..b341a8a 100644 --- a/.idea/dataSources.local.xml +++ b/.idea/dataSources.local.xml @@ -1,6 +1,6 @@ - + " diff --git a/README.md b/README.md index 419774f..f5e5e72 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## Login-Informationen für Dummy-User `Anmeldename, Passwort, Mailadresse`: -- `max.mustermann, test12345, mustermann@web.de` +- `max.mustermann, test12345, max.mustermann@web.de` ## Weitere Voraussetzungen zur Nutzung - Per Klick auf das Logo gelangt man auf die Home-Seite. @@ -18,6 +18,9 @@ - Bitte auf die gesetzten TODO's achten. Wenn Inhalte fehlen, sind sie i.d.R. als TODO kommentiert. - Die Suchseite und Kategorieseite packen momentan alle passenden Beiträge untereinander. Später sollen zunächst 10 Ergebnisse auf einer Seite angezeigt werden. +- Wenn ein Bild aus einem Beitrag entfernt wird, dann wird noch nicht die Datei im Pfad /uploads gelöscht. +- id in showArticle-controller.php und updateArticle-controller.php wird nicht als gültige numerische ID geprüft. +- sort in search-results-controller.php wird nicht gegen erlaubte Werte validiert. ## Besonderheiten des Projektes - Es wurde ein einfacher Beitrags-Editor erstellt. Mit diesem können Beiträge erstellt oder bearbeitet werden. diff --git a/content/createArticle.php b/content/createArticle.php index 072dfdb..959583e 100644 --- a/content/createArticle.php +++ b/content/createArticle.php @@ -17,8 +17,28 @@ if (!isset($_SESSION["user"])) { - + +
+ + +
+ + +
+ + + diff --git a/content/home.php b/content/home.php index c148fe8..17814bb 100644 --- a/content/home.php +++ b/content/home.php @@ -19,28 +19,34 @@ include_once 'php/controller/home-controller.php'; sea takimata sanctus est Lorem ipsum dolor sit amet.

- -
- + +
+ + + +

Aktuell sind keine Beiträge in den Kategorien vorhanden.

+
\ No newline at end of file diff --git a/content/profile.php b/content/profile.php index aefdc10..e6f3de3 100644 --- a/content/profile.php +++ b/content/profile.php @@ -7,8 +7,8 @@ $isEditMode = (isset($_GET["edit"]) && $_GET["edit"] === "1") || !empty($error);
-
+

@@ -93,8 +93,6 @@ $isEditMode = (isset($_GET["edit"]) && $_GET["edit"] === "1") || !empty($error);

- -

Meine Beiträge

@@ -157,7 +155,9 @@ $isEditMode = (isset($_GET["edit"]) && $_GET["edit"] === "1") || !empty($error);
- +
@@ -180,10 +179,54 @@ $isEditMode = (isset($_GET["edit"]) && $_GET["edit"] === "1") || !empty($error); - -
+
+
+ +

Meine Kommentare

+ + +
+ + 0): ?> + + +
+ + + + + +

+ getContent())); ?> +

+ + + + Zum Beitrag + + +
+ + + + +

Du hast noch keine Kommentare geschrieben.

+ + + +
+ +
+
+
+
\ No newline at end of file diff --git a/content/showArticle.php b/content/showArticle.php index 4457dcc..d461d16 100644 --- a/content/showArticle.php +++ b/content/showArticle.php @@ -1,37 +1,36 @@ getCommentsByArticle($_GET["id"]); + + foreach ($comments as $comment) { + if ($comment->isReply()) { + $parentId = $comment->getParentCommentId(); + $repliesByParent[$parentId][] = $comment; + } else { + $mainComments[] = $comment; + } + } + } catch (Exception $e) { + $_SESSION["message"] = "internal_error"; + } +} ?> - + Seite: Anzeige für Beiträge + Funktion: Stellt einen übergebenen Beitrag dar. + -->
- -

- Es ist ein interner Fehler aufgetreten. Bitte versuche es erneut. -

- - -

- Es ist ein Fehler aufgetreten. Die ID konnte nicht ausgelesen werden. Bitte versuche es erneut. -

- - - -

- Jeder Beitrag muss einen Titel, Kategorie und Inhalt besitzen. -

- - -

- Dein Beitrag wurde erfolgreich bearbeitet! -

- - +
@@ -39,6 +38,7 @@ include_once 'php/controller/showArticle-controller.php'; +

@@ -54,8 +54,33 @@ include_once 'php/controller/showArticle-controller.php';
- -
+ + +
+ +
+ + +
+ Beitragsbild +
+ + +
+ +
+
@@ -80,5 +105,79 @@ include_once 'php/controller/showArticle-controller.php';
-
+
+

Kommentare

+
+ + +
+

+ getAuthor()); ?> + getCreated()); ?> +

+ +

getContent())); ?>

+ + + + + +
+ getId()])): ?> + getId()] as $reply): ?> +
+

+ getAuthor()); ?> + getCreated()); ?> +

+ +

getContent())); ?>

+
+ + +
+
+ + +

+ Noch keine Kommentare vorhanden. +

+ +
+ + +
+ "> + + + + + + + + +
+ + + +
+ \ No newline at end of file diff --git a/content/updateArticle.php b/content/updateArticle.php index 69a726d..8555072 100644 --- a/content/updateArticle.php +++ b/content/updateArticle.php @@ -10,7 +10,7 @@ include_once 'php/controller/showArticle-controller.php'; Seite: Beitrag erstellen Inhalt: Formular für die Erstellung eines neuen Beitrags --> -
" id="editor-form" class="article-editor-scope.editor-container article-editor-scope editor-container"> +" id="editor-form" enctype="multipart/form-data" class="article-editor-scope.editor-container article-editor-scope editor-container">
@@ -26,8 +26,33 @@ include_once 'php/controller/showArticle-controller.php'; ?>" placeholder="Titel hier eingeben" required> - + +
+ + +
+ + +
+ + + + + +
diff --git a/css/createArticle.css b/css/createArticle.css index ced5d7e..51555c7 100644 --- a/css/createArticle.css +++ b/css/createArticle.css @@ -90,6 +90,135 @@ padding: 18px 12px; } +/* Container für die dynamisch per JS eingefügten Blöcke */ +.article-editor-scope #block-container { + width: 100%; + display: flex; + flex-direction: column; + gap: 20px; +} + +/* Styling für jeden einzelnen dynamisch generierten Block */ +.article-editor-scope .editor-block { + position: relative; /* Wichtig für die absolute Positionierung des Lösch-Buttons */ + width: 100%; + padding: 15px; + background-color: #fafafa; + border: 1px dashed #cccccc; + border-radius: 6px; +} + +/* Textarea innerhalb eines dynamischen Textblocks */ +.article-editor-scope .editor-block textarea { + width: 100%; + min-height: 120px; + padding: 10px; + border: 1px solid #dddddd; + border-radius: 4px; + font-family: inherit; + font-size: 1.1rem; + line-height: 1.6; + resize: vertical; + outline: none; + background: #ffffff; +} + +/* Komfort-Löschbutton oben rechts an jedem Block */ +.article-editor-scope .delete-block-btn { + position: absolute; + top: -10px; + right: -10px; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #e74c3c; + color: #ffffff; + border: none; + font-size: 12px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + transition: background-color 0.2s ease; +} + +.article-editor-scope .delete-block-btn:hover { + background-color: #c0392b; +} + +/* Steuerungselement für den Plus-Button und das Pop-up */ +.article-editor-scope .add-block-control { + position: relative; /* Dient als Anker für das absolut positionierte Pop-up */ + margin-top: 10px; + display: inline-block; + align-self: flex-start; /* Verhindert, dass der Button die volle Breite spannt */ +} + +/* Der runde Plus-Button */ +.article-editor-scope .plus-button { + width: 45px; + height: 45px; + border-radius: 50%; + background-color: #3498db; + color: #ffffff; + border: none; + font-size: 26px; + font-weight: 300; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + transition: background-color 0.2s ease, transform 0.2s ease; +} + +.article-editor-scope .plus-button:hover { + background-color: #2980b9; + transform: scale(1.05); +} + +/* Das Pop-up Menü */ +.article-editor-scope .block-popup { + position: absolute; + left: 60px; /* Platziert das Menü rechts neben dem Plus-Button */ + top: 50%; + transform: translateY(-50%); /* Zentriert das Menü vertikal zum Button */ + background-color: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 8px; + display: flex; + gap: 8px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); + z-index: 999; + white-space: nowrap; +} + +/* Die entscheidende Klasse zum Ausblenden */ +.article-editor-scope .block-popup.hidden { + display: none !important; +} + +/* Buttons im Pop-up (Textblock / Bild einfügen) */ +.article-editor-scope .block-popup button { + background-color: #f8f9fa; + border: 1px solid #dcdde1; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + color: #2f3640; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.article-editor-scope .block-popup button:hover { + background-color: #f1f2f6; + border-color: #b2bec3; +} + /* Responsive Anpassungen unter 760px (für z.B. Smartphones) */ @media (max-width: 760px) { .article-editor-scope.editor-container { @@ -113,4 +242,8 @@ border-left: none; border-top: 1px solid #e0e0e0; } + + .article-editor-scope .editor-block textarea { + min-height: 150px; + } } \ No newline at end of file diff --git a/css/showArticle.css b/css/showArticle.css index ca3b40a..46ffbad 100644 --- a/css/showArticle.css +++ b/css/showArticle.css @@ -105,6 +105,37 @@ cursor: default; } +.article-view-content { + margin-bottom: 3rem; + display: flex; + flex-direction: column; + gap: 1.5rem; /* Erzeugt einen sauberen Abstand zwischen den einzelnen Blöcken */ + width: 100%; +} + +.article-view-body { + font-size: 1.125rem; + color: #2d3748; + width: 100%; +} + +.article-view-body.block-text { + white-space: pre-line; + word-break: break-word; +} + +.article-view-body.block-image { + display: flex; + justify-content: center; /* Zentriert das Bild horizontal */ +} + +.article-view-body.block-image img { + max-width: 100%; /* Verhindert das Ausbrechen aus der Lesebreite */ + height: auto; /* Behält das originale Seitenverhältnis bei */ + border-radius: 6px; /* Optionale leichte Rundung für ein moderneres Layout */ + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03); /* Minimaler, eleganter Schatten */ +} + /* Responsive Anpassungen unter 760px (für z.B. Smarticlephones) */ @media (max-width: 760px) { .article-view-container { @@ -116,3 +147,96 @@ font-size: 1.85rem; } } + +/* --- KOMMENTARE --- */ + +.article-comments-section { + margin-top: 3rem; + padding-top: 2rem; + border-top: 1px solid #e2e8f0; +} + +.article-comments-section h2 { + font-size: 2rem; + margin-bottom: 1.5rem; +} + +#comments-list { + margin-bottom: 2rem; +} + +.comment-item { + background-color: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} + +#comment-form { + width: 100%; + display: flex; + flex-direction: column; + gap: 1rem; +} + +#comment-content { + width: 100%; + min-height: 130px; + padding: 1rem; + border: 1px solid #cbd5e1; + border-radius: 8px; + font-size: 1rem; + font-family: inherit; + resize: vertical; +} + +#comment-form .button { + width: 100%; +} + +.reply-button { + display: inline-block; + margin-top: 0.75rem; + background: none; + border: none; + color: #2563eb; + font-weight: 700; + cursor: pointer; + padding: 0; + font-size: 0.95rem; +} + +.reply-button:hover { + text-decoration: underline; +} + +.comment-replies { + margin-top: 1rem; + margin-left: 2rem; + padding-left: 1rem; + border-left: 3px solid #cbd5e1; +} + +.comment-reply { + background-color: #eef6ff; + margin-top: 1rem; +} + +.reply-info { + margin: 0.5rem 0; + color: #475569; + font-weight: 600; +} +.comment-login-hint { + margin-top: 2rem; + padding: 1.5rem; + background-color: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; + text-align: center; +} + +.comment-login-hint p { + margin-bottom: 1rem; +} \ No newline at end of file diff --git a/includes/alertMessages.php b/includes/alertMessages.php index f88f3ae..e6112da 100644 --- a/includes/alertMessages.php +++ b/includes/alertMessages.php @@ -15,7 +15,7 @@

- Der Text erlaubt eine Länge von 10 bis maximal 7.000 Zeichen (ca. 1.000 Wörter). + Ein Beitrag muss Inhalt besitzen. Text- und Bildelemente dürfen nicht leer sein!

@@ -63,6 +63,21 @@ Dein Beitrag wurde erfolgreich veröffentlicht!

+ +

+ Der Beitrag wurde erfolgreich bearbeitet und gespeichert. +

+ + +

+ Das Profil wurde erfolgreich bearbeitet. +

+ + +

+ Das Bild konnte nicht hochgeladen werden. Bitte versuche es erneut oder verwende ein anderes Bildformat. +

+ diff --git a/index.php b/index.php index b27a1d7..0c2be41 100644 --- a/index.php +++ b/index.php @@ -54,6 +54,8 @@ if ($pfad === "deleteAccount") { + + EduForge diff --git a/js/comments.js b/js/comments.js new file mode 100644 index 0000000..2d3258d --- /dev/null +++ b/js/comments.js @@ -0,0 +1,137 @@ +/** + * Initialisiert die Kommentarfunktion. + * + * Kommentare werden per AJAX gespeichert, + * ohne dass die Seite neu geladen werden muss. + */ +document.addEventListener("DOMContentLoaded", function () { + const form = document.getElementById("comment-form"); + const commentsList = document.getElementById("comments-list"); + const commentContent = document.getElementById("comment-content"); + const parentCommentInput = document.getElementById("parent-comment-id"); + const replyInfo = document.getElementById("reply-info"); + + if (!form || !commentsList || !commentContent || !parentCommentInput || !replyInfo) { + return; + } + + /** + * Aktiviert einen einzelnen Antworten-Button. + * + * @param {HTMLButtonElement} button Antworten-Button + */ + function registerReplyButton(button) { + button.addEventListener("click", function () { + parentCommentInput.value = button.dataset.commentId; + replyInfo.textContent = "Antwort auf " + button.dataset.author; + replyInfo.style.display = "block"; + commentContent.focus(); + }); + } + + /** + * Registriert alle bereits vorhandenen Antwort-Buttons. + */ + document.querySelectorAll(".reply-button").forEach(function (button) { + registerReplyButton(button); + }); + + /** + * Sendet Kommentare per AJAX an den Server. + */ + form.addEventListener("submit", function (event) { + event.preventDefault(); + + const formData = new FormData(form); + const parentCommentId = parentCommentInput.value; + + fetch("php/ajax/add-comment.php", { + method: "POST", + body: formData + }) + .then(response => response.json()) + .then(data => { + if (!data.success) { + alert(data.message); + return; + } + + const emptyMessage = commentsList.querySelector(".no-comments-message"); + + if (emptyMessage) { + emptyMessage.remove(); + } + + const commentElement = document.createElement("div"); + commentElement.classList.add("comment-item"); + + if (parentCommentId) { + commentElement.classList.add("comment-reply"); + + commentElement.innerHTML = ` +

+ ${escapeHtml(data.author)} + ${escapeHtml(data.created)} +

+

${escapeHtml(data.content).replace(/\n/g, "
")}

+ `; + + const parentReplies = document.querySelector( + `.comment-item[data-comment-id="${parentCommentId}"] .comment-replies` + ); + + if (parentReplies) { + parentReplies.appendChild(commentElement); + } + + } else { + commentElement.dataset.commentId = data.commentId; + + commentElement.innerHTML = ` +

+ ${escapeHtml(data.author)} + ${escapeHtml(data.created)} +

+

${escapeHtml(data.content).replace(/\n/g, "
")}

+ + + +
+ `; + + commentsList.prepend(commentElement); + + const newReplyButton = commentElement.querySelector(".reply-button"); + + if (newReplyButton) { + registerReplyButton(newReplyButton); + } + } + + commentContent.value = ""; + parentCommentInput.value = ""; + replyInfo.textContent = ""; + replyInfo.style.display = "none"; + }) + .catch(() => { + alert("Kommentar konnte nicht gesendet werden."); + }); + }); + + /** + * Entfernt HTML-Sonderzeichen aus Nutzereingaben. + * + * @param {string} text Zu bereinigender Text + * @returns {string} Sicherer Text + */ + function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } +}); \ No newline at end of file diff --git a/js/editor.js b/js/editor.js new file mode 100644 index 0000000..e666f40 --- /dev/null +++ b/js/editor.js @@ -0,0 +1,179 @@ +console.log("Die JavaScript-Datei wurde erfolgreich geladen!"); + +function initEditor() { + const form = document.getElementById("editor-form"); + + if (!form) { + console.error("Skript abgebrochen: Formular nicht gefunden!"); + return; + } else { + console.log("Formular gefunden und Editor initialisiert:", form); + } + + const container = document.getElementById("block-container"); + const plusButton = document.getElementById("plus-button"); + const popup = document.getElementById("block-popup"); + const hiddenContentInput = document.getElementById("content"); + + const initialImages = []; + + // Pop-up umschalten bei Klick auf das Plus + plusButton.addEventListener("click", () => { + popup.classList.toggle("hidden"); + }); + + // Klick auf eine Block-Option im Pop-up + popup.querySelectorAll("button").forEach(btn => { + btn.addEventListener("click", function() { + const type = this.getAttribute("data-type"); + addBlockElement(type, ""); + popup.classList.add("hidden"); + }); + }); + + // Erstellt ein visuelles HTML-Element im Editor + function addBlockElement(type, value = "") { + const blockDiv = document.createElement("div"); + blockDiv.classList.add("editor-block"); + blockDiv.setAttribute("data-type", type); + + // Wenn es ein existierendes Server-Bild beim Laden ist, Pfad im globalen Array sichern + if (type === "image" && value && typeof value === 'string' && value.startsWith('uploads/')) { + initialImages.push(value); + blockDiv.setAttribute("data-value", value); + } + + // Löschen-Button + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.innerHTML = "✕"; + deleteBtn.classList.add("delete-block-btn"); + deleteBtn.addEventListener("click", () => { + // ANPASSUNG 2B: Logik hier komplett geleert. Das '✕' entfernt den Block jetzt nur noch sicher aus dem HTML. + blockDiv.remove(); + }); + blockDiv.appendChild(deleteBtn); + + if (type === "text") { + const textarea = document.createElement("textarea"); + textarea.placeholder = "Schreibe deinen Textblock..."; + textarea.value = value; + blockDiv.appendChild(textarea); + } else if (type === "image") { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = "image/*"; + + const imgPreview = document.createElement("img"); + imgPreview.style.maxWidth = "200px"; + imgPreview.style.display = "block"; + imgPreview.style.marginTop = "10px"; + + if (value && typeof value === 'string') { + if (value.startsWith('uploads/') || value.startsWith('data:image/')) { + imgPreview.src = value; + blockDiv.setAttribute("data-value", value); + } + } + + fileInput.addEventListener("change", function() { + if (this.files && this.files[0]) { + const reader = new FileReader(); + reader.onload = function(e) { + imgPreview.src = e.target.result; + blockDiv.setAttribute("data-value", e.target.result); + } + reader.readAsDataURL(this.files[0]); + } + }); + + blockDiv.appendChild(fileInput); + blockDiv.appendChild(imgPreview); + } + + container.appendChild(blockDiv); + } + + // beim Abschicken verbleibende Blöcke auslesen UND gelöschte Bilder ermitteln + form.addEventListener("submit", function(e) { + const blocks = []; + const currentImages = []; + + // alle aktuell im Formular verbliebenen Blöcke scannen + container.querySelectorAll(".editor-block").forEach(blockDiv => { + const type = blockDiv.getAttribute("data-type"); + let value = ""; + + if (type === "text") { + value = blockDiv.querySelector("textarea").value; + } else if (type === "image") { + + const imgTag = blockDiv.querySelector("img"); + if (imgTag) { + const srcValue = imgTag.getAttribute("src") || ""; + // Wenn es ein neues Bild ist, nutzen wir das data-value (Base64) + if (srcValue.startsWith('data:image/')) { + value = blockDiv.getAttribute("data-value") || ""; + } else { + value = srcValue; + } + } + + // Pfade sammeln, die der Nutzer NICHT gelöscht hat (für den Abgleich) + if (value && value.startsWith('uploads/')) { + currentImages.push(value); + } + } + + blocks.push({ type: type, value: value }); + }); + + // das reguläre unsichtbare Content-Feld befüllen + hiddenContentInput.value = JSON.stringify(blocks); + + // Differenz berechnen: Welche Bilder aus 'initialImages' fehlen in 'currentImages' ? + const deletedImages = initialImages.filter(img => !currentImages.includes(img)); + + // das 'deleted_images'-Feld dynamisch befüllen und an den Controller senden + let deletedInput = document.getElementById("deleted-images"); + if (!deletedInput) { + deletedInput = document.createElement("input"); + deletedInput.type = "hidden"; + deletedInput.id = "deleted-images"; + deletedInput.name = "deleted_images"; + form.appendChild(deletedInput); + } + deletedInput.value = JSON.stringify(deletedImages); + }); + + // Existierende Blöcke laden (stellt alte Daten aus der Session wieder her) + try { + const initialBlocks = JSON.parse(hiddenContentInput.value.trim()); + if (Array.isArray(initialBlocks)) { + initialBlocks.forEach(b => { + if (b.type === "image" && b.value && typeof b.value === 'string' && !b.value.startsWith('data:image/')) { + let cleanPath = b.value.trim().replace(/\\\//g, '/'); // Verwandelt \/ in / + + initialImages.push(cleanPath); + addBlockElement(b.type, cleanPath); + + } else { + addBlockElement(b.type, b.value); + } + }); + console.log("Erfolgreich registrierte Start-Bilder:", initialImages); + } + } catch(e) { + if (hiddenContentInput.value.trim() !== "") { + addBlockElement("text", hiddenContentInput.value); + } + } +} + +// SICHERER START: Prüft, ob das HTML bereits bereit ist +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initEditor); +} else { + // Falls das DOM schon fertig geladen ist, führen wir es direkt aus + initEditor(); +} diff --git a/php/ajax/add-comment.php b/php/ajax/add-comment.php new file mode 100644 index 0000000..b2c14b9 --- /dev/null +++ b/php/ajax/add-comment.php @@ -0,0 +1,58 @@ + false, + "message" => "Du musst angemeldet sein, um zu kommentieren." + ]); + exit(); +} + +$articleId = $_POST["article_id"] ?? null; +$content = trim($_POST["content"] ?? ""); +$parentCommentId = $_POST["parent_comment_id"] ?? null; + +if ($parentCommentId === "" || $parentCommentId === "0") { + $parentCommentId = null; +} + +if (empty($articleId) || empty($content)) { + echo json_encode([ + "success" => false, + "message" => "Kommentar darf nicht leer sein." + ]); + exit(); +} + +try { + $commentManager = CommentManager::getInstance(); + + $commentId = $commentManager->addComment( + $articleId, + $_SESSION["user_email"], + $content, + $parentCommentId + ); + + echo json_encode([ + "success" => true, + "commentId" => $commentId, + "author" => $_SESSION["user_email"], + "content" => $content, + "created" => date("Y-m-d H:i:s"), + "parentCommentId" => $parentCommentId + ]); + +} catch (Exception $e) { + echo json_encode([ + "success" => false, + "message" => "Kommentar konnte nicht gespeichert werden." + ]); +} \ No newline at end of file diff --git a/php/controller/createArticle-controller.php b/php/controller/createArticle-controller.php index 53c7f3a..a48a98a 100644 --- a/php/controller/createArticle-controller.php +++ b/php/controller/createArticle-controller.php @@ -6,6 +6,10 @@ require_once '../model/LocalArticleManager.php'; require_once '../model/ArticleManager.php'; require_once '../validator/article-validator.php'; +if (!isset($_SESSION["user"])) { + header("Location: index.php?pfad=login"); + exit(); +} if ($_SERVER["REQUEST_METHOD"] === "POST") { $_SESSION["old_title"] = $_POST["title"] ?? ''; $_SESSION["old_content"] = $_POST["content"] ?? ''; @@ -24,12 +28,6 @@ require_once '../validator/article-validator.php'; $tags = $_POST['tags'] ?? ''; // -------------------------------- Validierung der Daten: ------------------------- - if (!articleAuthorValidator($author)) { - $_SESSION["message"] = "author_not_valid"; - header("location: ../../index.php?pfad=createArticle"); - exit(); - } - if (!articleTitleValidator($title)) { $_SESSION["message"] = "invalid_title"; header("location: ../../index.php?pfad=createArticle"); @@ -64,6 +62,59 @@ require_once '../validator/article-validator.php'; $cleanedTags = array_unique($cleanedTags); $cleanedTags = implode(',', $cleanedTags); } + + // ----------------- Base64-Bilder verarbeiten und auf Server speichern ----------------- + $blocks = json_decode($content, true); + $uploadDir = __DIR__ . '/../../uploads/'; + + if (!file_exists($uploadDir)) { + mkdir($uploadDir, 0755, true); + } + + if (is_array($blocks)) { + foreach ($blocks as &$block) { + // sicherstellen, dass 'type' und 'value' existieren: + if (isset($block['type']) && isset($block['value']) && $block['type'] === 'image' && str_starts_with($block['value'], 'data:image/')) { + + // Base64-String zerlegen + $parts = explode(',', $block['value']); + + // falls der String korrupt ist und kein Komma hat + if (count($parts) < 2) { + continue; + } + + $metadata = $parts[0]; + $base64Data = $parts[1]; + + // Dateiendung ermitteln + preg_match('/data:image\/(?.*?);/', $metadata, $matches); + $extension = $matches['extension'] ?? 'jpg'; + if ($extension === 'jpeg') { + $extension = 'jpg'; + } + + // Eindeutigen Dateinamen generieren + $fileName = 'img_' . uniqid() . '.' . $extension; + $filePath = $uploadDir . $fileName; + + // Datei im /uploads speichern: + if (file_put_contents($filePath, base64_decode($base64Data)) !== false) { + // temporären Base64-String durch den echten Pfad ersetzen + $block['value'] = 'uploads/' . $fileName; + } else { + $_SESSION["message"] = "image_upload_error"; + header("location: ../../index.php?pfad=createArticle"); + exit(); + } + } + } + unset($block); + } + + // Aktualisiertes Array wieder in JSON konvertieren + $finalContent = json_encode($blocks, JSON_UNESCAPED_UNICODE); + // ----------------- Übertragung der validierten Daten in ArticleManager: --------------------------- try { $articleManager = ArticleManager::getInstance(); @@ -72,7 +123,7 @@ require_once '../validator/article-validator.php'; // Formulardaten nach erfolgreichem Erstellen aus der Session löschen unset($_SESSION["old_title"], $_SESSION["old_content"], $_SESSION["old_category"], $_SESSION["old_tags"]); - } catch (Exception $e){ + } catch (\Throwable $e){ $_SESSION["message"] = "internal_error"; header("location: ../../index.php?pfad=createArticle"); exit(); @@ -84,7 +135,4 @@ require_once '../validator/article-validator.php'; exit(); } } - - - ?> \ No newline at end of file diff --git a/php/controller/deleteAccount-controller.php b/php/controller/deleteAccount-controller.php index cdf35a4..351364d 100644 --- a/php/controller/deleteAccount-controller.php +++ b/php/controller/deleteAccount-controller.php @@ -6,6 +6,11 @@ if (session_status() === PHP_SESSION_NONE) { require_once __DIR__ . "/../model/UserManager.php"; require_once __DIR__ . "/../model/ArticleManager.php"; +if (!isset($_SESSION["user"])) { + header("Location: index.php?pfad=login"); + exit(); +} + /* Deregistrierung Funktion: Entfernt User aus der Datenbank und beendet die Session diff --git a/php/controller/deleteArticle-controller.php b/php/controller/deleteArticle-controller.php index a8cda7f..ffc664d 100644 --- a/php/controller/deleteArticle-controller.php +++ b/php/controller/deleteArticle-controller.php @@ -5,6 +5,11 @@ if (session_status() === PHP_SESSION_NONE) { require_once __DIR__ . "/../model/ArticleManager.php"; +if (!isset($_SESSION["user"])) { + header("Location: index.php?pfad=login"); + exit(); +} + if ($_SERVER["REQUEST_METHOD"] === "POST") { if (isset($_SESSION["user_email"])) { diff --git a/php/controller/home-controller.php b/php/controller/home-controller.php index d9f6006..5143907 100644 --- a/php/controller/home-controller.php +++ b/php/controller/home-controller.php @@ -6,14 +6,44 @@ require_once 'php/model/Article.php'; require_once 'php/model/ArticleManager.php'; require_once 'php/model/LocalArticleManager.php'; +$categoriesWithArticles = []; + +$allowedCategories = [ + 'deutsch', 'englisch', 'franzoesisch', 'latein', 'literatur', + 'mathe', 'biologie', 'chemie', 'physik', 'informatik', 'astronomie', + 'geschichte', 'erdkunde', 'sozialkunde', 'wirtschaft', 'religion', + 'ethik', 'philosophie', 'psychologie', 'kunst', 'musik', 'theater', + 'technik', 'werken', 'hauswirtschaft', 'sport' +]; + try { $articleManager = ArticleManager::getInstance(); - // Beziehen der Dummy-Beiträge aus dem ArticleManager: - $dummy1 = $articleManager->getArticle(1); - $dummy2 = $articleManager->getArticle(2); - $dummy3 = $articleManager->getArticle(3); + + foreach ($allowedCategories as $categorySlug) { + $allCategoryArticles = $articleManager->getArticlesByCategory($categorySlug); + + // nur Kategorien, die Beiträge enthält: + if (!empty($allCategoryArticles) && is_array($allCategoryArticles)) { + + // Beiträge nach Erstellungsdatum sortieren: + usort($allCategoryArticles, function($a, $b) { + $dateA = strtotime($a->getCreationDate()); + $dateB = strtotime($b->getCreationDate()); + return $dateB <=> $dateA; // Absteigende Sortierung + }); + + // auf die 5 zuletzt hinzugefügten Beiträge begrenzen: + $limitedArticles = array_slice($allCategoryArticles, 0, 5); + + $categoriesWithArticles[] = [ + 'slug' => $categorySlug, + 'name' => ucfirst($categorySlug), // Erster Buchstabe groß für die Ansicht + 'articles' => $limitedArticles + ]; + } + } + } catch (Exception $e){ $_SESSION["message"] = "internal_error"; - echo "Fehler aufgetreten: " . $e->getMessage(); } ?> \ No newline at end of file diff --git a/php/controller/profile-controller.php b/php/controller/profile-controller.php index e183b20..3d5cc42 100644 --- a/php/controller/profile-controller.php +++ b/php/controller/profile-controller.php @@ -3,6 +3,7 @@ require_once "php/model/UserManager.php"; require_once "php/model/Article.php"; require_once "php/model/ArticleManager.php"; +require_once "php/model/CommentManager.php"; require_once "php/validator/user-validator.php"; $error = null; @@ -62,6 +63,7 @@ try { $_SESSION["user"] = $vorname . " " . $nachname; $_SESSION["user_email"] = $newEmail; + $_SESSION["message"] = "profile_updated"; header("Location: index.php?pfad=profile"); exit(); } else { @@ -76,6 +78,9 @@ try { $articleManager = ArticleManager::getInstance(); $userArticles = $articleManager->getArticlesByAuthor($_SESSION["user_email"]); + $commentManager = CommentManager::getInstance(); + $userComments = $commentManager->getCommentsByAuthor($_SESSION["user_email"]); + if (!isset($userArticles)) { $_SESSION["message"] = "user_has_no_articles"; } diff --git a/php/controller/updateArticle-controller.php b/php/controller/updateArticle-controller.php index cc881fd..44905db 100644 --- a/php/controller/updateArticle-controller.php +++ b/php/controller/updateArticle-controller.php @@ -8,6 +8,11 @@ require_once '../model/ArticleManager.php'; require_once '../model/Article.php'; require_once '../validator/article-validator.php'; +if (!isset($_SESSION["user"])) { + header("Location: index.php?pfad=login"); + exit(); +} + if ($_SERVER["REQUEST_METHOD"] === "POST") { $_SESSION["old_title"] = $_POST["title"] ?? ''; $_SESSION["old_content"] = $_POST["content"] ?? ''; @@ -22,6 +27,20 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { exit(); } + try { + $articleManager = ArticleManager::getInstance(); + $article = $articleManager->getArticle($id); + if ($article->getAuthor() != $_SESSION["user"]->getUsername()) { + $_SESSION["message"] = "unauthorized_access"; + header("location: ../../index.php"); + exit(); + } + } catch (Exception $e) { + $_SESSION["message"] = $e->getMessage(); + header("location: ../../index.php?pfad=updateArticle&id=$id"); + exit(); + } + if (!isset($_POST["title"]) ||!isset($_POST["content"]) || !isset($_POST["category"])){ $_SESSION["message"] = "missing_parameters"; header("location: ../../index.php?pfad=updateArticle&id=$id"); @@ -34,12 +53,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { $tags = $_POST['tags'] ?? ''; // -------------------------------- Validierung der Daten: ------------------------- - if (!articleAuthorValidator($author)) { - $_SESSION["message"] = "author_not_valid"; - header("location: ../../index.php?pfad=updateArticle&id=$id"); - exit(); - } - if (!articleTitleValidator($title)) { $_SESSION["message"] = "invalid_title"; header("location: ../../index.php?pfad=updateArticle&id=$id"); @@ -75,16 +88,91 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { $cleanedTags = implode(',', $cleanedTags); } + // --------------------------------------- Base64-Bilder speichern --------------------------------------------- + $blocks = json_decode($content, true); + $uploadDir = __DIR__ . '/../../uploads/'; + + if (!file_exists($uploadDir)) { + mkdir($uploadDir, 0755, true); + } + + // ----------------- Gelöschte Bilder über die JS-Löschliste entfernen ----------------- TODO: Gelöschte Bilder über die JS-Löschliste entfernen + /*if (isset($_POST['deleted_images'])) { + $deletedImages = json_decode($_POST['deleted_images'], true); + + // Wir ermitteln den physisch echten, absoluten Pfad zum uploads-Ordner auf der Festplatte + $uploadDir = realpath(__DIR__ . '/../../uploads') . DIRECTORY_SEPARATOR; + + if (is_array($deletedImages)) { + foreach ($deletedImages as $imagePath) { + // Nur den reinen Dateinamen heraustrennen (z.B. img_65a123.jpg) + $filename = basename($imagePath); + $fullDeletePath = $uploadDir . $filename; + + // Debugging & Löschen: + if (file_exists($fullDeletePath)) { + // Versuchen zu löschen. Wenn es fehlschlägt, Fehlermeldung erzwingen + if (!@unlink($fullDeletePath)) { + $error = error_get_last(); + die("Datei existiert, aber PHP darf sie nicht löschen! Grund: " . $error['message']); + } + } else { + // Wenn PHP die Datei an diesem Pfad nicht findet, brechen wir zum Debuggen ab + // die("PHP findet die Datei nicht unter dem Pfad: " . $fullDeletePath); + } + } + } + }*/ + + // ----------------------- NEU hinzugefügte Base64-Bilder: -------------------------- + if (is_array($blocks)) { + foreach ($blocks as &$block) { + // Prüfen, ob der Block ein Bild ist und ein NEUES Bild (Base64-Format) enthält + if (isset($block['type']) && isset($block['value']) && $block['type'] === 'image' && is_string($block['value'])) { + + if (str_starts_with($block['value'], 'data:image/')) { + $parts = explode(',', $block['value']); + if (count($parts) >= 2) { + $metadata = $parts[0]; + $base64Data = $parts[1]; + + preg_match('/data:image\/(?.*?);/', $metadata, $matches); + $extension = $matches['extension'] ?? 'jpg'; + if ($extension === 'jpeg') { $extension = 'jpg'; } + + $fileName = 'img_' . uniqid() . '.' . $extension; + $filePath = $uploadDir . $fileName; + + if (file_put_contents($filePath, base64_decode($base64Data)) !== false) { + $block['value'] = 'uploads/' . $fileName; + } else { + $_SESSION["message"] = "image_upload_error"; + header("location: ../../index.php?pfad=updateArticle&id=$id"); + exit(); + } + } + } + } + } + unset($block); + } + + // Aktualisiertes Array wieder in JSON konvertieren + $finalContent = json_encode($blocks, JSON_UNESCAPED_UNICODE); + // ----------------- Übertragung der validierten Daten in ArticleManager: --------------------------- try { $articleManager = ArticleManager::getInstance(); $article = $articleManager->getArticle($id); $article->setTitle($title); - $article->setContent($content); + $article->setContent($finalContent); $article->setCategory($category); $article->setTags($cleanedTags); $articleManager->updateArticle($id ,$article, $author); - } catch (Exception $e){ + + unset($_SESSION["old_title"], $_SESSION["old_content"], $_SESSION["old_category"], $_SESSION["old_tags"]); + + } catch (\Throwable $e){ $_SESSION["message"] = $e->getMessage(); header("location: ../../index.php?pfad=updateArticle&id=$id"); exit(); diff --git a/php/model/ArticleManager.php b/php/model/ArticleManager.php index 435ac90..52d8bc3 100644 --- a/php/model/ArticleManager.php +++ b/php/model/ArticleManager.php @@ -145,9 +145,17 @@ class ArticleManager // Verteilt die 10 Autoren gleichmäßig (ID 1 -> Autor 1, ID 10 -> Autor 10, ID 11 -> Autor 1) $authorEmail = $authors[($id - 1) % 10]; + $blockStructure = [ + [ + 'type' => 'text', + 'value' => $data[1] // Der originale Text aus dem Dummy-Array + ] + ]; + $jsonContent = json_encode($blockStructure, JSON_UNESCAPED_UNICODE); + $articleManager->addArticle( $data[0], // Titel - $data[1], // Inhalt + $jsonContent, // Inhalt $authorEmail, // Rotierende Autoren-E-Mail $data[2], // Kategorie $data[3] // Tags diff --git a/php/model/Comment.php b/php/model/Comment.php new file mode 100644 index 0000000..b92543b --- /dev/null +++ b/php/model/Comment.php @@ -0,0 +1,115 @@ +id = $id; + $this->articleId = $articleId; + $this->parentCommentId = $parentCommentId; + $this->author = $author; + $this->content = $content; + $this->created = $created; + } + + /** + * Gibt die ID des Kommentars zurück. + * + * @return int Kommentar-ID + */ + public function getId(): int + { + return $this->id; + } + + /** + * Gibt die ID des zugehörigen Beitrags zurück. + * + * @return int Beitrags-ID + */ + public function getArticleId(): int + { + return $this->articleId; + } + + /** + * Gibt die ID des Eltern-Kommentars zurück. + * + * @return int|null ID des Eltern-Kommentars oder null + */ + public function getParentCommentId(): ?int + { + return $this->parentCommentId; + } + + /** + * Gibt zurück, ob der Kommentar eine Antwort ist. + * + * @return bool true wenn der Kommentar eine Antwort ist, sonst false + */ + public function isReply(): bool + { + return $this->parentCommentId !== null && $this->parentCommentId !== 0; + } + + /** + * Gibt den Autor des Kommentars zurück. + * + * @return string Autor + */ + public function getAuthor(): string + { + return $this->author; + } + + /** + * Gibt den Inhalt des Kommentars zurück. + * + * @return string Kommentarinhalt + */ + public function getContent(): string + { + return $this->content; + } + + /** + * Gibt das Erstellungsdatum des Kommentars zurück. + * + * @return string Erstellungsdatum + */ + public function getCreated(): string + { + return $this->created; + } +} \ No newline at end of file diff --git a/php/model/CommentManager.php b/php/model/CommentManager.php new file mode 100644 index 0000000..6ca6cb4 --- /dev/null +++ b/php/model/CommentManager.php @@ -0,0 +1,25 @@ +getConnection(); + + $db->exec(" + CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id INTEGER NOT NULL, + parent_comment_id INTEGER NULL, + author TEXT NOT NULL, + content TEXT NOT NULL, + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + "); + + $columns = $db->query("PRAGMA table_info(comments);")->fetchAll(PDO::FETCH_ASSOC); + $hasParentColumn = false; + + foreach ($columns as $column) { + if ($column["name"] === "parent_comment_id") { + $hasParentColumn = true; + break; + } + } + + if (!$hasParentColumn) { + $db->exec("ALTER TABLE comments ADD COLUMN parent_comment_id INTEGER NULL;"); + } + + } catch (PDOException $e) { + throw new RuntimeException("internal_error"); + } + } + + /** + * Baut die Verbindung zur SQLite-Datenbank auf. + * + * @return PDO Datenbankverbindung + */ + private function getConnection() + { + try { + $dsn = 'sqlite:' . __DIR__ . '/../../db/comments.db'; + + $db = new PDO($dsn, null, null); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + return $db; + + } catch (PDOException $e) { + throw new RuntimeException("internal_error"); + } + } + + /** + * Gibt die Singleton-Instanz zurück. + * + * @return DatabaseCommentManager + */ + public static function getInstance() + { + if (self::$instance === null) { + self::$instance = new DatabaseCommentManager(); + } + + return self::$instance; + } + + /** + * Speichert einen neuen Kommentar oder eine Antwort. + * + * @param int $articleId ID des Beitrags + * @param string $author Autor des Kommentars + * @param string $content Inhalt des Kommentars + * @param int|null $parentCommentId ID des Eltern-Kommentars oder null + * + * @return int ID des neu gespeicherten Kommentars + */ + public function addComment( + $articleId, + $author, + $content, + $parentCommentId = null + ) { + try { + $db = $this->getConnection(); + + if ($parentCommentId === "" || $parentCommentId === 0 || $parentCommentId === "0") { + $parentCommentId = null; + } + + $sql = " + INSERT INTO comments ( + article_id, + parent_comment_id, + author, + content + ) + VALUES ( + :articleId, + :parentCommentId, + :author, + :content + ) + "; + + $command = $db->prepare($sql); + + $command->execute([ + ":articleId" => $articleId, + ":parentCommentId" => $parentCommentId, + ":author" => $author, + ":content" => $content + ]); + + return intval($db->lastInsertId()); + + } catch (PDOException $e) { + throw new RuntimeException("internal_error"); + } + } + + /** + * Lädt alle Kommentare eines Beitrags. + * + * @param int $articleId ID des Beitrags + * + * @return Comment[] + */ + public function getCommentsByArticle($articleId) + { + try { + $db = $this->getConnection(); + + $sql = " + SELECT + id, + article_id, + CASE + WHEN parent_comment_id IS NULL THEN NULL + WHEN parent_comment_id = '' THEN NULL + WHEN parent_comment_id = 0 THEN NULL + ELSE parent_comment_id + END AS parent_comment_id, + author, + content, + created + FROM comments + WHERE article_id = :articleId + ORDER BY created ASC + "; + + $command = $db->prepare($sql); + $command->execute([":articleId" => $articleId]); + + return $this->mapRowsToComments($command); + + } catch (PDOException $e) { + throw new RuntimeException("internal_error"); + } + } + + /** + * Lädt alle Kommentare eines Autors. + * + * @param string $author E-Mail-Adresse des Autors + * + * @return Comment[] + */ + public function getCommentsByAuthor($author) + { + try { + $db = $this->getConnection(); + + $sql = " + SELECT + id, + article_id, + CASE + WHEN parent_comment_id IS NULL THEN NULL + WHEN parent_comment_id = '' THEN NULL + WHEN parent_comment_id = 0 THEN NULL + ELSE parent_comment_id + END AS parent_comment_id, + author, + content, + created + FROM comments + WHERE author = :author + ORDER BY created DESC + "; + + $command = $db->prepare($sql); + $command->execute([":author" => $author]); + + return $this->mapRowsToComments($command); + + } catch (PDOException $e) { + throw new RuntimeException("internal_error"); + } + } + + /** + * Wandelt Datenbankzeilen in Comment-Objekte um. + * + * @param PDOStatement $command Ausgeführtes Statement + * + * @return Comment[] + */ + private function mapRowsToComments($command) + { + $comments = []; + + while ($row = $command->fetch(PDO::FETCH_ASSOC)) { + $parentCommentId = null; + + if ( + isset($row["parent_comment_id"]) + && $row["parent_comment_id"] !== null + && $row["parent_comment_id"] !== "" + && intval($row["parent_comment_id"]) !== 0 + ) { + $parentCommentId = intval($row["parent_comment_id"]); + } + + $comments[] = new Comment( + intval($row["id"]), + intval($row["article_id"]), + $parentCommentId, + $row["author"], + $row["content"], + $row["created"] + ); + } + + return $comments; + } +} \ No newline at end of file diff --git a/php/validator/article-validator.php b/php/validator/article-validator.php index d708258..e07b810 100644 --- a/php/validator/article-validator.php +++ b/php/validator/article-validator.php @@ -1,16 +1,5 @@ = 10) { - return true; - }else{ + // 1. Grundlegende Typprüfung + if (!is_string($content)) { return false; } + + // 2. Formale JSON-Prüfung (Kompatibel mit PHP 8.2) + $blocks = json_decode($content, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return false; + } + + // 3. Inhaltliche Validierung der einzelnen Blöcke + // Falls das JSON zwar valide, aber kein Array ist (z.B. nur ein String/Zahl) + if (!is_array($blocks)) { + return false; + } + + // Mindestens ein Block sollte vorhanden sein (optional, verhindert leere Beiträge) + if (empty($blocks)) { + return false; + } + + foreach ($blocks as $block) { + // Jeder Block muss die Keys 'type' und 'value' besitzen + if (!isset($block['type']) || !isset($block['value'])) { + return false; + } + + $type = $block['type']; + $value = $block['value']; + + if ($type === 'text') { + // Validierung für Text: Darf nach dem Trimmen nicht leer sein + if (trim($value) === '') { + return false; + } + } elseif ($type === 'image') { + // Validierung für Bild: Muss entweder mit uploads/ starten (Bestand) + // oder mit data:image/ beginnen (neues Base64-Bild aus dem Editor) + if (!is_string($value)) { + return false; + } + + $isValidPath = str_starts_with($value, 'uploads/'); + $isValidBase64 = str_starts_with($value, 'data:image/'); + + if (!$isValidPath && !$isValidBase64) { + return false; + } + } else { + // Unbekannter Blocktyp wird zur Sicherheit abgewiesen + return false; + } + } + + // Wenn alle Prüfungen bestanden wurden + return true; } /** diff --git a/uploads/.htaccess b/uploads/.htaccess new file mode 100644 index 0000000..b2891c3 --- /dev/null +++ b/uploads/.htaccess @@ -0,0 +1,14 @@ +# Verhindert das Auflisten aller Dateien im Ordner +Options -Indexes + +# Schaltet die PHP-Ausführung in diesem Ordner komplett ab + + Order Deny,Allow + Deny from all + + +# erlaubt nur den Zugriff auf folgende Dateien: + + Order Allow,Deny + Allow from all + \ No newline at end of file