Compare commits

...
13 Commits
13 changed files with 994 additions and 145 deletions
+3
View File
@@ -15,3 +15,6 @@ venv/
# Dependencies (just in case they get added later)
node_modules/
package-lock.json
# Environment variables
.env
+223 -96
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: sessionStorage.getItem('schnappix_admin_secret') || '' })
});
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: sessionStorage.getItem('schnappix_admin_secret') || '' })
});
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,96 @@ 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();
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet...';
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';
}
});
}
// --- Tester Registration Logic ---
const testerForm = document.getElementById('tester-form');
if (testerForm) {
testerForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Reset form
feedbackForm.reset();
// Re-render
renderFeedback(getActiveFilter());
// Smooth scroll to list
feedbackList.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const emailInput = document.getElementById('tester-email');
const submitBtn = document.getElementById('tester-submit-btn');
const successMsg = document.getElementById('tester-success-msg');
const errorMsg = document.getElementById('tester-error-msg');
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet...';
successMsg.style.display = 'none';
errorMsg.style.display = 'none';
try {
const response = await fetch('tester_backend.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailInput.value })
});
const result = await response.json();
if (result.success) {
successMsg.style.display = 'block';
testerForm.reset();
} else {
errorMsg.innerHTML = '<i class="fa-solid fa-triangle-exclamation"></i> ' + (result.error || 'Unbekannter Fehler');
errorMsg.style.display = 'block';
}
} catch (err) {
console.error(err);
errorMsg.innerHTML = '<i class="fa-solid fa-triangle-exclamation"></i> Netzwerkfehler. Bitte später erneut versuchen.';
errorMsg.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Als Tester anmelden';
}
});
}
@@ -353,26 +467,37 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (adminLoginForm) {
adminLoginForm.addEventListener('submit', (e) => {
adminLoginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const password = adminPasswordInput.value;
// Admin password matching the default app settings: "1234"
if (password === '1234') {
isAdmin = true;
sessionStorage.setItem('schnappix_is_admin', 'true');
adminPasswordInput.value = '';
adminLoginError.style.display = 'none';
try {
const response = await fetch('feedback_backend.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'verify_password', secret: password })
});
// Switch Modals
adminLoginModal.classList.remove('open');
adminPanelModal.classList.add('open');
// Re-render to show trash cans
renderFeedback(getActiveFilter());
} else {
if (response.ok) {
isAdmin = true;
sessionStorage.setItem('schnappix_is_admin', 'true');
sessionStorage.setItem('schnappix_admin_secret', password);
adminPasswordInput.value = '';
adminLoginError.style.display = 'none';
// Switch Modals
adminLoginModal.classList.remove('open');
adminPanelModal.classList.add('open');
// Re-render to show trash cans
renderFeedback(getActiveFilter());
} else {
adminLoginError.style.display = 'block';
adminPasswordInput.value = '';
}
} catch (err) {
console.error(err);
adminLoginError.style.display = 'block';
adminPasswordInput.value = '';
adminPasswordInput.focus();
}
});
}
@@ -421,5 +546,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Initial render
renderFeedback();
if (feedbackList) {
loadFeedback();
}
});
+10 -3
View File
@@ -24,8 +24,8 @@
<a href="upload.html">Upload</a>
<a href="faq.html">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="tester.html">App Testing</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>
@@ -44,6 +44,9 @@
<h4>Allgemeine Hinweise</h4>
<p>Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können.</p>
<h4>Verantwortliche Stelle</h4>
<p>Verantwortlich für die Datenverarbeitung auf dieser Website ist die im Impressum genannte Person.</p>
<h3>2. Datenerfassung auf unserer Website</h3>
<h4>Cookies & Local Storage</h4>
<p>Diese Webseite nutzt den lokalen Speicher (Local Storage) Ihres Browsers, um die von Ihnen geschriebenen Feedbacks sowie Ihre Stimmen (Upvotes) zu speichern. Dies geschieht ausschließlich auf Ihrem Endgerät und wird nicht an externe Server übertragen, es sei denn, Sie senden explizit ein Feedback ab.</p>
@@ -51,8 +54,12 @@
<h4>Server-Logfiles</h4>
<p>Der Provider der Seiten erhebt und speichert automatisch Informationen in sogenannten Server-Logfiles, die Ihr Browser automatisch an uns übermittelt. Dies sind Browsertyp, Betriebssystem, Referrer URL, Hostname des zugreifenden Rechners und Uhrzeit der Serveranfrage. Diese Daten sind nicht bestimmten Personen zuzuordnen.</p>
<h3>3. Feedback Hub</h3>
<p>Wenn Sie den Feedback Hub nutzen, werden Ihre Eingaben (Name, Titel, Text, Kategorie) gespeichert, um die Funktionalität der Feedback-Plattform bereitzustellen. Bitte geben Sie keine sensiblen personenbezogenen Daten im Freitextfeld an.</p>
<h3>3. Feedback Hub & Uploads</h3>
<p><strong>Feedback Hub:</strong> Wenn Sie den Feedback Hub nutzen, werden Ihre Eingaben (Name, Titel, Text, Kategorie) gespeichert, um die Funktionalität der Feedback-Plattform bereitzustellen. Bitte geben Sie keine sensiblen personenbezogenen Daten im Freitextfeld an.</p>
<p><strong>Upload-Funktion:</strong> Wenn Sie Bilddateien (Rahmen) über die Upload-Funktion hochladen, werden diese gespeichert. Nach erfolgreicher Prüfung werden diese Rahmen öffentlich in die App integriert und sind somit für alle Nutzer der App sichtbar. Bitte stellen Sie sicher, dass Sie keine privaten Bilder oder urheberrechtlich geschützten Inhalte ohne entsprechende Rechte hochladen.</p>
<h3>4. Ihre Rechte</h3>
<p>Sie haben jederzeit das Recht, unentgeltlich Auskunft über Herkunft, Empfänger und Zweck Ihrer gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht, die Berichtigung oder Löschung dieser Daten zu verlangen. Hierzu sowie zu weiteren Fragen zum Thema Datenschutz können Sie sich jederzeit an uns unter der im Impressum angegebenen E-Mail-Adresse wenden. Des Weiteren steht Ihnen ein Beschwerderecht bei der zuständigen Aufsichtsbehörde zu.</p>
</div>
</div>
</div>
+246 -15
View File
@@ -1,17 +1,22 @@
<!DOCTYPE html>
<html lang="de">
<head>
<link rel="icon" type="image/png" href="favicon.png">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schnappix - Häufig gestellte Fragen (FAQ)</title>
<meta name="description" content="Antworten auf Fragen zum Kiosk-Modus, IPP-Drucker-Einrichtung, Fotorahmen-Konfiguration und Foto-Speicherung.">
<meta name="description"
content="Antworten auf Fragen zum Kiosk-Modus, IPP-Drucker-Einrichtung, Fotorahmen-Konfiguration und Foto-Speicherung.">
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Outfit:wght@500;700;800&display=swap" rel="stylesheet">
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Outfit:wght@500;700;800&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<!-- Header Navigation -->
@@ -25,8 +30,8 @@
<a href="upload.html">Upload</a>
<a href="faq.html" class="active-nav">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="tester.html">App Testing</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>
@@ -37,8 +42,10 @@
<!-- Page Title -->
<div style="padding: 140px 0 20px;">
<div class="container">
<h1 class="section-title" style="margin-bottom: 20px;">Häufig gestellte <span class="text-gradient">Fragen</span></h1>
<p style="text-align: center; color: var(--text-muted); max-width: 600px; margin: 0 auto 40px;">Alles Wissenswerte zur Einrichtung, Hardware-Kompatibilität und Anpassung der Photobooth App.</p>
<h1 class="section-title" style="margin-bottom: 20px;">Häufig gestellte <span
class="text-gradient">Fragen</span></h1>
<p style="text-align: center; color: var(--text-muted); max-width: 600px; margin: 0 auto 40px;">Alles
Wissenswerte zur Einrichtung, Hardware-Kompatibilität und Anpassung der Schnappix App.</p>
</div>
</div>
@@ -46,15 +53,86 @@
<section class="faq-section" style="padding-top: 20px;">
<div class="container faq-container">
<div class="accordion">
<!-- Voraussetzungen & Kompatibilität -->
<div class="accordion-item">
<button class="accordion-header">
<span>Wie aktiviere ich den Kiosk-Modus auf dem Tablet?</span>
<span>Ist die App auch für Smartphones geeignet?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Damit der Kiosk-Modus (LockTask) ohne Bestätigungsdialoge funktioniert, muss die App als <strong>Device Owner</strong> festgelegt werden. Führe dazu folgenden ADB-Befehl aus:</p>
<code>adb shell dpm set-device-owner de.orfel.schnappix/de.orfel.schnappix.KioskDeviceAdminReceiver</code>
<p>Danach kannst du den Kiosk-Modus einfach im passwortgeschützten Admin-Bereich ein- und ausschalten.</p>
<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.
Allerdings funktioniert die App auch auf jedem Android Smartphone.</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>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>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. Sollte dies nicht sofort funktionieren, probiere es mit einem
USB-C-Hub.
Wenn eine externe Kamera verbunden wird, muss der Schnappix App in den
Admineinstellungen die Frontkamera deaktiviert werden, damit die USB-Kamera funktioniert,
da dies im Hintergrund nicht automatisch geschieht.
<br>Wichtig: Die Foto-Qualität hängt von der verbundenen Kamera ab.
</p>
</div>
</div>
<!-- Funktionen der Fotobox -->
<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.
Wie viel Bilder aufgenommen werden sollen, kann in den Admineinstellungen voreingestellt
werden. In einer Serie kann immer nur die eingestellte Anzahl an Fotos aufgenommen werden.
</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>
@@ -64,7 +142,22 @@
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Erstelle ein transparentes PNG im Format <strong>1500 x 1000 px</strong> (3:2 Querformat, max. 500 KB) und lege es im Ordner <code>assets/frames/</code> als <code>frame_name.png</code> ab. Trage das Template anschließend in <code>src/data/frames.ts</code> ein und führe einen neuen Build aus.</p>
<p>Erstelle ein transparentes PNG im Format <strong>1500 x 1000 px</strong> (3:2 Querformat,
max. 5 MB)
<strong>Variante 1 - Für besondere Anlässe: </strong>Das erstelle Bild kann auf der Seite
unter Upload
an den Entwickler übermittelt werden. Dieser kann das Template dann in die App integrieren.
Wichtig hierbei ist, dass nicht alle Anfragen berücksichtigt werden können.
Außerdem werden alle Rahmen öffentlich für alle sichtbar sein, sobald sie in die App
integriert wurden.
Die Bildrechte der hochgeladenen Rahmen müssen frei von Rechten Dritter sein (z.B. selber
gemalt oder selbst erstellt).
Der Entwickler übernimmt keine Haftung für die Nutzung der hochgeladenen Rahmen.
<strong>Variante 2 - Nur für Entwickler: </strong>Lege das erstellte PNG im Ordner
<code>assets/frames/</code> als <code>frame_name.png</code> ab.
Trage das Template anschließend in <code>src/data/frames.ts</code> ein
und führe einen neuen Build aus.
</p>
</div>
</div>
@@ -74,7 +167,51 @@
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Es werden alle Drucker unterstützt, die das <strong>IPP (Internet Printing Protocol)</strong> im lokalen Netzwerk unterstützen. Beliebte Fotodrucker wie der Canon CP1300 lassen sich direkt über ihre lokale IP-Adresse ansteuern.</p>
<p>Es werden alle Drucker unterstützt, die das <strong>IPP (Internet Printing Protocol)</strong>
im lokalen Netzwerk unterstützen. Beliebte Fotodrucker wie der Canon CP1300 lassen sich
direkt über ihre lokale IP-Adresse ansteuern.</p>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header">
<span>Kann ich die App auch ohne Drucker nutzen?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Ja, die App lässt sich auch <strong>komplett digital</strong> nutzen! Lässt du im
Admin-Bereich das Feld für die Drucker-IP-Adresse leer, schaltet die App automatisch in
einen digitalen Modus um und alle Druck-Buttons in der Benutzeroberfläche werden für die
Gäste ausgeblendet. Die Fotos werden dann rein lokal auf dem Gerät gespeichert.</p>
</div>
</div>
<!-- Speicherung & Event-Verwaltung -->
<div class="accordion-item">
<button class="accordion-header">
<span>Wie erstelle ich ein neues Event?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>In Schnappix gibt es keine komplizierte Account- oder Event-Verwaltung. Öffne einfach den
versteckten Admin-Bereich in der App und trage dort unter "Event-Name" den Titel deiner
Veranstaltung ein (z. B. "Hochzeit Anna & Paul"). Ab diesem Moment werden alle Fotos und
Collagen automatisch unter diesem Namen verknüpft und gespeichert.
Bei Neuinstallation der App wird automatisch nach dem Event gefragt, welcher in der
Admin-Übersicht jederzeit eingesehen und geändert werden kann.</p>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header">
<span>Kann ich den Event-Namen nachträglich ändern?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Ja, du kannst den Namen im Admin-Bereich jederzeit ändern. Beachte aber: Diese Namensänderung
gilt nur für zukünftige Aufnahmen. Bereits gespeicherte Fotos verbleiben in der
Galerie-Ordnerstruktur unter dem alten Event-Namen, damit deine bisherigen Dateien nicht
durcheinandergeraten.</p>
</div>
</div>
@@ -84,13 +221,103 @@
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Schnappix nutzt die moderne Android MediaStore API und speichert Collagen sowie Originale direkt in der Galerie deines Tablets unter den Ordnern:</p>
<p>Schnappix nutzt die moderne Android MediaStore API und speichert Collagen sowie Originale
direkt in der Galerie deines Tablets unter den Ordnern:</p>
<ul>
<li><code>Schnappix - &lt;EventName&gt; - Collagen</code></li>
<li><code>Schnappix - &lt;EventName&gt; - Originale</code></li>
</ul>
</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 Gerätes (unter dem jeweiligen Event-Namen) abgespeichert
und können jederzeit eingesehen oder exportiert werden.</p>
</div>
</div>
<!-- Sicherheit & Administration -->
<div class="accordion-item">
<button class="accordion-header">
<span>Wie aktiviere ich den Kiosk-Modus auf dem Tablet?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p><strong>Wichtiger Hinweis: </strong>Die Kiosk-Funktion ist nur für Entwickler gedacht!
Damit der Kiosk-Modus (LockTask) ohne Bestätigungsdialoge funktioniert, muss die App als
<strong>Device Owner</strong> festgelegt werden. Führe dazu folgenden ADB-Befehl aus:
</p>
<code>adb shell dpm set-device-owner de.orfel.schnappix/de.orfel.schnappix.KioskDeviceAdminReceiver</code>
<p>Danach kannst du den Kiosk-Modus einfach im passwortgeschützten Admin-Bereich ein- und
ausschalten.</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>Wo finde ich die Einstellungen in der Fotobox-App?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Die eigentliche Schnappix-App verfügt über einen versteckten Admin-Bereich.
Dieser ist durch ein Passwort (Standardmäßig: 1234) geschützt und ermöglicht es dir,
grundlegende
Parameter wie Event-Namen, Drucker-IP-Adressen oder Fotorahmen einzustellen und den
Kiosk-Modus zu steuern.</p>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header">
<span>Welche Einstellungsfelder gibt es im Admin-Bereich der App?</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="accordion-content">
<p>Der passwortgeschützte Admin-Bereich der App steuert das gesamte Verhalten der
Fotobox. Folgende Felder und Schalter stehen dir zur Verfügung:</p>
<ul>
<li style="margin-bottom: 8px;"><strong>Event-Name:</strong> Der Titel der Veranstaltung.
Dieser wird für die Datei-Ordnerstruktur genutzt und steht den Gästen als Sticker zur
Verfügung.</li>
<li style="margin-bottom: 8px;"><strong>Admin-Passwort:</strong> Erlaubt dir, das Passwort
für diesen Bereich zu ändern, damit Gäste sich keinen Zugriff verschaffen können.
Standardmäßig ist das Passwort: 1234.
Bei Vergessen ist ein Rücksetzen nur durch Neuinstallation der App möglich.</li>
<li style="margin-bottom: 8px;"><strong>Kiosk-Modus (LockTask):</strong> Ein Schalter, der
das Tablet sicher sperrt. Ist er aktiviert, gibt es keinen Zurück- oder Home-Button
mehr. Die App lässt sich erst nach Eingabe des Passworts wieder verlassen.</li>
<li style="margin-bottom: 8px;"><strong>Drucker-IP-Adresse:</strong> Trage hier die lokale
WLAN-Adresse (IPv4) deines Fotodruckers ein. Lässt du das Feld leer, schaltet die App in
einen rein digitalen Modus und versteckt alle Druck-Buttons.</li>
<li style="margin-bottom: 8px;"><strong>Fotorahmen / Template:</strong> Hier kannst du
festlegen, welche Rahmen als Überlagerung für die Collagen genutzt werden sollen.</li>
<li style="margin-bottom: 8px;"><strong>Log-Funktion:</strong> Ein Diagnose-Tool, das im
Hintergrund Ereignisse und potenzielle Abstürze protokolliert. Die Fehlerberichte
werden nicht automatisch an die Entwickler gesendet. Falls es zu Problemen kommt,
kannst du die Log-Funktion hier jederzeit aktivieren. Die Logs können dann gern
direkt mit dem Betreff "Schnappix Crash-Report" und einer kurzen Beschreibung des
Problems per E-Mail an <code>jannik.schnappix@gmail.com</code> gesendet werden.</li>
</ul>
</div>
</div>
</div>
</div>
</section>
@@ -116,7 +343,9 @@
<label for="admin-password">Admin Passwort</label>
<input type="password" id="admin-password" placeholder="Passwort eingeben" required>
</div>
<div id="admin-login-error" style="color: #ff4466; font-size: 14px; margin-bottom: 15px; display: none;">Falsches Passwort!</div>
<div id="admin-login-error"
style="color: #ff4466; font-size: 14px; margin-bottom: 15px; display: none;">Falsches Passwort!
</div>
<button type="submit" class="btn btn-primary btn-block">Anmelden</button>
</form>
</div>
@@ -133,7 +362,8 @@
<button id="admin-export-btn" class="btn btn-primary">Einträge-Code exportieren</button>
</div>
<div id="export-container" style="display: none; margin-top: 20px;">
<textarea id="export-textarea" rows="6" readonly style="width: 100%; font-family: monospace; font-size: 12px; background: rgba(5,5,16,0.8); border: 1px solid var(--border-color); color: var(--accent-color); padding: 10px; border-radius: 8px;"></textarea>
<textarea id="export-textarea" rows="6" readonly
style="width: 100%; font-family: monospace; font-size: 12px; background: rgba(5,5,16,0.8); border: 1px solid var(--border-color); color: var(--accent-color); padding: 10px; border-radius: 8px;"></textarea>
</div>
</div>
</div>
@@ -141,4 +371,5 @@
<script src="app.js"></script>
</body>
</html>
</html>
+1 -1
View File
@@ -25,8 +25,8 @@
<a href="upload.html">Upload</a>
<a href="faq.html">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="tester.html">App Testing</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>
+2 -2
View File
@@ -25,8 +25,8 @@
<a href="upload.html">Upload</a>
<a href="faq.html">FAQ</a>
<a href="feedback.html" class="active-nav">Feedback Hub</a>
<a href="tester.html">App Testing</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 +124,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>
+208
View File
@@ -0,0 +1,208 @@
<?php
error_reporting(0);
header('Content-Type: application/json');
$dataDir = __DIR__ . '/data/';
$dataFile = $dataDir . 'feedback.json';
function getAdminPassword() {
$envVar = getenv('ADMIN_PASSWORD');
if ($envVar !== false && $envVar !== '') {
return $envVar;
}
$envFile = __DIR__ . '/.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos(trim($line), '#') === 0) continue;
$parts = explode('=', $line, 2);
if (count($parts) === 2) {
if (trim($parts[0]) === 'ADMIN_PASSWORD') {
return trim($parts[1]);
}
}
}
}
return '1234';
}
$adminPassword = getAdminPassword();
// 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 === 'verify_password') {
$secret = $input['secret'] ?? '';
if ($secret === $adminPassword) {
echo json_encode(['success' => true]);
} else {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
}
exit;
}
elseif ($action === 'delete') {
$id = $input['id'] ?? '';
$secret = $input['secret'] ?? '';
if ($secret !== $adminPassword) {
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 !== $adminPassword) {
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;
}
+10 -11
View File
@@ -24,8 +24,8 @@
<a href="upload.html">Upload</a>
<a href="faq.html">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="tester.html">App Testing</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>
@@ -40,21 +40,20 @@
<h1 class="text-gradient" style="font-family: var(--font-titles); font-size: 36px; margin-bottom: 24px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px;">Impressum</h1>
<h3 style="margin-top: 20px; font-size: 18px;">Angaben gemäß § 5 TMG</h3>
<p style="color: var(--text-muted); margin-bottom: 15px;">Jannik [Nachname]<br>
[Straße und Hausnummer]<br>
[PLZ und Ort]</p>
<p style="color: var(--text-muted); margin-bottom: 15px;">Jannik Müller<br>
Rothenthaler Straße 2<br>
09526 Olbernhau</p>
<h3 style="margin-top: 20px; font-size: 18px;">Kontakt</h3>
<p style="color: var(--text-muted); margin-bottom: 15px;">E-Mail: info@schnappix.de<br>
Telefon: [Telefonnummer - optional]</p>
<p style="color: var(--text-muted); margin-bottom: 15px;">E-Mail: jannik.schnappix@gmail.com<br>
Telefon: 015678579661</p>
<h3 style="margin-top: 20px; font-size: 18px;">Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h3>
<p style="color: var(--text-muted); margin-bottom: 15px;">Jannik [Nachname]<br>
[Adresse wie oben]</p>
<p style="color: var(--text-muted); margin-bottom: 15px;">Jannik Müller<br>
Rothenthaler Straße 2<br>
09526 Olbernhau</p>
<h3 style="margin-top: 20px; font-size: 18px;">Streitschlichtung</h3>
<p style="color: var(--text-muted); margin-bottom: 15px;">Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener" style="color: var(--accent-color);">https://ec.europa.eu/consumers/odr</a>.<br>
Unsere E-Mail-Adresse finden Sie oben im Impressum. Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</p>
</div>
</div>
</section>
+26 -10
View File
@@ -1,17 +1,22 @@
<!DOCTYPE html>
<html lang="de">
<head>
<link rel="icon" type="image/png" href="favicon.png">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schnappix - Die ultimative Photobooth App</title>
<meta name="description" content="Schnappix ist eine Tablet-optimierte Photobooth-App mit Kiosk-Modus, Direktdruck, Filtern, Stickern und anpassbaren Rahmen für dein perfektes Event.">
<meta name="description"
content="Schnappix ist eine Tablet-optimierte Photobooth-App mit Kiosk-Modus, Direktdruck, Filtern, Stickern und anpassbaren Rahmen für dein perfektes Event.">
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Outfit:wght@500;700;800&display=swap" rel="stylesheet">
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Outfit:wght@500;700;800&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<!-- Header Navigation -->
@@ -25,8 +30,8 @@
<a href="upload.html">Upload</a>
<a href="faq.html">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="tester.html">App Testing</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>
@@ -39,9 +44,11 @@
<div class="hero-bg-glow"></div>
<div class="container hero-container">
<div class="hero-content">
<div class="badge">🚀 Version 23.01.X</div>
<div class="badge">🚀 Version 23.01.17</div>
<h1>Erstelle unvergessliche Momente mit <span class="text-gradient">Schnappix</span></h1>
<p class="hero-lead">Die professionelle, Tablet-optimierte Photobooth-App für dein Event. Kiosk-Modus, Direktdruck, kreative Sticker und maßgeschneiderte Rahmen vereint in einem atemberaubenden Synthwave-Design.</p>
<p class="hero-lead">Die professionelle, Tablet-optimierte Photobooth-App für dein Event. Kiosk-Modus,
Direktdruck, kreative Sticker und maßgeschneiderte Rahmen vereint in einem atemberaubenden
Synthwave-Design.</p>
<div class="hero-actions">
<a href="features.html" class="btn btn-primary">Features entdecken</a>
<a href="feedback.html" class="btn btn-secondary">Feedback abgeben</a>
@@ -91,7 +98,9 @@
</div>
</div>
<div style="display: flex; justify-content: center; gap: 8px; margin-bottom: 5px;">
<button class="btn btn-secondary" id="demo-btn-reset" style="padding: 6px 12px; font-size: 11px; border-radius: 6px;"><i class="fa-solid fa-rotate-left"></i> Neues Foto aufnehmen</button>
<button class="btn btn-secondary" id="demo-btn-reset"
style="padding: 6px 12px; font-size: 11px; border-radius: 6px;"><i
class="fa-solid fa-rotate-left"></i> Neues Foto aufnehmen</button>
</div>
</div>
</div>
@@ -122,7 +131,9 @@
<label for="admin-password">Admin Passwort</label>
<input type="password" id="admin-password" placeholder="Passwort eingeben" required>
</div>
<div id="admin-login-error" style="color: #ff4466; font-size: 14px; margin-bottom: 15px; display: none;">Falsches Passwort!</div>
<div id="admin-login-error"
style="color: #ff4466; font-size: 14px; margin-bottom: 15px; display: none;">Falsches Passwort!
</div>
<button type="submit" class="btn btn-primary btn-block">Anmelden</button>
</form>
</div>
@@ -140,8 +151,12 @@
<button id="admin-export-btn" class="btn btn-primary">Einträge-Code exportieren</button>
</div>
<div id="export-container" style="display: none; margin-top: 20px;">
<label style="color: var(--text-muted); font-size: 13px; display: block; margin-bottom: 8px;">Kopiere diesen Code und ersetze damit den <code>defaultFeedback</code> Block in <code>app.js</code>, um Änderungen dauerhaft für alle Besucher zu speichern:</label>
<textarea id="export-textarea" rows="6" readonly style="width: 100%; font-family: monospace; font-size: 12px; background: rgba(5,5,16,0.8); border: 1px solid var(--border-color); color: var(--accent-color); padding: 10px; border-radius: 8px;"></textarea>
<label
style="color: var(--text-muted); font-size: 13px; display: block; margin-bottom: 8px;">Kopiere
diesen Code und ersetze damit den <code>defaultFeedback</code> Block in <code>app.js</code>, um
Änderungen dauerhaft für alle Besucher zu speichern:</label>
<textarea id="export-textarea" rows="6" readonly
style="width: 100%; font-family: monospace; font-size: 12px; background: rgba(5,5,16,0.8); border: 1px solid var(--border-color); color: var(--accent-color); padding: 10px; border-radius: 8px;"></textarea>
</div>
</div>
</div>
@@ -149,4 +164,5 @@
<script src="app.js"></script>
</body>
</html>
</html>
+141
View File
@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="de">
<head>
<link rel="icon" type="image/png" href="favicon.png">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schnappix - App Testing Anmeldung</title>
<meta name="description" content="Melde dich als App Tester für Schnappix an und hilf uns, die App für den Release vorzubereiten.">
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Outfit:wght@500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<!-- Header Navigation -->
<header class="main-header">
<div class="container nav-container">
<div class="logo">
<a href="index.html" style="text-decoration: none;"><span class="logo-glow">Schnappix</span></a>
</div>
<nav class="nav-links">
<a href="features.html">Features</a>
<a href="upload.html">Upload</a>
<a href="faq.html">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="tester.html" class="active-nav">App Testing</a>
<a href="#" id="admin-login-trigger"><i class="fa-solid fa-user-gear"></i> Admin</a>
</nav>
<button class="menu-toggle" aria-label="Menu öffnen">
<i class="fa-solid fa-bars"></i>
</button>
</div>
</header>
<!-- Page Title -->
<div style="padding: 140px 0 20px;">
<div class="container">
<h1 class="section-title" style="margin-bottom: 20px;">Werde <span class="text-gradient">App Tester</span></h1>
<p style="text-align: center; color: var(--text-muted); max-width: 600px; margin: 0 auto 40px;">Hilf uns beim finalen Feinschliff! Trage dich als Tester ein und erhalte exklusiven Vorabzugang zu Schnappix im Google Play Store.</p>
</div>
</div>
<!-- Tester Registration Section -->
<section class="feedback-section" style="border-top: none; padding-top: 20px; padding-bottom: 80px;">
<div class="container">
<div class="feedback-grid" style="grid-template-columns: 1fr; max-width: 800px; margin: 0 auto;">
<!-- Info Card -->
<div class="glass" style="padding: 30px; border-radius: 20px; border: 1px solid rgba(139, 92, 246, 0.3); margin-bottom: 30px; background: rgba(5, 5, 16, 0.6);">
<h3 style="color: var(--accent-color); margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
<i class="fa-solid fa-circle-info"></i> Wichtige Hinweise für Tester
</h3>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 15px; color: var(--text-muted);">
<li style="display: flex; gap: 15px; align-items: flex-start;">
<i class="fa-brands fa-google" style="color: #fff; font-size: 20px; margin-top: 2px;"></i>
<div>
<strong style="color: #fff; display: block; margin-bottom: 5px;">GoogleMail-Adresse erforderlich</strong>
Bitte gib unbedingt die GoogleMail (Gmail) Adresse an, welche fest mit deinem Android-Gerät und dem Google Play Store verknüpft ist. Nur so können wir dich für das Testing freischalten.
</div>
</li>
<li style="display: flex; gap: 15px; align-items: flex-start;">
<i class="fa-solid fa-calendar-check" style="color: #fff; font-size: 20px; margin-top: 2px;"></i>
<div>
<strong style="color: #fff; display: block; margin-bottom: 5px;">Mindestens 14 Tage installiert lassen</strong>
Aufgrund der Google Play Console Richtlinien müssen 20 Tester die App für mindestens 14 Tage durchgehend installiert behalten, damit wir die Freigabe für den öffentlichen Store (Production Access) erhalten. Bitte deinstalliere die App in dieser Zeit nicht!
</div>
</li>
</ul>
</div>
<!-- Submit Form -->
<div class="feedback-form-card glass">
<h3 style="margin-bottom: 20px;">Registrierung</h3>
<form id="tester-form">
<div class="form-group">
<label for="tester-email">Deine Google Play Store E-Mail Adresse</label>
<input type="email" id="tester-email" placeholder="deine.adresse@gmail.com" required>
</div>
<div class="form-group" style="display: flex; gap: 10px; align-items: flex-start;">
<input type="checkbox" id="tester-consent" required style="margin-top: 5px; accent-color: var(--accent-color);">
<label for="tester-consent" style="font-size: 13px; font-weight: normal; margin-bottom: 0;">Ich habe die Hinweise gelesen und werde die App nach der Installation für mindestens 14 Tage nicht deinstallieren.</label>
</div>
<div id="tester-success-msg" style="display: none; background: rgba(34, 197, 94, 0.2); color: #4ade80; padding: 15px; border-radius: 8px; margin-bottom: 20px; text-align: center; border: 1px solid rgba(74, 222, 128, 0.3);">
<i class="fa-solid fa-check-circle"></i> Erfolgreich eingereicht! Nach erfolgreicher Prüfung senden wir dir eine Mail mit den Registrierungslinks.
</div>
<div id="tester-error-msg" style="display: none; background: rgba(2ef, 68, 102, 0.2); color: #ff4466; padding: 15px; border-radius: 8px; margin-bottom: 20px; text-align: center; border: 1px solid rgba(255, 68, 102, 0.3);">
<i class="fa-solid fa-triangle-exclamation"></i> Es gab einen Fehler. Bitte versuche es später noch einmal.
</div>
<button type="submit" class="btn btn-primary btn-block" id="tester-submit-btn">Als Tester anmelden</button>
</form>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="main-footer">
<div class="container footer-container">
<p>&copy; 2026 Schnappix. Alle Rechte vorbehalten.</p>
<div class="footer-links">
<a href="impressum.html">Impressum</a>
<a href="datenschutz.html">Datenschutzerklärung</a>
</div>
</div>
</footer>
<!-- Admin Login Modal -->
<div id="admin-login-modal" class="modal-overlay">
<div class="modal-content glass">
<button class="modal-close" aria-label="Schließen">&times;</button>
<h2>Admin Login</h2>
<form id="admin-login-form">
<div class="form-group">
<label for="admin-password">Admin Passwort</label>
<input type="password" id="admin-password" placeholder="Passwort eingeben" required>
</div>
<div id="admin-login-error" style="color: #ff4466; font-size: 14px; margin-bottom: 15px; display: none;">Falsches Passwort!</div>
<button type="submit" class="btn btn-primary btn-block">Anmelden</button>
</form>
</div>
</div>
<!-- Admin Panel Control Modal (If Logged In) -->
<div id="admin-panel-modal" class="modal-overlay">
<div class="modal-content glass">
<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.</p>
<div style="margin-top: 20px; display: flex; gap: 15px;">
<button id="admin-logout-btn" class="btn btn-secondary">Abmelden</button>
</div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<?php
error_reporting(0);
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$inputJSON = file_get_contents('php://input');
$input = json_decode($inputJSON, true);
if (!$input || empty($input['email'])) {
http_response_code(400);
echo json_encode(['error' => 'Ungültige Eingabe']);
exit;
}
$email = filter_var($input['email'], FILTER_SANITIZE_EMAIL);
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo json_encode(['error' => 'Ungültige E-Mail Adresse']);
exit;
}
// Send notification to Ntfy
$ntfyUrl = 'https://ntfy.orfel.de/Schnappix-App-Testing';
$message = "E-Mail: $email";
$ch = curl_init($ntfyUrl);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $message);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Title: Neue Tester-Anmeldung',
'Tags: calling,bust_in_silhouette'
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_exec($ch);
curl_close($ch);
echo json_encode(['success' => true]);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
+38 -3
View File
@@ -159,8 +159,8 @@
<a href="upload.html" class="active-nav">Upload</a>
<a href="faq.html">FAQ</a>
<a href="feedback.html">Feedback Hub</a>
<a href="tester.html">App Testing</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>
@@ -173,7 +173,7 @@
<div class="container">
<div class="upload-card glass">
<h1 style="font-family: var(--font-titles); font-size: 32px; margin-bottom: 10px;">Hintergrund <span class="text-gradient">Upload</span></h1>
<p style="color: var(--text-muted); font-size: 15px;">Lade dein eigenes PNG hoch. Es wird uns per E-Mail gesendet und wir binden es in deine App ein.</p>
<p style="color: var(--text-muted); font-size: 15px;">Lade dein eigenes PNG hoch. Nach erfolgreicher Prüfung wird dein Rahmen in die App eingebaut und ist dort für alle Nutzer sichtbar.</p>
<!-- FormSubmit.co is used to send the email directly with attachment without a backend -->
<form id="bg-upload-form">
@@ -185,6 +185,12 @@
<input type="file" id="file-input" name="background_image" accept="image/png" required>
</div>
<div class="form-group" style="text-align: left; display: none;" id="frame-info">
<label for="frame-name">Name des Rahmens (nur Kleinbuchstaben & Zahlen)</label>
<input type="text" id="frame-name" name="frame_name" pattern="[a-z0-9_]+" placeholder="z.B. summer1" required>
<div class="upload-hint" style="margin-top: 5px;">Wird gespeichert als: <strong style="color: var(--primary-color);">frame_<span id="frame-preview-name">...</span>.png</strong></div>
</div>
<div class="form-group" style="text-align: left; display: none;" id="customer-info">
<label for="customer-email">Deine E-Mail Adresse (für Rückfragen)</label>
<input type="email" id="customer-email" name="email" placeholder="mail@example.com" required>
@@ -246,8 +252,17 @@
const previewBox = document.getElementById('preview-box');
const imagePreview = document.getElementById('image-preview');
const submitBtn = document.getElementById('submit-btn');
const frameInfo = document.getElementById('frame-info');
const frameNameInput = document.getElementById('frame-name');
const framePreviewName = document.getElementById('frame-preview-name');
const customerInfo = document.getElementById('customer-info');
frameNameInput.addEventListener('input', (e) => {
const val = e.target.value.trim().toLowerCase().replace(/[^a-z0-9_]/g, '');
e.target.value = val;
framePreviewName.textContent = val || '...';
});
// Drag and Drop Effects
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
@@ -296,6 +311,7 @@
validationBox.style.display = 'block';
previewBox.style.display = 'none';
submitBtn.style.display = 'none';
frameInfo.style.display = 'none';
customerInfo.style.display = 'none';
valFormat.innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i> Überprüfe Dateiformat...`;
@@ -328,6 +344,7 @@
// Show Preview and Submit Button
imagePreview.src = e.target.result;
previewBox.style.display = 'block';
frameInfo.style.display = 'block';
customerInfo.style.display = 'block';
submitBtn.style.display = 'block';
} else {
@@ -357,19 +374,37 @@
body: formData
});
const result = await response.json();
const responseText = await response.text();
let result;
try {
result = JSON.parse(responseText);
} catch (e) {
console.error('Server antwortete nicht mit gültigem JSON:', responseText);
alert('Ein Server-Fehler ist aufgetreten (siehe Konsole). Das Bild wurde eventuell trotzdem hochgeladen.');
form.reset();
framePreviewName.textContent = '...';
previewBox.style.display = 'none';
submitBtn.style.display = 'none';
frameInfo.style.display = 'none';
customerInfo.style.display = 'none';
validationBox.style.display = 'none';
return;
}
if (response.ok) {
alert('Erfolgreich hochgeladen!');
form.reset();
framePreviewName.textContent = '...';
previewBox.style.display = 'none';
submitBtn.style.display = 'none';
frameInfo.style.display = 'none';
customerInfo.style.display = 'none';
validationBox.style.display = 'none';
} else {
alert('Fehler: ' + (result.error || 'Unbekannter Fehler'));
}
} catch (error) {
console.error('Upload Error:', error);
alert('Upload fehlgeschlagen. Bitte prüfe deine Verbindung.');
} finally {
submitBtn.innerHTML = originalBtnText;
+43 -4
View File
@@ -1,4 +1,5 @@
<?php
error_reporting(0);
header('Content-Type: application/json');
// Check if request is POST
@@ -35,10 +36,48 @@ if ($mimeType !== 'image/png') {
exit;
}
// Generate unique filename
$filename = uniqid('frame_') . '_' . time() . '.png';
$rawFrameName = isset($_POST['frame_name']) ? trim($_POST['frame_name']) : '';
$frameName = preg_replace('/[^a-z0-9_]/', '', strtolower($rawFrameName));
if (empty($frameName)) {
http_response_code(400);
echo json_encode(['error' => 'Ungültiger oder fehlender Frame-Name.']);
exit;
}
$filename = 'frame_' . $frameName . '.png';
$destination = $uploadDir . $filename;
// Check local uniqueness (already uploaded via web)
if (file_exists($destination)) {
http_response_code(400);
echo json_encode(['error' => 'Ein Rahmen mit diesem Namen existiert bereits auf dem Server. Bitte wähle einen anderen.']);
exit;
}
// Check Gitea uniqueness
$giteaUrl = 'https://git.orfel.de/api/v1/repos/Jannik/Schnappix/contents/assets/frames';
$chGitea = curl_init($giteaUrl);
curl_setopt($chGitea, CURLOPT_RETURNTRANSFER, true);
curl_setopt($chGitea, CURLOPT_TIMEOUT, 5);
curl_setopt($chGitea, CURLOPT_HTTPHEADER, ['User-Agent: Schnappix-Web Backend']);
$giteaResponse = curl_exec($chGitea);
$giteaCode = curl_getinfo($chGitea, CURLINFO_HTTP_CODE);
curl_close($chGitea);
if ($giteaCode === 200 && $giteaResponse) {
$framesData = json_decode($giteaResponse, true);
if (is_array($framesData)) {
foreach ($framesData as $frameFile) {
if (isset($frameFile['name']) && strtolower($frameFile['name']) === strtolower($filename)) {
http_response_code(400);
echo json_encode(['error' => 'Ein Rahmen mit diesem Namen existiert bereits in der App. Bitte wähle einen anderen.']);
exit;
}
}
}
}
// Move uploaded file
if (move_uploaded_file($file['tmp_name'], $destination)) {
// Build public URL
@@ -49,14 +88,14 @@ if (move_uploaded_file($file['tmp_name'], $destination)) {
// Send notification to Ntfy
$ntfyUrl = 'https://ntfy.orfel.de/Schnappix-Frame-Upload';
$message = "Ein neuer Rahmen wurde hochgeladen!\n\nVon: $email\nBild-URL: $publicUrl";
$message = "Name: $filename\nVon: $email\nBild-URL: $publicUrl";
$ch = curl_init($ntfyUrl);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $message);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Title: Neuer Schnappix Hintergrund',
'Tags: frame_with_picture,tada',
'Tags: framed_picture,tada',
'Click: ' . $publicUrl,
'Attach: ' . $publicUrl
]);