Grupowanie komentarzy

This commit is contained in:
2026-02-18 22:07:29 +01:00
parent a282dd2080
commit e7549c6f00
5 changed files with 389 additions and 49 deletions

3
.htaccess Normal file
View File

@@ -0,0 +1,3 @@
<Files "db_connect.php">
Deny from all
</Files>

View File

@@ -5,8 +5,41 @@ header('Content-Type: application/json');
// Dołączamy połączenie do bazy (upewnij się, że plik db_connect.php istnieje i ma poprawne dane)
require_once 'db_connect.php';
session_start();
$action = $_GET['action'] ?? '';
// 0. AUTORYZACJA (LOGOWANIE)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'auth') {
$input = json_decode(file_get_contents('php://input'), true);
$pass = $input['password'] ?? '';
// $PROTOTYPE_PASSWORD jest zdefiniowane w db_connect.php
if (isset($PROTOTYPE_PASSWORD) && $pass === $PROTOTYPE_PASSWORD) {
$_SESSION['prototype_auth'] = true;
echo json_encode(['status' => 'success']);
} else {
echo json_encode(['status' => 'error', 'message' => 'Nieprawidłowe hasło']);
}
exit;
}
// 0. SPRAWDZENIE SESJI (Opcjonalne dla list, wymagane dla add)
function isAuthorized()
{
return !empty($_SESSION['prototype_auth']);
}
// 0.5 SPRAWDZENIE STANU AUTORYZACJI (GET) - dla frontendowego odtworzenia sesji
if ($_SERVER['REQUEST_METHOD'] === 'GET' && $action === 'check_auth') {
if (isAuthorized()) {
echo json_encode(['status' => 'success']);
} else {
echo json_encode(['status' => 'error', 'message' => 'Brak sesji']);
}
exit;
}
// 1. POBIERANIE KOMENTARZY (GET)
if ($_SERVER['REQUEST_METHOD'] === 'GET' && $action === 'list') {
$pagePath = $_GET['page_path'] ?? '';
@@ -20,6 +53,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && $action === 'list') {
$stmt = $pdo->prepare("SELECT * FROM prototype_comments WHERE page_path = ? ORDER BY created_at DESC");
$stmt->execute([$pagePath]);
$comments = $stmt->fetchAll();
// XSS PROTECTION: Escaping danych przed wysłaniem
foreach ($comments as &$c) {
$c['author'] = htmlspecialchars($c['author'] ?? '', ENT_QUOTES, 'UTF-8');
$c['comment'] = htmlspecialchars($c['comment'] ?? '', ENT_QUOTES, 'UTF-8');
// Selector musi zostać oryginalny, bo JS go używa do querySelector!
// $c['dom_selector'] = htmlspecialchars($c['dom_selector'] ?? '', ENT_QUOTES, 'UTF-8');
}
echo json_encode(['status' => 'success', 'data' => $comments]);
} catch (PDOException $e) {
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
@@ -29,6 +71,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && $action === 'list') {
// 2. DODAWANIE KOMENTARZA (POST)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'add') {
// Wymagana autoryzacja
if (!isAuthorized()) {
echo json_encode(['status' => 'error', 'message' => 'Brak autoryzacji. Odśwież stronę i podaj hasło.']);
exit;
}
// Odczyt danych JSON z body requestu
$input = json_decode(file_get_contents('php://input'), true);
@@ -52,4 +100,55 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'add') {
}
exit;
}
// 3. ROZWIĄZYWANIE KOMENTARZA (POST)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'resolve') {
if (!isAuthorized()) {
echo json_encode(['status' => 'error', 'message' => 'Brak autoryzacji']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? 0;
if (!$id) {
echo json_encode(['status' => 'error', 'message' => 'Brak ID']);
exit;
}
try {
// Zmieniamy flagę is_resolved na 1
$stmt = $pdo->prepare("UPDATE prototype_comments SET is_resolved = 1 WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['status' => 'success']);
} catch (PDOException $e) {
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
exit;
}
// 4. USUWANIE KOMENTARZA (POST)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'delete') {
if (!isAuthorized()) {
echo json_encode(['status' => 'error', 'message' => 'Brak autoryzacji']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? 0;
if (!$id) {
echo json_encode(['status' => 'error', 'message' => 'Brak ID']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM prototype_comments WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['status' => 'success']);
} catch (PDOException $e) {
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
exit;
}
?>

View File

@@ -87,7 +87,7 @@ input:checked+.slider:before {
.comment-marker:hover {
z-index: 10001;
transform: rotate(-45deg) scale(1.2);
transform: rotate(-45deg) translate(-50%, -100%) scale(1.2);
}
.comment-marker.resolved {

View File

@@ -45,11 +45,43 @@
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Listenery UI
document.getElementById('comment-toggle').addEventListener('change', function (e) {
isCommentMode = e.target.checked;
toggleCommentMode(isCommentMode);
// Listenery UI
const toggle = document.getElementById('comment-toggle');
toggle.addEventListener('change', function (e) {
if (e.target.checked) {
// Próba włączenia - autoryzacja
checkAuth().then(ok => {
if (ok) {
isCommentMode = true;
localStorage.setItem('magico_comment_mode', 'true');
toggleCommentMode(true);
} else {
e.target.checked = false; // Cofnij switch
}
});
} else {
isCommentMode = false;
localStorage.removeItem('magico_comment_mode');
toggleCommentMode(false);
}
});
// --- RESTORE SESSION ---
const savedMode = localStorage.getItem('magico_comment_mode');
if (savedMode === 'true') {
// Sprawdź cicho czy sesja PHP jest aktywna
checkAuth(true).then(ok => {
if (ok) {
toggle.checked = true;
isCommentMode = true;
toggleCommentMode(true);
} else {
localStorage.removeItem('magico_comment_mode'); // Sesja wygasła
}
});
}
document.getElementById('cancel-comment').addEventListener('click', closeInputBox);
document.getElementById('save-comment').addEventListener('click', saveComment);
@@ -57,6 +89,51 @@
window.addEventListener('scroll', updateMarkerPositions, true);
}
// --- 1.5 AUTORYZACJA ---
// Prosta weryfikacja - czy mamy flagę w sesji JS?
// Lepiej: przy włączeniu zapytać o hasło jeśli nie mamy, i wysłać do API.
// API ustawi sesję PHP.
let isAuthorized = false; // Lokalna flaga, aby nie pytać co chwilę
async function checkAuth(silent = false) {
if (isAuthorized) return true;
// Najpierw zapytaj API czy już jesteśmy zalogowani (sesja PHP)
try {
const check = await fetch(API_URL + '?action=check_auth');
const checkData = await check.json();
if (checkData.status === 'success') {
isAuthorized = true;
return true;
}
} catch (e) { }
if (silent) return false;
const pass = prompt("Podaj hasło do trybu komentowania:");
if (!pass) return false;
try {
const res = await fetch(API_URL + '?action=auth', {
method: 'POST',
body: JSON.stringify({ password: pass })
});
const data = await res.json();
if (data.status === 'success') {
isAuthorized = true;
return true;
} else {
alert("Błąd: " + (data.message || "Nieprawidłowe hasło"));
return false;
}
} catch (e) {
alert("Błąd połączenia z API");
return false;
}
}
// --- 2. LOGIKA TRYBU (AGRESYWNE BLOKOWANIE) ---
function toggleCommentMode(active) {
if (active) {
@@ -220,18 +297,58 @@
document.querySelectorAll('.comment-marker').forEach(el => el.remove());
if (activePopover) { activePopover.remove(); activePopover = null; }
markersData.forEach((c, index) => {
// Grupowanie komentarzy po selektorze
const grouped = {};
markersData.forEach((c) => {
// Pomiń rozwiązane, jeśli chcemy je ukrywać (na razie pokazujemy wszystkie, albo tylko nierozwiązane?)
// User: "archiwum zmienia flagę is_resolved". Zakładamy że archiwum = ukryte?
// "Archiwum" zwykle oznacza ukryte. Ukryjmy rozwiązane.
if (c.is_resolved == 1) return;
if (!grouped[c.dom_selector]) grouped[c.dom_selector] = [];
grouped[c.dom_selector].push(c);
});
Object.keys(grouped).forEach((selector, index) => {
const commentsList = grouped[selector];
if (!commentsList.length) return;
try {
const el = document.querySelector(c.dom_selector);
const el = document.querySelector(selector);
if (el && el.offsetParent !== null) { // Tylko widoczne elementy
const marker = document.createElement('div');
marker.className = 'comment-marker';
marker.dataset.index = index; // Żeby łatwo znaleźć dane
marker.dataset.selector = selector;
// Zdarzenie kliknięcia w marker
marker.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
showPopover(marker, c);
// Jeśli więcej niż 1 komentarz, pokaż licznik
if (commentsList.length > 1) {
marker.textContent = commentsList.length;
marker.style.display = 'flex';
marker.style.alignItems = 'center';
marker.style.justifyContent = 'center';
marker.style.color = 'white';
marker.style.fontSize = '12px';
marker.style.fontWeight = 'bold';
// Resetuejmu transform dla tekstu żeby był czytelny? Nie, rotacja jest na pinezce.
// Tekst też się obróci. Trzeba by go odkręcić.
// Prościej: dodajmy span w środku który odkręcimy.
marker.innerHTML = `<span style="transform: rotate(45deg); display:block;">${commentsList.length}</span>`;
}
// Zdarzenie HOVER (najazd myszką)
marker.addEventListener('mouseenter', (e) => {
showPopover(marker, commentsList);
});
marker.addEventListener('mouseleave', (e) => {
marker._leaveTimeout = setTimeout(() => {
if (activePopover && activePopover._associatedMarker === marker) {
if (!activePopover.matches(':hover')) {
activePopover.remove();
activePopover = null;
}
}
}, 300);
});
document.body.appendChild(marker);
@@ -243,48 +360,96 @@
updateMarkerPositions();
}
function showPopover(marker, data) {
function showPopover(marker, commentsList) {
if (activePopover) activePopover.remove();
const pop = document.createElement('div');
pop.className = 'comment-popover';
// Escape HTML - XSS Protection
const safeAuthor = document.createElement('div');
safeAuthor.textContent = data.author || 'Anonim';
const safeComment = document.createElement('div');
safeComment.textContent = data.comment || '';
// Budowanie listy komentarzy
let html = '<div style="max-height: 200px; overflow-y: auto;">';
pop.innerHTML = `
<h6>${safeAuthor.innerHTML}</h6>
<p>${safeComment.innerHTML}</p>
`;
commentsList.forEach(data => {
// Escape (chociaż API to robi, warto mieć warstwę w JS w razie czego, ale API już robi htmlspecialchars)
// Skoro API robi htmlspecialchars, to tutaj możemy bezpiecznie wstawić
// Ale uwaga: JS textContent jest bezpieczniejszy.
// Hack na szybkie budowanie bezpiecznego HTML w pętli stringów jest trudny.
// Zbudujmy to jako elementy DOM potem? Albo zaufajmy API + textContent buildera.
// Zrobimy placeholder i podstawimy wartości.
html += `
<div class="comment-item" style="border-bottom:1px solid #eee; padding-bottom:8px; margin-bottom:8px;">
<div style="display:flex; justify-content:space-between; margin-bottom:4px;">
<h6 style="margin:0;">${data.author || 'Anonim'}</h6>
<span style="font-size:10px; color:#999;">${data.created_at}</span>
</div>
<p style="margin-bottom:5px;">${data.comment}</p>
<div style="text-align:right; gap:5px; display:flex; justify-content:flex-end;">
<button class="btn btn-xs btn-outline-success btn-resolve" data-id="${data.id}" style="font-size:10px; padding: 2px 5px;">Rozwiąż</button>
<button class="btn btn-xs btn-outline-danger btn-delete" data-id="${data.id}" style="font-size:10px; padding: 2px 5px;">Usuń</button>
</div>
</div>
`;
});
html += '</div>';
pop.innerHTML = html;
document.body.appendChild(pop);
activePopover = pop;
pop._associatedMarker = marker;
// Bindowanie akcji
pop.querySelectorAll('.btn-resolve').forEach(btn => {
btn.addEventListener('click', () => resolveComment(btn.dataset.id));
});
pop.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', () => deleteComment(btn.dataset.id));
});
// Obsługa zamykania przy wyjechaniu z popovera
pop.addEventListener('mouseleave', () => {
if (activePopover === pop) {
activePopover.remove();
activePopover = null;
}
});
// Pozycjonowanie
const rect = marker.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
pop.style.top = (rect.top + scrollTop - 10) + 'px'; // Trochę wyżej
pop.style.left = (rect.right + scrollLeft + 10) + 'px'; // Obok markera
pop.style.top = (rect.top + scrollTop - 5) + 'px';
pop.style.left = (rect.right + scrollLeft + 2) + 'px';
// Jeśli wychodzi poza ekran z prawej, daj na lewo
if (rect.right + 260 > window.innerWidth) {
pop.style.left = (rect.left + scrollLeft - 260) + 'px';
}
}
// Zamknij popover przy kliknięciu w tło
window.addEventListener('click', (e) => {
if (activePopover && !e.target.closest('.comment-popover') && !e.target.closest('.comment-marker')) {
activePopover.remove();
activePopover = null;
}
});
function resolveComment(id) {
if (!confirm('Czy na pewno chcesz rozwiązać/zarchiwizować ten komentarz?')) return;
fetch(API_URL + '?action=resolve', {
method: 'POST', body: JSON.stringify({ id: id })
}).then(res => res.json()).then(res => {
if (res.status === 'success') loadComments();
else alert('Błąd: ' + res.message);
});
}
function deleteComment(id) {
if (!confirm('Czy na pewno chcesz trwale usunąć ten komentarz?')) return;
fetch(API_URL + '?action=delete', {
method: 'POST', body: JSON.stringify({ id: id })
}).then(res => res.json()).then(res => {
if (res.status === 'success') loadComments();
else alert('Błąd: ' + res.message);
});
}
// (Usunięto click-outside listener, bo teraz działamy na hover)
// Aktualizuj też pozycję otwartego popovera przy scrollu
const originalUpdatePositions = updateMarkerPositions;
@@ -296,44 +461,47 @@
const markers = document.querySelectorAll('.comment-marker');
markers.forEach(marker => {
const index = marker.dataset.index;
const data = markersData[index];
if (!data) return;
const selector = marker.dataset.selector;
if (!selector) return;
try {
const el = document.querySelector(data.dom_selector);
const el = document.querySelector(selector);
if (el && el.offsetParent !== null) {
const rect = el.getBoundingClientRect();
// Jeśli element wyjechał poza ekran (jest w scrollowanym divie ale schowany)
// Można go ukryć, ale na razie po prostu przesuwamy
marker.style.top = (rect.top + window.scrollY) + 'px';
marker.style.left = (rect.left + window.scrollX + (rect.width / 2)) + 'px';
marker.style.display = 'block';
marker.style.display = (marker.textContent.length > 0) ? 'flex' : 'block';
// Flex dla licznika, block dla zwykłego? A w stylach mamy display?
// W renderMarkers ustawilismy display:flex ręcznie dla licznika.
// Tu musimy uwazac zeby tego nie zepsuc. Pominmy style.display='block' jesli ma flex.
if (marker.style.display !== 'flex') marker.style.display = 'block';
if (activePopover && activePopover._associatedMarker === marker) {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
// Recalculate position
let top = (rect.top + scrollTop - 10);
let left = (rect.right + scrollLeft + 10);
let top = (rect.top + scrollTop - 5);
let left = (rect.right + scrollLeft + 2);
if (rect.right + 260 > window.innerWidth) {
left = (rect.left + scrollLeft - 260); // Flip to left
left = (rect.left + scrollLeft - 260);
}
activePopover.style.top = top + 'px';
activePopover.style.left = left + 'px';
}
} else {
marker.style.display = 'none'; // Ukryj jeśli element zniknął
marker.style.display = 'none';
if (activePopover && activePopover._associatedMarker === marker) {
activePopover.remove();
activePopover = null;
}
}
} catch (e) { }
});
// Jeśli jest aktywny popover, zamknij go przy szybkim scrollu żeby nie pływał dziwnie?
// Albo po prostu zostaw.
}
// START
@@ -343,11 +511,77 @@
// Odświeżanie przy zmianie rozmiaru okna
window.addEventListener('resize', updateMarkerPositions);
// MutationObserver - odświeżaj pozycje jak coś się zmieni w DOM (np otwarcie taba)
const observer = new MutationObserver(() => {
// Debounce dla wydajności
if (window.commentUpdateTimeout) clearTimeout(window.commentUpdateTimeout);
window.commentUpdateTimeout = setTimeout(renderMarkers, 200);
// MutationObserver - odświeżaj pozycje jak coś się zmieni w wygenerowanym DOM (ale ignoruj nasze markery)
// Helper sprawdzający czy element jest częścią naszego UI
function isOurElement(node) {
if (!node || node.nodeType !== 1) return false;
if (node.classList && (
node.classList.contains('comment-marker') ||
node.classList.contains('comment-popover') ||
node.id === 'comment-input-box' ||
node.id === 'comment-input-box-overlay'
)) return true;
if (node.closest && (
node.closest('.comment-marker') ||
node.closest('.comment-popover') ||
node.closest('#comment-input-box')
)) return true;
return false;
}
const observer = new MutationObserver((mutations) => {
let shouldUpdate = false;
for (const mutation of mutations) {
// 1. Zmiany atrybutów na naszych elementach
if (mutation.type === 'attributes') {
if (isOurElement(mutation.target)) continue;
}
// 2. Zmiany w strukturze DOM (dodawanie/usuwanie węzłów)
if (mutation.type === 'childList') {
let externalChanges = false;
// Sprawdź dodane
if (mutation.addedNodes.length > 0) {
for (let i = 0; i < mutation.addedNodes.length; i++) {
const node = mutation.addedNodes[i];
if (node.nodeType === 1 && !isOurElement(node)) {
externalChanges = true; break;
}
// Ignorujemy same zmiany tekstowe/puste znaki w body, chyba że to istotne?
// Dla bezpieczeństwa: jeśli to tekst i nie jest pusty -> update
if (node.nodeType === 3 && node.textContent.trim() !== '') {
externalChanges = true; break;
}
}
}
// Sprawdź usunięte (tylko jeśli jeszcze nie wykryto zmian)
if (!externalChanges && mutation.removedNodes.length > 0) {
for (let i = 0; i < mutation.removedNodes.length; i++) {
const node = mutation.removedNodes[i];
if (node.nodeType === 1 && !isOurElement(node)) {
externalChanges = true; break;
}
}
}
// Jeśli zmiany nie dotyczyły "zewnętrznych" elementów (czyli tylko nasze), ignoruj.
if (!externalChanges) continue;
}
shouldUpdate = true;
break;
}
if (shouldUpdate) {
if (window.commentUpdateTimeout) clearTimeout(window.commentUpdateTimeout);
window.commentUpdateTimeout = setTimeout(renderMarkers, 200);
}
});
observer.observe(document.body, {

View File

@@ -3,8 +3,12 @@ $host = 'localhost';
$db = 'srv105686_magicoprototype';
$user = 'srv105686_magicoprototype';
$pass = 'pMMdmYHXSBs6wM5LHNjd';
$pass = 'pMMdmYHXSBs6wM5LHNjd';
$charset = 'utf8mb4';
// Hasło do trybu komentowania
$PROTOTYPE_PASSWORD = 'magic'; // Zmień to hasło!
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,