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:
+73
-1
@@ -80,7 +80,79 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="screen-reward" class="screen"></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">← 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>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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 */
|
||||||
.keyboard {
|
.keyboard {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user