2026-03-29 13:46:10 +02:00
|
|
|
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);
|
2026-03-29 18:16:51 +02:00
|
|
|
|
|
|
|
|
// Wire data action buttons
|
|
|
|
|
document.getElementById("export-btn")!.onclick = () => exportData(db);
|
|
|
|
|
document.getElementById("import-btn")!.onclick = () =>
|
|
|
|
|
(document.getElementById("import-file") as HTMLInputElement).click();
|
|
|
|
|
document.getElementById("import-file")!.addEventListener("change", (e) => {
|
|
|
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
|
|
|
if (file) importData(db, file);
|
|
|
|
|
});
|
|
|
|
|
document.getElementById("reset-btn")!.onclick = () => resetAllData(db);
|
2026-03-29 13:46:10 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-03-29 18:16:51 +02:00
|
|
|
speechEnabled: false,
|
2026-03-29 13:46:10 +02:00
|
|
|
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,
|
2026-03-29 18:16:51 +02:00
|
|
|
speechEnabled: false,
|
2026-03-29 13:46:10 +02:00
|
|
|
apiKey: "",
|
|
|
|
|
apiCallsToday: { text: 0, image: 0 },
|
|
|
|
|
lastApiCallDate: "",
|
|
|
|
|
};
|
|
|
|
|
currentSettings.apiKey = apiKeyInput.value;
|
|
|
|
|
await saveSettings(db, currentSettings);
|
|
|
|
|
showSavedFeedback(saveApiKeyBtn);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 18:16:51 +02:00
|
|
|
const DB_STORES = ["progress", "companionAssets", "styleReference", "forestElements", "settings"];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Show feedback message in the data section.
|
|
|
|
|
*/
|
|
|
|
|
function showDataFeedback(message: string, isError = false): void {
|
|
|
|
|
const el = document.getElementById("data-feedback")!;
|
|
|
|
|
el.textContent = message;
|
|
|
|
|
el.style.display = "block";
|
|
|
|
|
el.style.color = isError ? "#D46A6A" : "";
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
el.style.display = "none";
|
|
|
|
|
}, 3000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Export progress and settings as a JSON file download.
|
|
|
|
|
*/
|
|
|
|
|
async function exportData(db: IDBDatabase): Promise<void> {
|
|
|
|
|
const progress = await getProgress(db);
|
|
|
|
|
const settings = await getSettings(db);
|
|
|
|
|
|
|
|
|
|
const data = {
|
|
|
|
|
version: 1,
|
|
|
|
|
exportedAt: new Date().toISOString(),
|
|
|
|
|
progress,
|
|
|
|
|
settings,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
a.href = url;
|
|
|
|
|
a.download = "zauberwald-backup.json";
|
|
|
|
|
a.click();
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
|
|
|
|
showDataFeedback("Exportiert!");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Import progress and settings from a JSON file.
|
|
|
|
|
*/
|
|
|
|
|
async function importData(db: IDBDatabase, file: File): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const text = await file.text();
|
|
|
|
|
const parsed = JSON.parse(text);
|
|
|
|
|
|
|
|
|
|
// Validate structure
|
|
|
|
|
if (
|
|
|
|
|
!parsed.progress ||
|
|
|
|
|
typeof parsed.progress.currentLevel !== "number" ||
|
|
|
|
|
!Array.isArray(parsed.progress.completedLevels) ||
|
|
|
|
|
typeof parsed.progress.totalSessions !== "number" ||
|
|
|
|
|
typeof parsed.progress.selectedCharacter !== "string" ||
|
|
|
|
|
typeof parsed.progress.selectedLayout !== "string"
|
|
|
|
|
) {
|
|
|
|
|
showDataFeedback("Ungueltige Datei", true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!parsed.settings || typeof parsed.settings.audioEnabled !== "boolean") {
|
|
|
|
|
showDataFeedback("Ungueltige Datei", true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 20:46:04 +02:00
|
|
|
if (!confirm("Fortschritt überschreiben? Aktuelle Daten gehen verloren.")) {
|
2026-03-29 18:16:51 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await saveProgress(db, { ...parsed.progress, id: 1 });
|
|
|
|
|
await saveSettings(db, { ...parsed.settings, id: 1 });
|
|
|
|
|
|
|
|
|
|
showDataFeedback("Importiert!");
|
|
|
|
|
setTimeout(() => location.reload(), 1500);
|
|
|
|
|
} catch {
|
|
|
|
|
showDataFeedback("Ungueltige Datei", true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clear a single IndexedDB object store.
|
|
|
|
|
*/
|
|
|
|
|
function clearStore(db: IDBDatabase, storeName: string): Promise<void> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const tx = db.transaction(storeName, "readwrite");
|
|
|
|
|
const request = tx.objectStore(storeName).clear();
|
|
|
|
|
request.onsuccess = () => resolve();
|
|
|
|
|
request.onerror = () => reject(request.error);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reset all data with double confirmation, then reload.
|
|
|
|
|
*/
|
|
|
|
|
async function resetAllData(db: IDBDatabase): Promise<void> {
|
2026-03-29 20:46:04 +02:00
|
|
|
if (!confirm("Wirklich alles löschen?")) return;
|
|
|
|
|
if (!confirm("Alle Fortschritte und Einstellungen werden gelöscht. Bist du sicher?")) return;
|
2026-03-29 18:16:51 +02:00
|
|
|
|
|
|
|
|
await Promise.all(DB_STORES.map((store) => clearStore(db, store)));
|
|
|
|
|
location.reload();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 13:46:10 +02:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|