Auto-Commit: 2026-07-05 10:51:13

This commit is contained in:
2026-07-05 10:51:13 +02:00
parent 7ff01ea431
commit ed9f0d058c
9 changed files with 410 additions and 88 deletions
+153 -80
View File
@@ -93,55 +93,32 @@ document.addEventListener('DOMContentLoaded', () => {
const feedbackList = document.getElementById('feedback-list');
const filterButtons = document.querySelectorAll('.filter-btn');
// Default Seed Data
const defaultFeedback = [
{
id: 'fb-1',
author: 'Markus T.',
type: 'feature',
title: 'QR-Code Download für Gäste',
text: 'Es wäre super cool, wenn nach der Aufnahme ein QR-Code angezeigt wird, über den sich die Gäste das Foto direkt auf das Handy laden können.',
upvotes: 42,
voted: false
},
{
id: 'fb-2',
author: 'Sabrina',
type: 'love',
title: 'Sticker-Editor ist genial!',
text: 'Unsere Hochzeitsgäste haben stundenlang Emojis und Eventtexte verschoben. Die Bedienung ist wirklich super intuitiv und einfach.',
upvotes: 28,
voted: false
},
{
id: 'fb-3',
author: 'EventTech GmbH',
type: 'bug',
title: 'Drucker-Timeout bei schlechtem WLAN',
text: 'Manchmal meldet die App einen Fehler, wenn der Drucker im lokalen Netzwerk kurz die Verbindung verliert. Ein automatischer Retry wäre hilfreich.',
upvotes: 15,
voted: false
}
];
// Load from LocalStorage or seed defaults
let feedbackData = JSON.parse(localStorage.getItem('schnappix_feedback'));
if (!feedbackData) {
feedbackData = defaultFeedback;
localStorage.setItem('schnappix_feedback', JSON.stringify(feedbackData));
}
let feedbackData = [];
// Upvote storage track (local to session/device)
let upvotedIds = JSON.parse(localStorage.getItem('schnappix_upvoted_ids')) || [];
const saveToLocalStorage = () => {
localStorage.setItem('schnappix_feedback', JSON.stringify(feedbackData));
const saveUpvotesToLocal = () => {
localStorage.setItem('schnappix_upvoted_ids', JSON.stringify(upvotedIds));
};
// Admin State
let isAdmin = sessionStorage.getItem('schnappix_is_admin') === 'true';
const loadFeedback = async () => {
if (!feedbackList) return;
try {
const response = await fetch('feedback_backend.php?action=get');
const result = await response.json();
if (result.success) {
feedbackData = result.data;
renderFeedback(getActiveFilter());
}
} catch (error) {
console.error('Error loading feedback:', error);
}
};
const renderFeedback = (filter = 'all') => {
if (!feedbackList) return;
feedbackList.innerHTML = '';
@@ -166,20 +143,37 @@ document.addEventListener('DOMContentLoaded', () => {
categoryLabel = 'Lob';
}
// Show delete button if user is admin
const deleteBtnHtml = isAdmin
? `<button class="fb-admin-delete-btn" data-id="${item.id}" title="Eintrag löschen"><i class="fa-solid fa-trash"></i> Löschen</button>`
: '';
// Show admin actions if user is admin
let adminActionsHtml = '';
if (isAdmin) {
adminActionsHtml = `
<div class="fb-admin-actions" style="margin-top: 10px; margin-bottom: 15px; display: flex; gap: 10px;">
<button class="fb-admin-answer-btn btn btn-secondary" style="padding: 5px 10px; font-size: 12px;" data-id="${item.id}" title="Antworten"><i class="fa-solid fa-reply"></i> Antworten</button>
<button class="fb-admin-delete-btn btn" style="padding: 5px 10px; font-size: 12px; background: rgba(255,50,50,0.2); color: #ff6b6b;" data-id="${item.id}" title="Eintrag löschen"><i class="fa-solid fa-trash"></i> Löschen</button>
</div>
`;
}
let answerHtml = '';
if (item.answer) {
answerHtml = `
<div class="fb-answer" style="margin-top: 15px; padding: 12px; background: rgba(var(--accent-color-rgb), 0.1); border-left: 3px solid var(--accent-color); border-radius: 4px;">
<div class="fb-answer-header" style="font-weight: bold; font-size: 13px; color: var(--accent-color); margin-bottom: 5px;"><i class="fa-solid fa-check-circle"></i> Offizielle Antwort</div>
<p style="margin: 0; font-size: 14px;">${escapeHtml(item.answer)}</p>
</div>
`;
}
card.innerHTML = `
<div class="fb-card-content">
<div class="fb-meta">
<span class="fb-tag ${item.type}">${badgeIcon} ${categoryLabel}</span>
<span class="fb-author">von ${escapeHtml(item.author)}</span>
${deleteBtnHtml}
</div>
${adminActionsHtml}
<h4>${escapeHtml(item.title)}</h4>
<p>${escapeHtml(item.text)}</p>
${answerHtml}
</div>
<div class="fb-upvote-section">
<button class="upvote-btn ${isVoted ? 'voted' : ''}" data-id="${item.id}" aria-label="Abstimmen">
@@ -194,25 +188,74 @@ document.addEventListener('DOMContentLoaded', () => {
setupUpvoteButtons();
setupAdminDeleteButtons();
setupAdminAnswerButtons();
};
const setupAdminDeleteButtons = () => {
if (!isAdmin) return;
const deleteButtons = document.querySelectorAll('.fb-admin-delete-btn');
deleteButtons.forEach(btn => {
btn.addEventListener('click', () => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
if (confirm('Möchten Sie diesen Eintrag wirklich permanent löschen?')) {
feedbackData = feedbackData.filter(item => item.id !== id);
saveToLocalStorage();
renderFeedback(getActiveFilter());
try {
const response = await fetch('feedback_backend.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete', id: id, secret: '1234' })
});
const result = await response.json();
if (result.success) {
loadFeedback();
} else {
alert('Fehler beim Löschen.');
}
} catch (e) {
console.error(e);
}
}
});
});
};
const setupAdminAnswerButtons = () => {
if (!isAdmin) return;
const answerButtons = document.querySelectorAll('.fb-admin-answer-btn');
answerButtons.forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-id');
const item = feedbackData.find(i => i.id === id);
const currentAnswer = item ? (item.answer || '') : '';
const answer = prompt('Antwort für das Feedback (leer lassen zum Löschen der Antwort):', currentAnswer);
if (answer !== null) {
submitAnswer(id, answer);
}
});
});
};
const submitAnswer = async (id, answer) => {
try {
const response = await fetch('feedback_backend.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'answer', id: id, answer: answer, secret: '1234' })
});
const result = await response.json();
if (result.success) {
loadFeedback();
} else {
alert('Fehler beim Speichern der Antwort.');
}
} catch (e) {
console.error(e);
}
};
const escapeHtml = (str) => {
return str.replace(/&/g, "&amp;")
if (!str) return '';
return String(str).replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
@@ -222,22 +265,33 @@ document.addEventListener('DOMContentLoaded', () => {
const setupUpvoteButtons = () => {
const buttons = document.querySelectorAll('.upvote-btn');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
const index = feedbackData.findIndex(item => item.id === id);
const isVoted = upvotedIds.includes(id);
if (index !== -1) {
if (upvotedIds.includes(id)) {
// Undo upvote
feedbackData[index].upvotes--;
upvotedIds = upvotedIds.filter(vId => vId !== id);
} else {
// Upvote
feedbackData[index].upvotes++;
upvotedIds.push(id);
}
saveToLocalStorage();
renderFeedback(getActiveFilter());
// Optimistic UI update
const btnSpan = btn.querySelector('span');
let currentVotes = parseInt(btnSpan.textContent);
if (isVoted) {
btn.classList.remove('voted');
btnSpan.textContent = currentVotes - 1;
upvotedIds = upvotedIds.filter(vId => vId !== id);
} else {
btn.classList.add('voted');
btnSpan.textContent = currentVotes + 1;
upvotedIds.push(id);
}
saveUpvotesToLocal();
try {
await fetch('feedback_backend.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'upvote', id: id, undo: isVoted })
});
} catch (e) {
console.error('Error upvoting', e);
}
});
});
@@ -259,36 +313,53 @@ document.addEventListener('DOMContentLoaded', () => {
// Form Submission
if (feedbackForm) {
feedbackForm.addEventListener('submit', (e) => {
feedbackForm.addEventListener('submit', async (e) => {
e.preventDefault();
const authorInput = document.getElementById('fb-author');
const typeSelect = document.getElementById('fb-type');
const titleInput = document.getElementById('fb-title');
const textInput = document.getElementById('fb-text');
const submitBtn = feedbackForm.querySelector('button[type="submit"]');
const newFb = {
id: 'fb-' + Date.now(),
action: 'add',
author: authorInput.value,
type: typeSelect.value,
title: titleInput.value,
text: textInput.value,
upvotes: 1,
voted: true
text: textInput.value
};
feedbackData.push(newFb);
upvotedIds.push(newFb.id); // Auto-upvote own post
saveToLocalStorage();
// Reset form
feedbackForm.reset();
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet...';
// Re-render
renderFeedback(getActiveFilter());
// Smooth scroll to list
feedbackList.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
try {
const response = await fetch('feedback_backend.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newFb)
});
const result = await response.json();
if (result.success) {
// Auto-upvote own post
upvotedIds.push(result.data.id);
saveUpvotesToLocal();
feedbackForm.reset();
await loadFeedback(); // Reload the list
feedbackList.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
alert('Fehler beim Senden des Feedbacks.');
}
} catch (error) {
console.error(error);
alert('Netzwerkfehler. Bitte später erneut versuchen.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Absenden';
}
});
}
@@ -421,5 +492,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Initial render
renderFeedback();
if (feedbackList) {
loadFeedback();
}
});
-1
View File
@@ -25,7 +25,6 @@
<a href="faq.html">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="#" id="admin-login-trigger"><i class="fa-solid fa-user-gear"></i> Admin</a>
<a href="impressum.html">Impressum</a>
</nav>
<button class="menu-toggle" aria-label="Menu öffnen">
<i class="fa-solid fa-bars"></i>
+80 -1
View File
@@ -26,7 +26,6 @@
<a href="faq.html" class="active-nav">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="#" id="admin-login-trigger"><i class="fa-solid fa-user-gear"></i> Admin</a>
<a href="impressum.html">Impressum</a>
</nav>
<button class="menu-toggle" aria-label="Menu öffnen">
<i class="fa-solid fa-bars"></i>
@@ -91,6 +90,86 @@
</ul>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header">
<span>Benötige ich eine Internetverbindung für die Fotobox?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Nein, für das Aufnehmen von Fotos, die Speicherung auf dem Gerät und den lokalen Druck (z.B. über ein lokales WLAN) wird <strong>keine</strong> aktive Internetverbindung benötigt.</p>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header">
<span>Welche Android-Version wird mindestens benötigt?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Schnappix benötigt mindestens <strong>Android 10 (API Level 29)</strong>, um die volle Unterstützung für die moderne CameraX-API und das aktuelle Dateisystem (Scoped Storage) zu gewährleisten.</p>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header">
<span>Kann ich eine externe USB-Kamera anschließen?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Ja, Schnappix unterstützt neben der eingebauten Tablet-Frontkamera auch externe USB-Kameras (UVC). Schließe einfach eine USB-Webcam an dein Tablet an, um für eine noch bessere Bildqualität zu sorgen.</p>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header">
<span>Wie funktionieren Sticker und Emojis?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Schnappix bietet einen integrierten Editor, mit dem die Gäste ihre Collagen vor dem Speichern oder Drucken verzieren können. Emojis, Datumsanzeigen und der Eventname können dabei wie auf Social Media frei auf dem Bildschirm verschoben, gedreht und skaliert werden.</p>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header">
<span>Wie viele Fotos werden für eine Collage aufgenommen?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Die App ist für Serienbilder ausgelegt und nimmt automatisch 1 bis 5 Fotos hintereinander auf. Aus dieser Serie wird im Anschluss direkt eine fertige Collage zum Drucken oder Speichern generiert.</p>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header">
<span>Wie kann ich die Fotobox-App wieder verlassen?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Da Schnappix für den sicheren Betrieb auf Events entwickelt wurde, sperrt der Kiosk-Modus das Tablet zuverlässig ab. Gäste können die App nicht beenden. Das Verlassen ist nur durch die Eingabe deines sicheren Passworts im versteckten Admin-Bereich möglich.</p>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header">
<span>Kann ich die Bilder nachträglich ansehen?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Ja, alle aufgenommenen Originalfotos und die fertigen Collagen werden automatisch und lokal in der Standard-Galerie-App deines Tablets (unter dem jeweiligen Event-Namen) abgespeichert und können jederzeit eingesehen oder exportiert werden.</p>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header">
<span>Ist die App auch für Smartphones geeignet?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Die Benutzeroberfläche von Schnappix ist primär für die Nutzung auf <strong>Tablets</strong> optimiert. Tablets bieten den Gästen auf Events einen größeren Bildschirm als Vorschau-Spiegel und eine angenehmere Bedienung für den Sticker-Editor.</p>
</div>
</div>
</div>
</div>
</section>
-1
View File
@@ -26,7 +26,6 @@
<a href="faq.html">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="#" id="admin-login-trigger"><i class="fa-solid fa-user-gear"></i> Admin</a>
<a href="impressum.html">Impressum</a>
</nav>
<button class="menu-toggle" aria-label="Menu öffnen">
<i class="fa-solid fa-bars"></i>
+1 -2
View File
@@ -26,7 +26,6 @@
<a href="faq.html">FAQ</a>
<a href="feedback.html" class="active-nav">Feedback Hub</a>
<a href="#" id="admin-login-trigger"><i class="fa-solid fa-user-gear"></i> Admin</a>
<a href="impressum.html">Impressum</a>
</nav>
<button class="menu-toggle" aria-label="Menu öffnen">
<i class="fa-solid fa-bars"></i>
@@ -124,7 +123,7 @@
<button class="modal-close" aria-label="Schließen">&times;</button>
<h2>Admin Panel</h2>
<div class="modal-scroll-area">
<p>Sie sind als Administrator angemeldet. Sie können nun Einträge im Feedback Hub löschen.</p>
<p>Sie sind als Administrator angemeldet. Sie können nun Einträge im Feedback Hub beantworten oder löschen.</p>
<div style="margin-top: 20px; display: flex; gap: 15px;">
<button id="admin-logout-btn" class="btn btn-secondary">Abmelden</button>
<button id="admin-export-btn" class="btn btn-primary">Einträge-Code exportieren</button>
+176
View File
@@ -0,0 +1,176 @@
<?php
error_reporting(0);
header('Content-Type: application/json');
$dataDir = __DIR__ . '/data/';
$dataFile = $dataDir . 'feedback.json';
// Create data directory if not exists
if (!is_dir($dataDir)) {
@mkdir($dataDir, 0755, true);
}
// Default Seed Data
$defaultFeedback = [
[
'id' => 'fb-1',
'author' => 'Markus T.',
'type' => 'feature',
'title' => 'QR-Code Download für Gäste',
'text' => 'Es wäre super cool, wenn nach der Aufnahme ein QR-Code angezeigt wird, über den sich die Gäste das Foto direkt auf das Handy laden können.',
'upvotes' => 42,
'answer' => null
],
[
'id' => 'fb-2',
'author' => 'Sabrina',
'type' => 'love',
'title' => 'Sticker-Editor ist genial!',
'text' => 'Unsere Hochzeitsgäste haben stundenlang Emojis und Eventtexte verschoben. Die Bedienung ist wirklich super intuitiv und einfach.',
'upvotes' => 28,
'answer' => 'Vielen Dank für das tolle Feedback! Freut uns, dass es euch gefallen hat.'
],
[
'id' => 'fb-3',
'author' => 'EventTech GmbH',
'type' => 'bug',
'title' => 'Drucker-Timeout bei schlechtem WLAN',
'text' => 'Manchmal meldet die App einen Fehler, wenn der Drucker im lokalen Netzwerk kurz die Verbindung verliert. Ein automatischer Retry wäre hilfreich.',
'upvotes' => 15,
'answer' => null
]
];
// Initialize JSON file if it doesn't exist
if (!file_exists($dataFile)) {
file_put_contents($dataFile, json_encode($defaultFeedback, JSON_PRETTY_PRINT));
}
// Read data
$feedbackData = json_decode(file_get_contents($dataFile), true);
if (!$feedbackData) {
$feedbackData = [];
}
$action = isset($_GET['action']) ? $_GET['action'] : '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// For POST requests, we might get the action from the body or query param
$inputJSON = file_get_contents('php://input');
$input = json_decode($inputJSON, true);
$action = isset($input['action']) ? $input['action'] : $action;
}
switch ($_SERVER['REQUEST_METHOD']) {
case 'GET':
// Return all feedback
echo json_encode(['success' => true, 'data' => $feedbackData]);
break;
case 'POST':
if (!$input) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON']);
exit;
}
if ($action === 'add') {
$newFb = [
'id' => 'fb-' . time() . rand(100, 999),
'author' => htmlspecialchars($input['author'] ?? 'Anonym'),
'type' => htmlspecialchars($input['type'] ?? 'feature'),
'title' => htmlspecialchars($input['title'] ?? 'Kein Titel'),
'text' => htmlspecialchars($input['text'] ?? ''),
'upvotes' => 1,
'answer' => null
];
array_push($feedbackData, $newFb);
file_put_contents($dataFile, json_encode($feedbackData, JSON_PRETTY_PRINT));
echo json_encode(['success' => true, 'data' => $newFb]);
}
elseif ($action === 'upvote') {
$id = $input['id'] ?? '';
$undo = isset($input['undo']) && $input['undo'] === true;
$found = false;
foreach ($feedbackData as &$item) {
if ($item['id'] === $id) {
if ($undo) {
$item['upvotes'] = max(0, $item['upvotes'] - 1);
} else {
$item['upvotes']++;
}
$found = true;
break;
}
}
if ($found) {
file_put_contents($dataFile, json_encode($feedbackData, JSON_PRETTY_PRINT));
echo json_encode(['success' => true]);
} else {
http_response_code(404);
echo json_encode(['error' => 'Feedback not found']);
}
}
elseif ($action === 'delete') {
$id = $input['id'] ?? '';
$secret = $input['secret'] ?? '';
// Simple authentication via secret "1234" (as defined in frontend)
if ($secret !== '1234') {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$filteredData = array_filter($feedbackData, function($item) use ($id) {
return $item['id'] !== $id;
});
// Re-index array
$feedbackData = array_values($filteredData);
file_put_contents($dataFile, json_encode($feedbackData, JSON_PRETTY_PRINT));
echo json_encode(['success' => true]);
}
elseif ($action === 'answer') {
$id = $input['id'] ?? '';
$answer = $input['answer'] ?? '';
$secret = $input['secret'] ?? '';
if ($secret !== '1234') {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$found = false;
foreach ($feedbackData as &$item) {
if ($item['id'] === $id) {
// Save htmlspecialchars to avoid XSS but allow simple answers
$item['answer'] = $answer === '' ? null : htmlspecialchars($answer);
$found = true;
break;
}
}
if ($found) {
file_put_contents($dataFile, json_encode($feedbackData, JSON_PRETTY_PRINT));
echo json_encode(['success' => true]);
} else {
http_response_code(404);
echo json_encode(['error' => 'Feedback not found']);
}
}
else {
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
}
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
break;
}
-1
View File
@@ -25,7 +25,6 @@
<a href="faq.html">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="#" id="admin-login-trigger"><i class="fa-solid fa-user-gear"></i> Admin</a>
<a href="impressum.html" class="active-nav">Impressum</a>
</nav>
<button class="menu-toggle" aria-label="Menu öffnen">
<i class="fa-solid fa-bars"></i>
-1
View File
@@ -26,7 +26,6 @@
<a href="faq.html">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="#" id="admin-login-trigger"><i class="fa-solid fa-user-gear"></i> Admin</a>
<a href="impressum.html">Impressum</a>
</nav>
<button class="menu-toggle" aria-label="Menu öffnen">
<i class="fa-solid fa-bars"></i>
-1
View File
@@ -160,7 +160,6 @@
<a href="faq.html">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="#" id="admin-login-trigger"><i class="fa-solid fa-user-gear"></i> Admin</a>
<a href="impressum.html">Impressum</a>
</nav>
<button class="menu-toggle" aria-label="Menu öffnen">
<i class="fa-solid fa-bars"></i>