470 lines
16 KiB
HTML
470 lines
16 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Misty Rhetorik-Coach</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
:root {
|
||
--bg: #f0f4ff;
|
||
--white: #ffffff;
|
||
--blue: #1a56db;
|
||
--blue-light: #dbeafe;
|
||
--blue-mid: #93c5fd;
|
||
--dark: #1e2d5a;
|
||
--muted: #6b7db3;
|
||
--green: #059669;
|
||
--green-light: #d1fae5;
|
||
--amber: #d97706;
|
||
--amber-light: #fef3c7;
|
||
--red: #dc2626;
|
||
--red-light: #fee2e2;
|
||
--border: #e2e8f0;
|
||
}
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--dark);
|
||
font-family: 'Nunito', sans-serif;
|
||
min-height: 100vh;
|
||
padding: 28px;
|
||
}
|
||
header {
|
||
background: var(--white);
|
||
border-radius: 20px;
|
||
padding: 20px 28px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 24px;
|
||
box-shadow: 0 2px 12px rgba(37,99,235,0.08);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.brand { display: flex; align-items: center; gap: 14px; }
|
||
.brand-avatar {
|
||
width: 48px; height: 48px;
|
||
border-radius: 14px;
|
||
background: linear-gradient(135deg, var(--blue), #3b82f6);
|
||
display: grid; place-items: center;
|
||
font-size: 24px;
|
||
box-shadow: 0 4px 12px rgba(37,99,235,0.3);
|
||
}
|
||
.brand-name { font-weight: 900; font-size: 20px; letter-spacing: -0.5px; }
|
||
.brand-sub { font-size: 12px; color: var(--muted); margin-top: 2px; font-weight: 500; }
|
||
.status-pill {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 10px 20px; border-radius: 100px;
|
||
font-size: 13px; font-weight: 700;
|
||
}
|
||
.status-dot {
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
animation: blink 1.5s ease-in-out infinite;
|
||
}
|
||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
||
.grid-top {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
gap: 20px; margin-bottom: 20px;
|
||
}
|
||
.grid-bottom {
|
||
display: grid;
|
||
grid-template-columns: 2fr 1fr;
|
||
gap: 20px; margin-bottom: 20px;
|
||
}
|
||
.card {
|
||
background: var(--white);
|
||
border-radius: 20px; padding: 24px;
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 2px 12px rgba(37,99,235,0.06);
|
||
}
|
||
.card-blue {
|
||
background: linear-gradient(135deg, var(--blue) 0%, #3b82f6 100%);
|
||
color: white; border: none;
|
||
box-shadow: 0 8px 24px rgba(37,99,235,0.3);
|
||
position: relative; overflow: hidden;
|
||
}
|
||
.dots {
|
||
position: absolute; top: 16px; right: 16px;
|
||
opacity: 0.15; font-size: 20px;
|
||
letter-spacing: 3px; color: white;
|
||
}
|
||
.card-label {
|
||
font-size: 11px; font-weight: 700;
|
||
letter-spacing: 1.5px; text-transform: uppercase;
|
||
color: var(--muted); margin-bottom: 14px;
|
||
}
|
||
.card-blue .card-label { color: rgba(255,255,255,0.7); }
|
||
.metric-num {
|
||
font-size: 56px; font-weight: 900;
|
||
line-height: 1; letter-spacing: -2px;
|
||
}
|
||
.metric-unit { font-size: 13px; color: var(--muted); margin-top: 4px; font-weight: 600; }
|
||
.card-blue .metric-unit { color: rgba(255,255,255,0.7); }
|
||
.badge {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
padding: 5px 12px; border-radius: 100px;
|
||
font-size: 12px; font-weight: 700; margin-top: 12px;
|
||
}
|
||
.badge-green { background: var(--green-light); color: var(--green); }
|
||
.badge-amber { background: var(--amber-light); color: var(--amber); }
|
||
.badge-red { background: var(--red-light); color: var(--red); }
|
||
.badge-blue { background: rgba(255,255,255,0.2); color: white; }
|
||
.gauge-wrap { margin-top: 16px; }
|
||
.gauge-track {
|
||
height: 8px; border-radius: 4px;
|
||
background: var(--blue-light);
|
||
position: relative; overflow: visible;
|
||
}
|
||
.gz { position: absolute; height: 100%; border-radius: 4px; }
|
||
.gauge-knob {
|
||
position: absolute; top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: 18px; height: 18px;
|
||
background: white; border-radius: 50%;
|
||
border: 3px solid var(--blue);
|
||
box-shadow: 0 2px 8px rgba(37,99,235,0.4);
|
||
}
|
||
.gauge-labels {
|
||
display: flex; justify-content: space-between;
|
||
margin-top: 8px; font-size: 10px;
|
||
color: var(--muted); font-weight: 600;
|
||
}
|
||
.face-card {
|
||
display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center;
|
||
min-height: 140px; text-align: center;
|
||
}
|
||
.face-circle {
|
||
width: 120px; height: 80px; border-radius: 12px;
|
||
background: var(--blue-light);
|
||
display: grid; place-items: center;
|
||
margin-bottom: 10px;
|
||
border: 3px solid var(--blue-mid);
|
||
overflow: hidden;
|
||
animation: float 3s ease-in-out infinite;
|
||
}
|
||
@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-5px)} }
|
||
.face-name {
|
||
font-size: 11px; color: var(--muted); font-weight: 600;
|
||
background: var(--blue-light); padding: 4px 10px; border-radius: 6px;
|
||
}
|
||
.transcript-box {
|
||
background: var(--bg); border-radius: 12px;
|
||
padding: 16px 18px; font-size: 14px;
|
||
line-height: 1.8; color: #475569;
|
||
border: 1px solid var(--border); font-weight: 500;
|
||
}
|
||
.tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
|
||
.tag {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
background: var(--amber-light); border: 1px solid #fde68a;
|
||
color: var(--amber); padding: 6px 12px;
|
||
border-radius: 8px; font-size: 12px; font-weight: 700;
|
||
}
|
||
.tag-n {
|
||
background: #fcd34d; color: #92400e;
|
||
border-radius: 4px; padding: 1px 6px; font-size: 11px;
|
||
}
|
||
.feedback-body {
|
||
font-size: 14px; line-height: 1.7;
|
||
color: #475569; font-weight: 500;
|
||
}
|
||
.feedback-icon { font-size: 32px; margin-bottom: 10px; }
|
||
.verlauf-row {
|
||
display: flex; align-items: center; gap: 14px;
|
||
padding: 12px 0; border-bottom: 1px solid var(--border);
|
||
}
|
||
.verlauf-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||
.v-num { font-size: 12px; font-weight: 800; color: var(--muted); width: 30px; flex-shrink: 0; }
|
||
.v-bar { flex: 1; height: 8px; background: var(--blue-light); border-radius: 4px; overflow: hidden; }
|
||
.v-fill { height: 100%; border-radius: 4px; }
|
||
.v-meta {
|
||
display: flex; align-items: center; gap: 10px;
|
||
font-size: 12px; font-weight: 600; color: var(--muted);
|
||
flex-shrink: 0; min-width: 190px; justify-content: flex-end;
|
||
}
|
||
.v-face { width: 40px; height: 28px; border-radius: 6px; object-fit: cover; }
|
||
.c-green { color: var(--green); }
|
||
.c-amber { color: var(--amber); }
|
||
.c-red { color: var(--red); }
|
||
.c-blue { color: var(--blue); }
|
||
.empty-state {
|
||
text-align: center; padding: 40px;
|
||
color: var(--muted); font-size: 15px; font-weight: 600;
|
||
line-height: 1.8;
|
||
}
|
||
.fuellwort-card {
|
||
background: var(--white);
|
||
border-radius: 20px; padding: 24px;
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 2px 12px rgba(37,99,235,0.06);
|
||
margin-bottom: 20px;
|
||
}
|
||
.fuellwort-liste {
|
||
display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0;
|
||
}
|
||
.fuellwort-tag {
|
||
display: inline-flex; align-items: center; gap: 8px;
|
||
background: var(--blue-light); border: 1px solid var(--blue-mid);
|
||
color: var(--dark); padding: 6px 12px;
|
||
border-radius: 8px; font-size: 13px; font-weight: 700;
|
||
}
|
||
.fuellwort-tag a {
|
||
color: var(--red); text-decoration: none;
|
||
font-size: 16px; font-weight: 900; line-height: 1;
|
||
}
|
||
.fuellwort-tag a:hover { color: #991b1b; }
|
||
.fuellwort-form {
|
||
display: flex; gap: 10px; margin-top: 14px;
|
||
}
|
||
.fuellwort-input {
|
||
flex: 1; padding: 10px 16px;
|
||
border: 2px solid var(--border); border-radius: 10px;
|
||
font-family: 'Nunito', sans-serif; font-size: 14px;
|
||
font-weight: 600; color: var(--dark);
|
||
outline: none;
|
||
}
|
||
.fuellwort-input:focus { border-color: var(--blue); }
|
||
.fuellwort-btn {
|
||
background: var(--blue); color: white;
|
||
border: none; border-radius: 10px;
|
||
padding: 10px 20px; font-family: 'Nunito', sans-serif;
|
||
font-size: 14px; font-weight: 700; cursor: pointer;
|
||
}
|
||
.fuellwort-btn:hover { background: #1e40af; }
|
||
.fehler-box {
|
||
background: var(--red-light);
|
||
border: 2px solid var(--red);
|
||
border-radius: 20px; padding: 24px;
|
||
margin-bottom: 20px;
|
||
display: flex; align-items: center; gap: 16px;
|
||
}
|
||
.fehler-icon { font-size: 32px; flex-shrink: 0; }
|
||
.fehler-titel { font-size: 16px; font-weight: 800; color: var(--red); margin-bottom: 4px; }
|
||
.fehler-text { font-size: 14px; color: #7f1d1d; font-weight: 500; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<div class="brand">
|
||
<div class="brand-avatar">🤖</div>
|
||
<div>
|
||
<div class="brand-name">Misty Rhetorik-Coach</div>
|
||
<div class="brand-sub">PH Weingarten · Modul M2 · WS 2025/26</div>
|
||
</div>
|
||
</div>
|
||
<div class="status-pill" id="status-pill"
|
||
style="background:{% if daten.status == 'fehler' %}var(--red-light);color:var(--red){% else %}var(--green-light);color:var(--green){% endif %};">
|
||
<div class="status-dot" id="status-dot"
|
||
style="background:{% if daten.status == 'fehler' %}var(--red){% else %}var(--green){% endif %};"></div>
|
||
<span id="status-text">
|
||
{% if daten.status == "wartend" %}Wartet auf Fußdruck
|
||
{% elif daten.status == "aufnehmend" %}Aufnahme läuft...
|
||
{% elif daten.status == "analysierend" %}Analyse läuft...
|
||
{% elif daten.status == "fehler" %}Fehler aufgetreten
|
||
{% endif %}
|
||
</span>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Fehlermeldung -->
|
||
{% if daten.status == "fehler" and daten.fehler %}
|
||
<div class="fehler-box">
|
||
<div class="fehler-icon">⚠️</div>
|
||
<div>
|
||
<div class="fehler-titel">Verbindungsfehler</div>
|
||
<div class="fehler-text">{{ daten.fehler }}</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Füllwörter-Verwaltung -->
|
||
<div class="fuellwort-card">
|
||
<div class="card-label">Füllwörter verwalten</div>
|
||
<div class="fuellwort-liste">
|
||
{% for wort in fuellwoerter %}
|
||
<span class="fuellwort-tag">
|
||
{{ wort }}
|
||
<a href="/fuellwort/entfernen/{{ wort }}" title="Entfernen">×</a>
|
||
</span>
|
||
{% endfor %}
|
||
</div>
|
||
<form class="fuellwort-form" method="POST" action="/fuellwort/hinzufuegen">
|
||
<input class="fuellwort-input" type="text" name="wort" placeholder="Neues Füllwort eingeben..." autocomplete="off">
|
||
<button class="fuellwort-btn" type="submit">+ Hinzufügen</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Hauptinhalt -->
|
||
<div id="hauptinhalt">
|
||
{% if daten.sessions %}
|
||
{% set letzte = daten.sessions[-1] %}
|
||
<div class="grid-top">
|
||
<div class="card card-blue">
|
||
<span class="dots">· · · · ·<br>· · · · ·<br>· · · · ·</span>
|
||
<div class="card-label">Füllwörter</div>
|
||
<div class="metric-num" style="color:white;">{{ letzte.fuellwoerter_anzahl }}</div>
|
||
<div class="metric-unit">in dieser Session</div>
|
||
{% if daten.sessions|length > 1 %}
|
||
{% set vorher = daten.sessions[-2] %}
|
||
{% if letzte.fuellwoerter_anzahl < vorher.fuellwoerter_anzahl %}
|
||
<div class="badge badge-blue">↓ {{ vorher.fuellwoerter_anzahl - letzte.fuellwoerter_anzahl }} weniger als zuvor</div>
|
||
{% elif letzte.fuellwoerter_anzahl > vorher.fuellwoerter_anzahl %}
|
||
<div class="badge badge-blue">↑ {{ letzte.fuellwoerter_anzahl - vorher.fuellwoerter_anzahl }} mehr als zuvor</div>
|
||
{% else %}
|
||
<div class="badge badge-blue">= gleich wie zuvor</div>
|
||
{% endif %}
|
||
{% endif %}
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-label">Sprechtempo</div>
|
||
<div class="metric-num c-blue">{{ letzte.tempo }}</div>
|
||
<div class="metric-unit">Wörter pro Minute</div>
|
||
<div class="gauge-wrap">
|
||
<div class="gauge-track">
|
||
<div class="gz" style="left:0;width:30%;background:#fde68a;border-radius:4px 0 0 4px;"></div>
|
||
<div class="gz" style="left:30%;width:40%;background:#bbf7d0;"></div>
|
||
<div class="gz" style="left:70%;width:30%;background:#fecaca;border-radius:0 4px 4px 0;"></div>
|
||
{% if letzte.tempo < 90 %}
|
||
<div class="gauge-knob" style="left:15%;"></div>
|
||
{% elif letzte.tempo <= 150 %}
|
||
<div class="gauge-knob" style="left:{{ 30 + ((letzte.tempo - 90) / 60 * 40)|int }}%;"></div>
|
||
{% else %}
|
||
<div class="gauge-knob" style="left:85%;"></div>
|
||
{% endif %}
|
||
</div>
|
||
<div class="gauge-labels">
|
||
<span>zu langsam</span>
|
||
<span>optimal</span>
|
||
<span>zu schnell</span>
|
||
</div>
|
||
</div>
|
||
{% if letzte.tempo < 90 %}
|
||
<div class="badge badge-amber">zu langsam</div>
|
||
{% elif letzte.tempo <= 150 %}
|
||
<div class="badge badge-green">✓ Sehr angenehm</div>
|
||
{% else %}
|
||
<div class="badge badge-red">zu schnell</div>
|
||
{% endif %}
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-label">Mistys Reaktion</div>
|
||
<div class="face-card">
|
||
<div class="face-circle">
|
||
<img src="/static/{{ letzte.gesicht }}" style="width:100%; height:100%; object-fit:cover;">
|
||
</div>
|
||
<div class="face-name">{{ letzte.gesicht }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="grid-bottom">
|
||
<div class="card">
|
||
<div class="card-label">Erkannter Text</div>
|
||
<div class="transcript-box">„{{ letzte.text }}"</div>
|
||
{% if letzte.gefundene_woerter %}
|
||
<div class="tags">
|
||
{% for wort, anzahl in letzte.gefundene_woerter.items() %}
|
||
<span class="tag">{{ wort }} <span class="tag-n">{{ anzahl }}×</span></span>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-label">Feedback von Misty</div>
|
||
<div class="feedback-icon">💬</div>
|
||
<div class="feedback-body">{{ letzte.feedback }}</div>
|
||
</div>
|
||
</div>
|
||
{% if daten.sessions|length > 1 %}
|
||
<div class="card">
|
||
<div class="card-label">Sessionverlauf</div>
|
||
{% for session in daten.sessions|reverse %}
|
||
{% if not loop.first %}
|
||
<div class="verlauf-row">
|
||
<div class="v-num">#{{ session.nummer }}</div>
|
||
<div class="v-bar">
|
||
<div class="v-fill" style="width:{{ [session.fuellwoerter_anzahl * 10, 100]|min }}%;
|
||
background:{% if session.fuellwoerter_anzahl == 0 %}#6ee7b7
|
||
{% elif session.fuellwoerter_anzahl <= 2 %}#fcd34d
|
||
{% else %}#fca5a5{% endif %};"></div>
|
||
</div>
|
||
<div class="v-meta">
|
||
<b class="{% if session.fuellwoerter_anzahl == 0 %}c-green{% elif session.fuellwoerter_anzahl <= 2 %}c-amber{% else %}c-red{% endif %}">
|
||
{{ session.fuellwoerter_anzahl }}
|
||
</b> Füllwörter ·
|
||
<b>{{ session.tempo }}</b> W/min ·
|
||
<img src="/static/{{ session.gesicht }}" class="v-face">
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% else %}
|
||
<div class="card">
|
||
<div class="empty-state">
|
||
🤖 Willkommen beim Misty Rhetorik-Coach!<br><br>
|
||
Hier werden die Ergebnisse jeder Präsentation – Füllwörter, Sprechtempo und Mistys Feedback gezeigt.<br><br>
|
||
Sobald der erste Durchgang abgeschlossen ist, erscheinen die Ergebnisse automatisch.<br><br>
|
||
Im Sessionverlauf ist zu sehen, wie sich die Leistung über mehrere Durchgänge hinweg verbessert.<br><br>
|
||
<strong>👣 Drücke Mistys Fuß um die Aufnahme zu starten!</strong>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<script>
|
||
const statusPill = document.getElementById('status-pill');
|
||
const statusDot = document.getElementById('status-dot');
|
||
const statusText = document.getElementById('status-text');
|
||
|
||
function aktualisiereStatus(status) {
|
||
if (status === 'wartend') {
|
||
statusPill.style.background = 'var(--green-light)';
|
||
statusPill.style.color = 'var(--green)';
|
||
statusDot.style.background = 'var(--green)';
|
||
statusText.textContent = 'Wartet auf Fußdruck';
|
||
} else if (status === 'aufnehmend') {
|
||
statusPill.style.background = 'var(--red-light)';
|
||
statusPill.style.color = 'var(--red)';
|
||
statusDot.style.background = 'var(--red)';
|
||
statusText.textContent = 'Aufnahme läuft...';
|
||
} else if (status === 'analysierend') {
|
||
statusPill.style.background = 'var(--amber-light)';
|
||
statusPill.style.color = 'var(--amber)';
|
||
statusDot.style.background = 'var(--amber)';
|
||
statusText.textContent = 'Analyse läuft...';
|
||
} else if (status === 'fehler') {
|
||
statusPill.style.background = 'var(--red-light)';
|
||
statusPill.style.color = 'var(--red)';
|
||
statusDot.style.background = 'var(--red)';
|
||
statusText.textContent = 'Fehler aufgetreten';
|
||
}
|
||
}
|
||
|
||
let letzteSessionAnzahl = {{ daten.sessions|length }};
|
||
let letzterFehler = {{ (daten.fehler if daten.fehler is defined else none)|tojson }};
|
||
|
||
const evtSource = new EventSource('/stream');
|
||
|
||
evtSource.onmessage = function(e) {
|
||
const daten = JSON.parse(e.data);
|
||
aktualisiereStatus(daten.status);
|
||
const neueAnzahl = daten.sessions ? daten.sessions.length : 0;
|
||
const neuerFehler = daten.fehler || null;
|
||
if (neueAnzahl !== letzteSessionAnzahl || neuerFehler !== letzterFehler) {
|
||
letzteSessionAnzahl = neueAnzahl;
|
||
letzterFehler = neuerFehler;
|
||
window.location.reload();
|
||
}
|
||
};
|
||
|
||
evtSource.onerror = function() {
|
||
console.log('SSE Verbindung unterbrochen, versuche neu...');
|
||
};
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|