PummelHummel/Alph in 3D-Druck-AG integriert

Quelle: D:/Claude-Projekte/PummelHummel/feedback_modul/ (Standard-Pattern,
auch in arduino.lehrstun.de + meta.lehrstun.de erprobt). 1:1 nach
3d.lehrstun.de/feedback_modul/ kopiert.

app.py erweitert:
- init_feedback_modul-Aufruf mit unseren Adaptern
- _alph_user_kontext: Lehrer = admin, SuS = profil mit Vorname+Tier,
  Anonym = kein Widget
- _alph_widget_aktiv: liest einstellung.feedback_widget_aktiv
- _alph_admin_unauthorized: Redirect zu admin.admin_login

Hart-Aus via ENV ALPH_DEAKTIVIERT=1 weiterhin moeglich.
Soft-Aus via einstellung.feedback_widget_aktiv='0' (per Admin-Inline-Edit).

DB:
- feedback-Tabelle via feedback_modul.schema.DDL angelegt (idempotent)
- einstellung.feedback_widget_aktiv auf '1' gesetzt (Alpha-Test-Modus)

Test:
- Eingeloggter SuS sieht das Alph-Widget auf /lektion/1
- Anonym (kein Login) sieht es nicht (user_kontext gibt None)
- Admin sieht es mit Rolle 'admin'

Naechster Schritt: Deploy auf Hetzner (3d.lehrstun.de), Markus loggt
sich als Test-Schueler ein und gibt Feedback zu den 14 Lektionen.
This commit is contained in:
2026-05-25 15:29:39 +02:00
parent 046fd56018
commit 66c52cc8b1
7 changed files with 1674 additions and 1 deletions

View File

