feat(03-05): parent area HTML, CSS, and access gate

- Add parent screen HTML with code gate, stats overview, and settings form
- Add parent area CSS styles for stats grid, calendar dots, error badges, settings
- Create src/ui/parent.ts with showParentCodePrompt, initParentScreen, loadParentData
This commit is contained in:
2026-03-29 13:46:10 +02:00
parent 2c0c2f2701
commit 44c28fe226
3 changed files with 475 additions and 1 deletions
+73 -1
View File
@@ -80,7 +80,79 @@
</div>
</section>
<section id="screen-reward" class="screen"></section>
<section id="screen-parent" class="screen"></section>
<section id="screen-parent" class="screen">
<div class="parent">
<button class="parent__back-btn" id="parent-back-btn">&#x2190; Zurueck</button>
<h1 class="parent__title">Elternbereich</h1>
<!-- Code gate overlay -->
<div class="parent__gate" id="parent-gate">
<p>Bitte Code eingeben:</p>
<input type="password" id="parent-code-input" class="parent__code-input" maxlength="4" placeholder="____" inputmode="numeric">
<p class="parent__gate-error" id="parent-gate-error" style="display:none">Falscher Code</p>
</div>
<!-- Content (hidden until code entered) -->
<div class="parent__content" id="parent-content" style="display:none">
<section class="parent__section">
<h2>Uebersicht</h2>
<div class="parent__stats" id="parent-stats">
<div class="parent__stat">
<span class="parent__stat-label">Aktuelle Stufe</span>
<span class="parent__stat-value" id="stat-level">-</span>
</div>
<div class="parent__stat">
<span class="parent__stat-label">Einheiten</span>
<span class="parent__stat-value" id="stat-sessions">-</span>
</div>
<div class="parent__stat">
<span class="parent__stat-label">Genauigkeit</span>
<span class="parent__stat-value" id="stat-accuracy">-</span>
</div>
</div>
<div class="parent__calendar" id="parent-calendar">
<h3>Uebungstage</h3>
<div class="parent__calendar-dots" id="calendar-dots"></div>
</div>
<div class="parent__errors" id="parent-errors">
<h3>Haeufigste Fehlertasten</h3>
<div id="error-keys-list"></div>
</div>
</section>
<section class="parent__section">
<h2>Einstellungen</h2>
<div class="parent__settings">
<div class="parent__setting">
<label>Tastatur-Layout</label>
<select id="setting-layout">
<option value="de">DE (Deutschland)</option>
<option value="ch">CH (Schweiz)</option>
</select>
</div>
<div class="parent__setting">
<label>Audio</label>
<label class="parent__toggle">
<input type="checkbox" id="setting-audio">
<span>An/Aus</span>
</label>
</div>
<div class="parent__setting">
<label>Begleitfigur</label>
<select id="setting-companion"></select>
</div>
<div class="parent__setting">
<label>API-Key</label>
<input type="password" id="setting-apikey" class="parent__input" placeholder="Gemini API Key">
<button class="parent__save-btn" id="save-apikey-btn">Speichern</button>
</div>
</div>
</section>
</div>
</div>
</section>
<script type="module" src="/src/main.ts"></script>
</body>
+173
View File
@@ -441,6 +441,179 @@ h3 {
}
}
/* Parent Area */
.parent {
max-width: 600px;
margin: 0 auto;
padding: 1.5rem;
}
.parent__back-btn {
background: none;
border: none;
font-family: var(--font-heading);
font-size: 1rem;
color: var(--text-warm);
cursor: pointer;
margin-bottom: 1rem;
}
.parent__title {
font-family: var(--font-heading);
color: var(--text-dark);
margin-bottom: 1.5rem;
}
.parent__gate {
text-align: center;
padding: 2rem;
font-family: var(--font-body);
}
.parent__code-input {
font-size: 2rem;
text-align: center;
width: 120px;
padding: 0.5rem;
border: 2px solid var(--accent-purple);
border-radius: 8px;
font-family: var(--font-heading);
letter-spacing: 0.5rem;
margin-top: 0.5rem;
}
.parent__gate-error {
color: var(--accent-pink);
margin-top: 0.5rem;
font-size: 0.9rem;
}
.parent__section {
margin-bottom: 2rem;
}
.parent__section h2 {
font-family: var(--font-heading);
color: var(--text-dark);
border-bottom: 2px solid var(--bg-forest);
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.parent__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.parent__stat {
background: var(--bg-forest);
border-radius: 12px;
padding: 1rem;
text-align: center;
}
.parent__stat-label {
display: block;
font-family: var(--font-body);
font-size: 0.85rem;
color: var(--text-warm);
margin-bottom: 0.25rem;
}
.parent__stat-value {
display: block;
font-family: var(--font-heading);
font-size: 1.5rem;
font-weight: 700;
color: var(--text-dark);
}
.parent__calendar-dots {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 0.5rem;
}
.parent__calendar-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent-green);
}
.parent__calendar-dot--empty {
background: var(--bg-cream);
border: 1px solid var(--text-light);
}
.parent__errors h3,
.parent__calendar h3 {
font-family: var(--font-heading);
font-size: 1rem;
color: var(--text-warm);
margin-bottom: 0.5rem;
}
.parent__error-key {
display: inline-block;
background: var(--accent-pink);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 8px;
margin: 0.25rem;
font-family: var(--font-heading);
font-size: 0.9rem;
}
.parent__settings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.parent__setting {
display: flex;
align-items: center;
gap: 1rem;
}
.parent__setting label:first-child {
min-width: 120px;
font-family: var(--font-body);
color: var(--text-warm);
}
.parent__setting select,
.parent__input {
font-family: var(--font-body);
padding: 0.5rem;
border: 1px solid var(--text-light);
border-radius: 8px;
font-size: 0.95rem;
}
.parent__save-btn {
font-family: var(--font-heading);
padding: 0.5rem 1rem;
background: var(--accent-green);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.parent__saved-feedback {
color: var(--accent-green);
font-family: var(--font-body);
font-size: 0.85rem;
margin-left: 0.5rem;
opacity: 1;
transition: opacity 0.3s ease;
}
/* Keyboard */
.keyboard {
display: flex;
+229
View File
@@ -0,0 +1,229 @@
import { getProgress, saveProgress, getSettings, saveSettings } from "../storage/db";
import { companions } from "../companion/characters";
import type { CompanionType, KeyboardLayout, Settings } from "../types";
import { showScreen, getDB } from "../app";
const ACCESS_CODE = "1234";
/**
* Show the parent code prompt gate, then reveal content on correct code.
*/
export function showParentCodePrompt(): void {
showScreen("parent");
const gate = document.getElementById("parent-gate")!;
const content = document.getElementById("parent-content")!;
const codeInput = document.getElementById("parent-code-input") as HTMLInputElement;
const gateError = document.getElementById("parent-gate-error")!;
// Reset state
gate.style.display = "block";
content.style.display = "none";
codeInput.value = "";
gateError.style.display = "none";
codeInput.focus();
// Remove previous listener to avoid duplicates
const handler = () => {
const value = codeInput.value;
if (value === ACCESS_CODE) {
gate.style.display = "none";
content.style.display = "block";
loadParentData();
codeInput.removeEventListener("input", handler);
} else if (value.length === 4) {
gateError.style.display = "block";
setTimeout(() => {
gateError.style.display = "none";
codeInput.value = "";
}, 1500);
codeInput.removeEventListener("input", handler);
// Re-attach after clearing
setTimeout(() => {
codeInput.addEventListener("input", handler);
}, 1500);
}
};
codeInput.addEventListener("input", handler);
}
/**
* Initialize the parent screen: wire back button.
*/
export function initParentScreen(_db: IDBDatabase, onBack: () => void): void {
const backBtn = document.getElementById("parent-back-btn");
if (backBtn) {
backBtn.onclick = () => onBack();
}
}
/**
* Load and display all parent data (stats + settings).
*/
async function loadParentData(): Promise<void> {
const db = getDB();
const progress = await getProgress(db);
const settings = await getSettings(db);
// Stats
if (progress) {
document.getElementById("stat-level")!.textContent = String(progress.currentLevel);
document.getElementById("stat-sessions")!.textContent = String(progress.totalSessions);
const total = progress.totalCorrect + progress.totalErrors;
const accuracy = total > 0 ? Math.round((progress.totalCorrect / total) * 100) : 0;
document.getElementById("stat-accuracy")!.textContent = `${accuracy}%`;
// Calendar dots (last 30 days)
renderCalendarDots(progress.sessionDates);
// Error keys
renderErrorKeys(progress.errorKeyCounts);
}
// Settings
await loadSettings(db, progress, settings);
}
/**
* Render calendar dots for the last 30 days.
*/
function renderCalendarDots(sessionDates: string[]): void {
const container = document.getElementById("calendar-dots")!;
container.innerHTML = "";
const today = new Date();
const sessionSet = new Set(sessionDates);
for (let i = 29; i >= 0; i--) {
const date = new Date(today);
date.setDate(today.getDate() - i);
const dateStr = date.toISOString().split("T")[0]!;
const dot = document.createElement("div");
dot.className = sessionSet.has(dateStr)
? "parent__calendar-dot"
: "parent__calendar-dot parent__calendar-dot--empty";
dot.title = dateStr;
container.appendChild(dot);
}
}
/**
* Render error key badges, sorted by frequency, top 5.
*/
function renderErrorKeys(errorKeyCounts: Record<string, number>): void {
const container = document.getElementById("error-keys-list")!;
container.innerHTML = "";
const entries = Object.entries(errorKeyCounts).sort((a, b) => b[1] - a[1]);
if (entries.length === 0) {
container.textContent = "Noch keine Fehler erfasst";
return;
}
const top5 = entries.slice(0, 5);
for (const [key, count] of top5) {
const badge = document.createElement("span");
badge.className = "parent__error-key";
badge.textContent = `${key.toUpperCase()} (${count}x)`;
container.appendChild(badge);
}
}
/**
* Load settings into form elements and wire change handlers.
*/
async function loadSettings(
db: IDBDatabase,
progress: Awaited<ReturnType<typeof getProgress>>,
settings: Settings | null,
): Promise<void> {
const layoutSelect = document.getElementById("setting-layout") as HTMLSelectElement;
const audioCheckbox = document.getElementById("setting-audio") as HTMLInputElement;
const companionSelect = document.getElementById("setting-companion") as HTMLSelectElement;
const apiKeyInput = document.getElementById("setting-apikey") as HTMLInputElement;
const saveApiKeyBtn = document.getElementById("save-apikey-btn")!;
// Populate companion select
companionSelect.innerHTML = "";
for (const c of companions) {
const option = document.createElement("option");
option.value = c.type;
option.textContent = `${c.emoji} ${c.name}`;
companionSelect.appendChild(option);
}
// Set current values
if (progress) {
layoutSelect.value = progress.selectedLayout;
companionSelect.value = progress.selectedCharacter;
}
if (settings) {
audioCheckbox.checked = settings.audioEnabled;
apiKeyInput.value = settings.apiKey || "";
}
// Wire change handlers
layoutSelect.onchange = async () => {
if (!progress) return;
progress.selectedLayout = layoutSelect.value as KeyboardLayout;
await saveProgress(db, progress);
showSavedFeedback(layoutSelect);
};
companionSelect.onchange = async () => {
if (!progress) return;
progress.selectedCharacter = companionSelect.value as CompanionType;
await saveProgress(db, progress);
showSavedFeedback(companionSelect);
};
audioCheckbox.onchange = async () => {
const currentSettings = settings ?? {
id: 1 as const,
audioEnabled: false,
apiKey: "",
apiCallsToday: { text: 0, image: 0 },
lastApiCallDate: "",
};
currentSettings.audioEnabled = audioCheckbox.checked;
await saveSettings(db, currentSettings);
showSavedFeedback(audioCheckbox);
};
saveApiKeyBtn.onclick = async () => {
const currentSettings = settings ?? {
id: 1 as const,
audioEnabled: false,
apiKey: "",
apiCallsToday: { text: 0, image: 0 },
lastApiCallDate: "",
};
currentSettings.apiKey = apiKeyInput.value;
await saveSettings(db, currentSettings);
showSavedFeedback(saveApiKeyBtn);
};
}
/**
* Show brief "Gespeichert!" feedback next to an element.
*/
function showSavedFeedback(element: HTMLElement): void {
// Remove any existing feedback
const existing = element.parentElement?.querySelector(".parent__saved-feedback");
if (existing) existing.remove();
const feedback = document.createElement("span");
feedback.className = "parent__saved-feedback";
feedback.textContent = "Gespeichert!";
element.parentElement?.appendChild(feedback);
setTimeout(() => {
feedback.style.opacity = "0";
setTimeout(() => feedback.remove(), 300);
}, 1500);
}