Files
3d.lehrstun.de/feedback_modul/static/alph.js
Hans Puettmann 66c52cc8b1 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.
2026-05-25 15:29:39 +02:00

445 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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.
})();