@@ -0,0 +1,444 @@
/* Alph der PummelHummel — Alphatest-Feedback-Widget
* ----------------------------------------------------
* Self-contained: HTML + CSS + SVG + Logik in dieser einen Datei.
* Einbindung:
*
* <script src=".../alph.js"
* data-endpoint="/api/feedback"
* data-app="meine-app"
* data-user='{"tier":"otter","kasten":7}'
* defer></script>
*
* Liest config aus data-Attributen vom <script>-Tag:
* data-endpoint POST-URL fuer Feedback (Pflicht)
* data-app Identifier der App (Pflicht, z.B. "arduino-werkstatt")
* data-user JSON-String mit user_kontext (optional, Server kann auch selbst setzen)
* data-position "bottom-right" (Default) | "bottom-left"
*
* Schickt JSON mit:
* { kategorie, text, url_pfad, seitentitel, viewport, sprache,
* element_selector?, element_text? }
*/
(function () {
'use strict';
if (window.__alphMounted) return;
window.__alphMounted = true;
var script = document.currentScript ||
(function () {
var all = document.getElementsByTagName('script');
return all[all.length - 1];
})();
var ENDPOINT = script.getAttribute('data-endpoint') || '/api/feedback';
var APP_NAME = script.getAttribute('data-app') || 'unknown';
var POSITION = script.getAttribute('data-position') || 'bottom-right';
var USER_RAW = script.getAttribute('data-user') || '';
var USER_KONTEXT = null;
if (USER_RAW) {
try { USER_KONTEXT = JSON.parse(USER_RAW); }
catch (e) { USER_KONTEXT = null; }
}
// Cookie-Helper fuer "diese Stunde verstecken"
function getCookie(name) {
var m = document.cookie.match(new RegExp('(^|; )' + name + '=([^;]*)'));
return m ? decodeURIComponent(m[2]) : null;
}
function setCookie(name, value, hours) {
var d = new Date();
d.setTime(d.getTime() + hours * 60 * 60 * 1000);
document.cookie = name + '=' + encodeURIComponent(value) +
';expires=' + d.toUTCString() + ';path=/;SameSite=Lax';
}
if (getCookie('alph_versteckt') === '1') return;
// === CSS injizieren ============================================
var css = `
.alph-host { all: initial; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.alph-host * { box-sizing: border-box; }
.alph-fab {
position: fixed;
${POSITION === 'bottom-left' ? 'left: 1.5rem' : 'right: 1.5rem'};
bottom: 1.5rem;
width: 110px; height: 110px;
z-index: 99998;
cursor: pointer;
user-select: none;
animation: alph-float 6s ease-in-out infinite;
transition: transform 0.15s;
filter: drop-shadow(0 8px 14px rgba(0,0,0,0.28));
}
.alph-fab:hover { transform: scale(1.15) rotate(-6deg); animation-play-state: paused; }
.alph-fab:active { transform: scale(0.95); }
.alph-fab svg { width: 100%; height: 100%; display: block; overflow: visible; }
.alph-wing { transform-origin: 50% 50%; animation: alph-wing 0.13s ease-in-out infinite alternate; }
.alph-wing-r { animation-delay: 0.04s; }
.alph-antenna { transform-origin: 50% 100%; animation: alph-antenna 4s ease-in-out infinite; }
.alph-tooltip {
position: absolute;
bottom: 120px;
${POSITION === 'bottom-left' ? 'left: 0' : 'right: 0'};
background: #2C3E50; color: white;
padding: 0.5rem 0.9rem; border-radius: 8px;
font-size: 0.92rem; font-weight: 500; white-space: nowrap;
opacity: 0; pointer-events: none;
transition: opacity 0.15s;
box-shadow: 0 4px 10px rgba(0,0,0,0.2);
}
.alph-fab:hover .alph-tooltip { opacity: 1; }
.alph-close {
position: absolute;
top: 2px; right: 2px;
width: 26px; height: 26px; border-radius: 50%;
background: #E74C3C; color: white;
font-size: 18px; font-weight: bold; line-height: 24px;
text-align: center;
cursor: pointer; opacity: 0;
transition: opacity 0.15s;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.alph-fab:hover .alph-close { opacity: 1; }
.alph-fab.alph-shake { animation: alph-shake 0.5s ease-in-out !important; }
/* Eine einzige 6-Sekunden-Schleife: ruhiges Schweben + bei 70-92% ein Hop */
@keyframes alph-float {
0% { transform: translateY(0) translateX(0) rotate(-2deg); }
10% { transform: translateY(-8px) translateX(2px) rotate(0deg); }
20% { transform: translateY(-14px) translateX(0) rotate(2deg); }
30% { transform: translateY(-8px) translateX(-2px) rotate(0deg); }
40% { transform: translateY(0) translateX(0) rotate(-2deg); }
55% { transform: translateY(-10px) translateX(2px) rotate(1deg); }
65% { transform: translateY(-4px) translateX(0) rotate(-1deg); }
/* Hop! Aufmerksamkeitsmacher */
72% { transform: translateY(-26px) rotate(-9deg) scale(1.08); }
78% { transform: translateY(-6px) rotate(7deg) scale(1.02); }
84% { transform: translateY(-20px) rotate(-4deg) scale(1.05); }
90% { transform: translateY(-4px) rotate(2deg) scale(1); }
100% { transform: translateY(0) translateX(0) rotate(-2deg); }
}
@keyframes alph-wing {
0% { transform: scaleY(1) rotate(0deg); }
100% { transform: scaleY(0.35) rotate(14deg); }
}
@keyframes alph-shake {
0%, 100% { transform: translateX(0) rotate(0) scale(1); }
20% { transform: translateX(-6px) rotate(-6deg) scale(1.06); }
40% { transform: translateX(6px) rotate(5deg) scale(1.10); }
60% { transform: translateX(-4px) rotate(-3deg) scale(1.05); }
80% { transform: translateX(4px) rotate(2deg) scale(1.02); }
}
@keyframes alph-antenna {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(4deg); }
}
.alph-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.45);
z-index: 99999;
display: flex; align-items: center; justify-content: center;
animation: alph-fade-in 0.2s ease-out;
}
.alph-modal {
background: white; color: #2C3E50;
width: min(440px, 92vw); max-height: 92vh; overflow-y: auto;
border-radius: 12px; padding: 1.4rem;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
animation: alph-pop-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.alph-modal h2 {
font-size: 1.15rem; margin: 0 0 0.4rem 0;
color: #2C3E50; display: flex; align-items: center; gap: 0.5rem;
}
.alph-modal p.alph-sub {
color: #7F8C8D; font-size: 0.88rem; margin: 0 0 1rem 0;
}
.alph-kat-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;
margin-bottom: 0.9rem;
}
.alph-kat-btn {
background: #F8F9FA; border: 2px solid #DEE2E6;
border-radius: 8px; padding: 0.7rem 0.6rem;
text-align: left; cursor: pointer;
font-size: 0.88rem; color: #2C3E50;
transition: all 0.15s; font-family: inherit;
}
.alph-kat-btn:hover { border-color: #00979D; background: #EBF8F9; }
.alph-kat-btn.alph-active {
border-color: #00979D; background: #00979D; color: white;
}
.alph-kat-btn .alph-emoji { font-size: 1.1rem; margin-right: 0.3rem; }
.alph-kat-btn small { display: block; opacity: 0.75; font-size: 0.74rem; margin-top: 0.15rem; }
.alph-modal textarea {
width: 100%; padding: 0.7rem; min-height: 100px;
border: 1px solid #DEE2E6; border-radius: 6px;
font-family: inherit; font-size: 0.95rem; resize: vertical;
color: #2C3E50;
}
.alph-modal textarea:focus { outline: none; border-color: #00979D; box-shadow: 0 0 0 3px rgba(0,151,157,0.15); }
.alph-context {
font-size: 0.78rem; color: #7F8C8D;
background: #F8F9FA; padding: 0.5rem 0.7rem; border-radius: 4px;
margin: 0.6rem 0; font-family: 'Fira Code', Consolas, monospace;
}
.alph-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
.alph-btn {
padding: 0.55rem 1.2rem; border-radius: 6px; border: none;
cursor: pointer; font-size: 0.92rem; font-family: inherit;
transition: background 0.15s;
}
.alph-btn-primary { background: #00979D; color: white; }
.alph-btn-primary:hover { background: #007A80; }
.alph-btn-primary:disabled { background: #B5C4C5; cursor: not-allowed; }
.alph-btn-secondary { background: #ECF0F1; color: #2C3E50; }
.alph-btn-secondary:hover { background: #D5DBDB; }
.alph-error {
background: #FADBD8; color: #943126;
padding: 0.5rem 0.8rem; border-radius: 4px; font-size: 0.88rem;
margin-top: 0.5rem;
}
.alph-success {
background: #D5F5E3; color: #1E8449;
padding: 1rem; border-radius: 6px; text-align: center;
}
.alph-success .alph-success-icon { font-size: 2rem; display: block; margin-bottom: 0.4rem; }
@keyframes alph-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes alph-pop-in {
from { opacity: 0; transform: scale(0.85) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@media (max-width: 600px) {
.alph-fab { width: 88px; height: 88px; bottom: 1rem; right: 1rem; }
.alph-tooltip { bottom: 96px; font-size: 0.84rem; }
.alph-kat-grid { grid-template-columns: 1fr; }
}
/* Reduzierte Bewegungs-Praeferenz respektieren (Barrierefreiheit) */
@media (prefers-reduced-motion: reduce) {
.alph-fab { animation: none; }
.alph-wing, .alph-antenna { animation: none; }
}
`;
var styleEl = document.createElement('style');
styleEl.textContent = css;
document.head.appendChild(styleEl);
// === Hummel-SVG ================================================
var hummelSVG = `
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-label="Alph der PummelHummel">
<!-- Linker Fluegel -->
<ellipse class="alph-wing alph-wing-l" cx="32" cy="35" rx="14" ry="22"
fill="#E8F4FD" fill-opacity="0.85" stroke="#B0C4DE" stroke-width="1"/>
<!-- Rechter Fluegel -->
<ellipse class="alph-wing alph-wing-r" cx="68" cy="35" rx="14" ry="22"
fill="#E8F4FD" fill-opacity="0.85" stroke="#B0C4DE" stroke-width="1"/>
<!-- Body (Pummel) -->
<ellipse cx="50" cy="58" rx="28" ry="26" fill="#FFCC33" stroke="#2C3E50" stroke-width="2"/>
<!-- Streifen -->
<path d="M 26 50 Q 50 46 74 50" stroke="#2C3E50" stroke-width="5" fill="none" stroke-linecap="round"/>
<path d="M 28 66 Q 50 70 72 66" stroke="#2C3E50" stroke-width="5" fill="none" stroke-linecap="round"/>
<!-- T-Shirt mit Alpha -->
<rect x="38" y="54" width="24" height="14" rx="3" fill="white" stroke="#2C3E50" stroke-width="1.2"/>
<text x="50" y="65" font-family="Georgia, serif" font-size="11" font-weight="bold"
text-anchor="middle" fill="#00979D">α</text>
<!-- Kopf -->
<circle cx="50" cy="32" r="13" fill="#2C3E50"/>
<!-- Augen -->
<circle cx="46" cy="30" r="2.5" fill="white"/>
<circle cx="54" cy="30" r="2.5" fill="white"/>
<circle cx="46" cy="30.5" r="1.2" fill="#2C3E50"/>
<circle cx="54" cy="30.5" r="1.2" fill="#2C3E50"/>
<!-- Antennen (wackeln) -->
<g class="alph-antenna">
<path d="M 44 22 Q 41 14 38 12" stroke="#2C3E50" stroke-width="1.8" fill="none" stroke-linecap="round"/>
<path d="M 56 22 Q 59 14 62 12" stroke="#2C3E50" stroke-width="1.8" fill="none" stroke-linecap="round"/>
<circle cx="38" cy="12" r="2" fill="#FFCC33" stroke="#2C3E50" stroke-width="1"/>
<circle cx="62" cy="12" r="2" fill="#FFCC33" stroke="#2C3E50" stroke-width="1"/>
</g>
<!-- Mund -->
<path d="M 47 36 Q 50 38 53 36" stroke="white" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<!-- Stinger -->
<path d="M 50 84 L 47 92 L 53 92 Z" fill="#2C3E50"/>
</svg>
`;
// === DOM aufbauen =============================================
var host = document.createElement('div');
host.className = 'alph-host';
host.innerHTML = `
<div class="alph-fab" id="alph-fab" role="button" aria-label="Feedback geben — Alph der PummelHummel" tabindex="0">
<span class="alph-close" id="alph-close" title="Heute ausblenden">×</span>
${hummelSVG}
<span class="alph-tooltip">Hier was kaputt? Sag's Alph!</span>
</div>
`;
document.body.appendChild(host);
var fab = host.querySelector('#alph-fab');
var closeBtn = host.querySelector('#alph-close');
closeBtn.addEventListener('click', function (e) {
e.stopPropagation();
setCookie('alph_versteckt', '1', 4); // 4 Stunden weg
host.remove();
});
fab.addEventListener('click', oeffneModal);
fab.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oeffneModal(); }
});
// === Modal-Logik ==============================================
var KATEGORIEN = [
{ id: 'code', emoji: '🐛', label: 'Codefehler', hint: 'Sketch tut nicht, Compiler-Error' },
{ id: 'link', emoji: '🔗', label: 'Link kaputt', hint: 'Knopf fuehrt ins Leere' },
{ id: 'verstehen', emoji: '❓', label: 'Versteh ich nicht', hint: 'Erklaerung unklar' },
{ id: 'fehlt', emoji: '❌', label: 'Etwas fehlt', hint: 'Info / Bauteil / Schritt' },
{ id: 'sonstiges', emoji: '💬', label: 'Sonstiges', hint: 'Idee, Lob, anderes' },
];
var modalEl = null;
var ausgewaehlteKat = null;
function oeffneModal() {
fab.classList.add('alph-shake');
setTimeout(function () { fab.classList.remove('alph-shake'); }, 400);
if (modalEl) return;
ausgewaehlteKat = null;
modalEl = document.createElement('div');
modalEl.className = 'alph-overlay';
var katBtns = KATEGORIEN.map(function (k) {
return `<button type="button" class="alph-kat-btn" data-kat="${k.id}">
<span class="alph-emoji">${k.emoji}</span>${k.label}
<small>${k.hint}</small>
</button>`;
}).join('');
modalEl.innerHTML = `
<div class="alph-modal" role="dialog" aria-label="Feedback geben">
<h2>🐝 Alph hört zu</h2>
<p class="alph-sub">Was ist dir aufgefallen? Wähl eine Kategorie:</p>
<div class="alph-kat-grid">${katBtns}</div>
<textarea id="alph-text" placeholder="Beschreib's kurz — was hast du gemacht, was ist passiert?"></textarea>
<div class="alph-context" id="alph-ctx"></div>
<div id="alph-msg"></div>
<div class="alph-actions">
<button type="button" class="alph-btn alph-btn-secondary" id="alph-cancel">Abbrechen</button>
<button type="button" class="alph-btn alph-btn-primary" id="alph-send" disabled>Absenden</button>
</div>
</div>
`;
host.appendChild(modalEl);
modalEl.querySelector('#alph-ctx').textContent =
'Seite: ' + window.location.pathname + ' · Titel: ' + (document.title || '—');
modalEl.querySelectorAll('.alph-kat-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
modalEl.querySelectorAll('.alph-kat-btn').forEach(function (b) {
b.classList.remove('alph-active');
});
btn.classList.add('alph-active');
ausgewaehlteKat = btn.getAttribute('data-kat');
pruefeAbsendbar();
});
});
modalEl.querySelector('#alph-text').addEventListener('input', pruefeAbsendbar);
modalEl.querySelector('#alph-cancel').addEventListener('click', schliesseModal);
modalEl.querySelector('#alph-send').addEventListener('click', sende);
modalEl.addEventListener('click', function (e) {
if (e.target === modalEl) schliesseModal();
});
document.addEventListener('keydown', escListener);
}
function escListener(e) {
if (e.key === 'Escape') schliesseModal();
}
function schliesseModal() {
if (!modalEl) return;
modalEl.remove();
modalEl = null;
document.removeEventListener('keydown', escListener);
}
function pruefeAbsendbar() {
if (!modalEl) return;
var text = modalEl.querySelector('#alph-text').value.trim();
modalEl.querySelector('#alph-send').disabled = !(ausgewaehlteKat && text.length >= 3);
}
function sende() {
if (!modalEl || !ausgewaehlteKat) return;
var sendBtn = modalEl.querySelector('#alph-send');
var msgEl = modalEl.querySelector('#alph-msg');
var text = modalEl.querySelector('#alph-text').value.trim();
sendBtn.disabled = true;
sendBtn.textContent = 'Sende...';
msgEl.innerHTML = '';
var payload = {
app: APP_NAME,
kategorie: ausgewaehlteKat,
text: text,
url_pfad: window.location.pathname + window.location.search,
seitentitel: document.title || '',
viewport: window.innerWidth + 'x' + window.innerHeight,
sprache: navigator.language || ''
};
// user_kontext nur senden, wenn vom Server via data-user mitgegeben
// (Drop-In-Modus). Bei Flask-Blueprint-Integration wird er sowieso
// serverseitig gesetzt und ueberschrieben.
if (USER_KONTEXT) {
payload.user_kontext = USER_KONTEXT;
}
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'same-origin'
}).then(function (r) {
return r.json().then(function (j) { return { ok: r.ok, body: j }; });
}).then(function (res) {
if (res.ok && res.body && res.body.ok) {
erfolgZeigen();
} else {
var err = (res.body && res.body.error) || 'Unbekannter Fehler';
if (err === 'rate_limit') err = 'Zu viele Reports — kurz warten.';
if (err === 'text_zu_kurz') err = 'Text muss mindestens 3 Zeichen lang sein.';
msgEl.innerHTML = '<div class="alph-error">' + err + '</div>';
sendBtn.disabled = false;
sendBtn.textContent = 'Absenden';
}
}).catch(function () {
msgEl.innerHTML = '<div class="alph-error">Netzwerkfehler. Versuch nochmal.</div>';
sendBtn.disabled = false;
sendBtn.textContent = 'Absenden';
});
}
function erfolgZeigen() {
if (!modalEl) return;
var modal = modalEl.querySelector('.alph-modal');
modal.innerHTML = `
<div class="alph-success">
<span class="alph-success-icon">🐝</span>
<strong>Danke!</strong><br>
Alph hat's notiert.
</div>
`;
setTimeout(schliesseModal, 1500);
}
// Klick-Capture Mode (optional, fuer "Codefehler" und "Link kaputt")
// — bewusst nicht implementiert in Erstversion; Element-Selector wird
// im Backend leergelassen. Erweiterbar ueber Page-Click-Handler.
})();