877 lines
No EOL
52 KiB
PHP
877 lines
No EOL
52 KiB
PHP
<?php
|
|
$current_project = 'storymachine';
|
|
require_once '../auth.php';
|
|
?>
|
|
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Deine magische Geschichten-Maschine</title>
|
|
|
|
<!-- Tailwind CSS via CDN -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
|
|
<!-- Google Fonts for a friendly look -->
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;700&display=swap" rel="stylesheet">
|
|
|
|
<style>
|
|
/* Custom styles and overrides */
|
|
body {
|
|
font-family: 'Fredoka', sans-serif;
|
|
background: linear-gradient(135deg, #a8e6cf, #dcedc1);
|
|
}
|
|
|
|
.card-selected {
|
|
transform: scale(1.08);
|
|
box-shadow: 0 10px 25px -5px rgba(255, 193, 7, 0.6), 0 8px 10px -6px rgba(255, 193, 7, 0.5);
|
|
}
|
|
|
|
.selector-btn {
|
|
padding: 0.5rem 1.25rem;
|
|
background-color: rgba(255, 255, 255, 0.7);
|
|
color: #0d9488;
|
|
font-weight: 600;
|
|
border-radius: 9999px;
|
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
border: 2px solid transparent;
|
|
}
|
|
.selector-btn:hover {
|
|
transform: scale(1.05);
|
|
}
|
|
.selector-selected {
|
|
background-color: #f59e0b;
|
|
color: #ffffff;
|
|
border-color: #d97706;
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
/* --- NEUES, FLEXIBLES GRID FÜR DIE KARTEN --- */
|
|
.card-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
/* --- PLACEHOLDER & REVEAL ANIMATIONS --- */
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
.spinner {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
0% { background-position: 200% 50%; }
|
|
100% { background-position: -200% 50%; }
|
|
}
|
|
.shimmer-bg {
|
|
background: linear-gradient(90deg, #dcedc1, #a8e6cf, #fde68a, #a8e6cf, #dcedc1);
|
|
background-size: 200% 100%;
|
|
animation: shimmer 5s ease infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
50% { opacity: .5; }
|
|
}
|
|
.placeholder-line {
|
|
height: 1.25rem;
|
|
width: 90%;
|
|
border-radius: 9999px;
|
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
}
|
|
.placeholder-line:nth-child(2) { width: 80%; }
|
|
.placeholder-line:nth-child(3) { width: 95%; }
|
|
.placeholder-line:nth-child(4) { width: 75%; }
|
|
|
|
@keyframes magic-dots {
|
|
0%, 100% { transform: translateY(0); opacity: 0.5; }
|
|
50% { transform: translateY(-8px); opacity: 1; }
|
|
}
|
|
.magic-dot {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
animation: magic-dots 1.5s ease-in-out infinite;
|
|
}
|
|
.magic-dot:nth-child(2) { animation-delay: 0.2s; }
|
|
.magic-dot:nth-child(3) { animation-delay: 0.4s; }
|
|
|
|
.loader-dot {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
animation: magic-dots 1.5s ease-in-out infinite;
|
|
}
|
|
.loader-dot:nth-child(2) { animation-delay: 0.2s; }
|
|
.loader-dot:nth-child(3) { animation-delay: 0.4s; }
|
|
|
|
.reveal-item {
|
|
opacity: 0;
|
|
transition: opacity 0.6s ease-in-out;
|
|
}
|
|
.reveal-item.visible {
|
|
opacity: 1;
|
|
}
|
|
#story-image {
|
|
transition: opacity 0.5s ease-in-out;
|
|
opacity: 0;
|
|
}
|
|
#story-image.loaded {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Karaoke highlight style */
|
|
#story-text-content span {
|
|
transition: background-color 0.4s ease-out;
|
|
border-radius: 4px;
|
|
padding: 0 2px;
|
|
background-color: transparent;
|
|
}
|
|
#story-text-content span.highlight {
|
|
background-color: #fde68a; /* amber-200 */
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="min-h-screen text-gray-800 flex items-center justify-center p-2 sm:p-4">
|
|
|
|
<div class="w-full max-w-4xl mx-auto bg-white/80 backdrop-blur-sm rounded-3xl shadow-2xl p-4 sm:p-6 md:p-10 space-y-8">
|
|
|
|
<header id="main-header" class="text-center relative">
|
|
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-teal-600 drop-shadow-md">
|
|
Die magische Geschichten-Maschine
|
|
</h1>
|
|
<button id="fullscreen-btn" title="Vollbild umschalten" class="absolute top-0 right-0 p-2 text-teal-500 hover:text-teal-700 transition-colors">
|
|
<svg id="fullscreen-icon-maximize" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path></svg>
|
|
<svg id="fullscreen-icon-minimize" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="hidden"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 0-2-2h-3M3 16h3a2 2 0 0 0 2 2h3"></path></svg>
|
|
</button>
|
|
</header>
|
|
|
|
<div id="audience-selector-container" class="text-center space-y-3 py-4 border-b border-t border-teal-100">
|
|
<h3 class="text-xl font-bold text-teal-700">Für wen ist die Geschichte?</h3>
|
|
<div id="audience-selector" class="flex justify-center gap-2 sm:gap-4 flex-wrap">
|
|
<button class="selector-btn" data-audience="Unter 6">Unter 6</button>
|
|
<button class="selector-btn" data-audience="6 bis 12">6 bis 12</button>
|
|
<button class="selector-btn" data-audience="12 bis 17">12 bis 17</button>
|
|
<button class="selector-btn" data-audience="Erwachsene">Erwachsene</button>
|
|
</div>
|
|
</div>
|
|
|
|
<main id="selection-area" class="space-y-8">
|
|
<p id="subtitle" class="text-center text-lg text-gray-600 -mt-4">Wähle aus, worum es gehen soll!</p>
|
|
<div id="story-categories" class="space-y-8">
|
|
<div id="initial-prompt-message" class="text-center py-10">
|
|
<p class="text-xl text-gray-600">👆 Wähle zuerst eine Altersgruppe aus, um Ideen zu laden!</p>
|
|
</div>
|
|
<div id="options-loader" class="hidden text-center py-10">
|
|
<div class="flex justify-center items-center space-x-3">
|
|
<div class="loader-dot bg-teal-400"></div>
|
|
<div class="loader-dot bg-amber-400"></div>
|
|
<div class="loader-dot bg-sky-400"></div>
|
|
</div>
|
|
<p class="text-xl text-gray-600 mt-4">Lade neue magische Ideen...</p>
|
|
</div>
|
|
<div id="card-area" class="space-y-8"></div>
|
|
</div>
|
|
|
|
<div class="text-center pt-4 flex flex-col sm:flex-row justify-center items-center gap-4">
|
|
<button id="reload-options-button" class="px-8 py-4 bg-sky-500 text-white text-xl font-bold rounded-full shadow-lg transform transition-transform duration-200 hover:scale-105">
|
|
🎲 Neue Ideen
|
|
</button>
|
|
<button id="generate-button" class="px-8 py-4 bg-amber-400 text-white text-xl font-bold rounded-full shadow-lg transform transition-transform duration-200 hover:scale-105 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed disabled:scale-100" disabled>
|
|
✨ Erschaffe meine Geschichte! ✨
|
|
</button>
|
|
</div>
|
|
</main>
|
|
|
|
<section id="result-area" class="hidden text-center py-6 space-y-6">
|
|
<div id="progress-container" class="pb-4">
|
|
<p id="progress-text" class="text-xl text-gray-600 mb-2">Starte die Maschine...</p>
|
|
<div class="w-full max-w-md mx-auto bg-gray-200 rounded-full h-4 overflow-hidden">
|
|
<div id="progress-bar" class="bg-teal-500 h-4 rounded-full transition-all duration-700 ease-in-out" style="width: 0%"></div>
|
|
</div>
|
|
</div>
|
|
<div class="min-h-[44px] flex justify-center items-center">
|
|
<h2 id="result-title" class="text-2xl sm:text-3xl md:text-4xl font-bold text-teal-600"></h2>
|
|
<div id="title-placeholder" class="flex gap-2">
|
|
<div class="magic-dot bg-teal-200"></div>
|
|
<div class="magic-dot bg-amber-200"></div>
|
|
<div class="magic-dot bg-sky-200"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="image-container" class="relative max-w-md mx-auto shadow-lg rounded-xl overflow-hidden bg-gray-200 aspect-square flex items-center justify-center">
|
|
<div id="image-loader" class="w-full h-full shimmer-bg"></div>
|
|
<img id="story-image" src="" alt="Eine Illustration der Geschichte" class="absolute w-full h-full object-cover">
|
|
</div>
|
|
|
|
<div id="story-text-container" class="max-w-prose mx-auto bg-white/60 p-4 rounded-xl text-left shadow-inner min-h-[150px]">
|
|
<div id="text-placeholder" class="space-y-3 p-4">
|
|
<div class="placeholder-line bg-gray-200"></div>
|
|
<div class="placeholder-line bg-gray-200"></div>
|
|
<div class="placeholder-line bg-gray-200"></div>
|
|
<div class="placeholder-line bg-gray-200"></div>
|
|
</div>
|
|
<p id="story-text-content" class="text-lg leading-relaxed"></p>
|
|
</div>
|
|
|
|
<div id="audio-container" class="bg-teal-50 rounded-xl p-4 max-w-md mx-auto shadow-inner">
|
|
<div id="audio-placeholder" class="flex items-center space-x-4 p-2">
|
|
<div class="w-12 h-12 bg-gray-200 rounded-full placeholder-line"></div>
|
|
<div class="flex-1 space-y-2">
|
|
<div class="h-3 w-3/4 bg-gray-200 rounded placeholder-line"></div>
|
|
<div class="h-3 w-1/2 bg-gray-200 rounded placeholder-line"></div>
|
|
</div>
|
|
</div>
|
|
<audio id="audio-player" controls class="w-full hidden"></audio>
|
|
</div>
|
|
|
|
<div id="final-buttons" class="hidden">
|
|
<a id="download-button" href="#" download="meine-geschichte.wav" class="inline-block px-8 py-3 bg-emerald-500 text-white text-xl font-bold rounded-full shadow-lg transform transition-transform duration-200 hover:scale-105">
|
|
Herunterladen
|
|
</a>
|
|
<button id="reset-button" class="mt-4 sm:mt-0 sm:ml-4 inline-block px-8 py-3 bg-sky-500 text-white text-xl font-bold rounded-full shadow-lg transform transition-transform duration-200 hover:scale-105">
|
|
Nochmal von vorn!
|
|
</button>
|
|
</div>
|
|
|
|
</section>
|
|
|
|
<div id="error-area" class="hidden text-center bg-red-100 border-2 border-red-400 text-red-700 px-4 py-3 rounded-xl relative" role="alert">
|
|
<strong class="font-bold">Hoppla!</strong>
|
|
<span id="error-message" class="block sm:inline">Etwas ist schiefgelaufen. Bitte versuche es noch einmal.</span>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// --- STATE ---
|
|
const userSelection = {};
|
|
let storyParts = {};
|
|
let selectedAudience = null;
|
|
let wordSpans = [];
|
|
let wordTimings = [];
|
|
let currentHighlightIndex = -1;
|
|
let progressCreepInterval = null;
|
|
let currentProgress = 0;
|
|
|
|
// --- ELEMENTS ---
|
|
const elements = {
|
|
categoriesContainer: document.getElementById('story-categories'),
|
|
generateButton: document.getElementById('generate-button'),
|
|
reloadOptionsButton: document.getElementById('reload-options-button'),
|
|
selectionArea: document.getElementById('selection-area'),
|
|
resultArea: document.getElementById('result-area'),
|
|
audioPlayer: document.getElementById('audio-player'),
|
|
downloadButton: document.getElementById('download-button'),
|
|
resetButton: document.getElementById('reset-button'),
|
|
errorArea: document.getElementById('error-area'),
|
|
errorMessage: document.getElementById('error-message'),
|
|
audioContainer: document.getElementById('audio-container'),
|
|
audioPlaceholder: document.getElementById('audio-placeholder'),
|
|
resultTitle: document.getElementById('result-title'),
|
|
titlePlaceholder: document.getElementById('title-placeholder'),
|
|
storyTextContent: document.getElementById('story-text-content'),
|
|
textPlaceholder: document.getElementById('text-placeholder'),
|
|
storyTextContainer: document.getElementById('story-text-container'),
|
|
optionsLoader: document.getElementById('options-loader'),
|
|
progressBar: document.getElementById('progress-bar'),
|
|
progressText: document.getElementById('progress-text'),
|
|
subtitle: document.getElementById('subtitle'),
|
|
imageContainer: document.getElementById('image-container'),
|
|
storyImage: document.getElementById('story-image'),
|
|
imageLoader: document.getElementById('image-loader'),
|
|
audienceSelector: document.getElementById('audience-selector'),
|
|
audienceSelectorContainer: document.getElementById('audience-selector-container'),
|
|
mainHeader: document.getElementById('main-header'),
|
|
progressContainer: document.getElementById('progress-container'),
|
|
finalButtons: document.getElementById('final-buttons'),
|
|
cardArea: document.getElementById('card-area'),
|
|
initialPromptMessage: document.getElementById('initial-prompt-message'),
|
|
fullscreenButton: document.getElementById('fullscreen-btn'),
|
|
fullscreenIconMaximize: document.getElementById('fullscreen-icon-maximize'),
|
|
fullscreenIconMinimize: document.getElementById('fullscreen-icon-minimize'),
|
|
};
|
|
|
|
// --- CORE FUNCTIONS ---
|
|
|
|
function updateProgress(percentage, text) {
|
|
currentProgress = percentage;
|
|
elements.progressBar.style.width = `${percentage}%`;
|
|
if (text) elements.progressText.textContent = text;
|
|
|
|
// Wenn wir einen Zielwert erreichen, stoppen wir das automatische Kriechen kurzzeitig
|
|
stopCreeping();
|
|
}
|
|
|
|
function startCreeping(targetLimit) {
|
|
stopCreeping();
|
|
progressCreepInterval = setInterval(() => {
|
|
if (currentProgress < targetLimit) {
|
|
currentProgress += 0.5; // Ganz kleine Schritte
|
|
elements.progressBar.style.width = `${currentProgress}%`;
|
|
} else {
|
|
stopCreeping();
|
|
}
|
|
}, 400); // Alle 0.4 Sekunden ein kleines Stück
|
|
}
|
|
|
|
function stopCreeping() {
|
|
if (progressCreepInterval) {
|
|
clearInterval(progressCreepInterval);
|
|
progressCreepInterval = null;
|
|
}
|
|
}
|
|
|
|
function shuffleArray(array) {
|
|
for (let i = array.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[array[i], array[j]] = [array[j], array[i]];
|
|
}
|
|
}
|
|
|
|
function toggleFullScreen() {
|
|
const doc = document.documentElement;
|
|
if (!document.fullscreenElement) {
|
|
doc.requestFullscreen().catch(err => {
|
|
console.error(`Vollbildmodus konnte nicht aktiviert werden: ${err.message}`);
|
|
});
|
|
} else {
|
|
if (document.exitFullscreen) {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateFullscreenIcons() {
|
|
if (document.fullscreenElement) {
|
|
elements.fullscreenIconMaximize.classList.add('hidden');
|
|
elements.fullscreenIconMinimize.classList.remove('hidden');
|
|
} else {
|
|
elements.fullscreenIconMaximize.classList.remove('hidden');
|
|
elements.fullscreenIconMinimize.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
|
|
// --- API FUNCTIONS ---
|
|
|
|
async function fetchWithBackoff(apiUrl, payload, maxRetries = 5) {
|
|
const proxyUrl = 'api_proxy.php';
|
|
let delay = 1000;
|
|
for (let i = 0; i < maxRetries; i++) {
|
|
try {
|
|
const response = await fetch(proxyUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ apiUrl: apiUrl, payload: payload })
|
|
});
|
|
|
|
if (response.status === 429) {
|
|
throw new Error("LIMIT_EXCEEDED");
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text();
|
|
console.error(`API Error (Attempt ${i+1}):`, response.status, errorBody);
|
|
throw new Error(`API request failed with status ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
} catch (error) {
|
|
if (error.message === "LIMIT_EXCEEDED") throw error;
|
|
|
|
if (i === maxRetries - 1) {
|
|
console.error(`Final attempt failed for ${apiUrl}.`);
|
|
throw error;
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
delay *= 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function generateStoryOptionsWithGemini() {
|
|
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent`;
|
|
let userQuery;
|
|
if (selectedAudience === 'Unter 6') {
|
|
userQuery = `Erstelle zufällige, sehr einfache Elemente für eine fröhliche Kindergeschichte für Kleinkinder (unter 6), die nicht lesen können. Der Fokus liegt auf dem Emoji, der Text sollte nur ein einziges, einfaches Wort sein (z.B. 'Bär', 'Schloss', 'tanzen'). Gib mir 3 Helden (protagonist), 3 Orte (location) und 3 Ereignisse (event). Die Helden sollten einfache, aber auch mal überraschende Tiere sein (z.B. 'Löwe', 'Pinguin', 'Dino'). Die Orte sollen immer mit einer passenden Präposition beginnen (z.B. 'Im...'). Jeder Eintrag soll einen 'text' und ein passendes 'emoji' haben. Antworte ausschließlich mit dem JSON-Objekt.`;
|
|
} else if (selectedAudience === '12 bis 17') {
|
|
userQuery = `Erstelle Elemente für eine Jugendgeschichte (12-17 Jahre). Die Themen sollten altersgerecht sein: Abenteuer, Freundschaft, Selbstfindung, Mystery, Fantasy oder Coming-of-Age. Gib mir 3 Protagonisten (protagonist), 3 Orte (location) und 3 Ereignisse (event). Die Protagonisten können Teenager mit bestimmten Eigenschaften sein (z.B. 'eine mutige Skateboarderin', 'ein schüchterner Hacker'). Die Orte können alltäglich oder fantastisch sein. Die Ereignisse sollten spannend sein (z.B. 'findet eine geheimnisvolle Karte'). Jeder Eintrag soll einen 'text' und ein passendes 'emoji' haben. Antworte ausschließlich mit dem JSON-Objekt.`;
|
|
} else if (selectedAudience === 'Erwachsene') {
|
|
userQuery = `Erstelle Elemente für eine Kurzgeschichte für Erwachsene im Stil eines Groschenromans mit einem Augenzwinkern. Die Themen sind Leidenschaft, Drama, Intrigen. Gib mir 3 Protagonisten (protagonist, z.B. 'eine geheimnisvolle Gräfin', 'ein charmanter, aber verarmter Künstler'), 3 Orte (location, z.B. 'auf einem opulenten Maskenball', 'in einer verlassenen Villa an der Küste') und 3 Ereignisse (event, z.B. 'trifft eine verbotene Liebe', 'deckt ein Familiengeheimnis auf'). Jeder Eintrag soll einen 'text' und ein passendes 'emoji' haben. Antworte ausschließlich mit dem JSON-Objekt.`;
|
|
} else { // 6 bis 12
|
|
userQuery = `Erstelle zufällige, kreative Elemente für eine fröhliche Kindergeschichte, passend für Kinder im Alter von 6-12 Jahren. Sorge für eine gute Mischung aus klassischen und überraschenden Ideen. Gib mir 3 Helden (protagonist), 3 Orte (location) und 3 Ereignisse (event). Für die Helden, sei besonders kreativ und sorge für eine gute Mischung aus bekannten Tieren (wie ein Hund), überraschenden Tieren (wie ein Faultier, ein T-Rex, ein Flamingo), Fabelwesen oder lustigen, lebendigen Gegenständen. Die Orte sollen immer mit einer passenden Präposition beginnen (z.B. 'In einem...'). Die Ereignisse sollen immer eine Handlung im Singular sein (z.B. 'findet einen Schatz'). Jeder Eintrag soll einen 'text' und ein passendes 'emoji' haben. Antworte ausschließlich mit dem JSON-Objekt.`;
|
|
}
|
|
const schema = { type: "OBJECT", properties: { protagonist: { type: "ARRAY", items: { type: "OBJECT", properties: { text: { type: "STRING" }, emoji: { type: "STRING" } }, required: ["text", "emoji"] } }, location: { type: "ARRAY", items: { type: "OBJECT", properties: { text: { type: "STRING" }, emoji: { type: "STRING" } }, required: ["text", "emoji"] } }, event: { type: "ARRAY", items: { type: "OBJECT", properties: { text: { type: "STRING" }, emoji: { type: "STRING" } }, required: ["text", "emoji"] } } }, required: ["protagonist", "location", "event"] };
|
|
const payload = { contents: [{ parts: [{ text: userQuery }] }], generationConfig: { responseMimeType: "application/json", responseSchema: schema } };
|
|
const result = await fetchWithBackoff(apiUrl, payload);
|
|
const jsonText = result.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
if (!jsonText) throw new Error("Konnte keine gültigen Story-Optionen von der API empfangen.");
|
|
return JSON.parse(jsonText);
|
|
}
|
|
|
|
async function generateStoryTextWithGemini() {
|
|
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent`;
|
|
let systemPrompt;
|
|
if (selectedAudience === '12 bis 17') {
|
|
systemPrompt = `Du bist ein Autor für Jugendbücher. Schreibe eine kurze, fesselnde und abgeschlossene Geschichte für Jugendliche im Alter von 12-17 Jahren. Die Geschichte sollte einen klaren Anfang, eine Mitte mit einer Herausforderung oder einem Konflikt und ein befriedigendes Ende haben. Behandle Themen wie Freundschaft, Mut, Selbstfindung oder Abenteuer. Die Geschichte sollte ca. 300 Wörter lang sein. Der Ton kann nachdenklich, abenteuerlich oder humorvoll sein, aber immer altersgerecht und respektvoll. Gib der Hauptfigur einen passenden Namen und eine Persönlichkeit. Antworte nur mit der Geschichte selbst, ohne zusätzliche Kommentare.`;
|
|
} else if (selectedAudience === 'Erwachsene') {
|
|
systemPrompt = `Du bist ein Autor von unterhaltsamen Kurzgeschichten für Erwachsene im Stil eines Groschenromans. Schreibe eine abgeschlossene, dramatische und leidenschaftliche Kurzgeschichte (ca. 300 Wörter) mit einem Augenzwinkern. Der Stil ist leicht übertrieben und unterhaltsam. Die Geschichte muss einen Anfang, einen Höhepunkt und einen klaren, oft überraschenden Schluss haben. Antworte nur mit der Geschichte selbst, ohne zusätzliche Kommentare.`;
|
|
} else { // Kinder
|
|
let agePrompt = '';
|
|
if (selectedAudience === 'Unter 6') {
|
|
agePrompt = 'sehr einfach, ca. 100 Wörter. Gib der Hauptfigur einen einfachen, lautmalerischen Namen.';
|
|
} else { // 6 bis 12
|
|
agePrompt = 'einfach, aber fesselnd, ca. 250 Wörter. Gib der Hauptfigur einen passenden und kreativen Namen.';
|
|
}
|
|
systemPrompt = `Du bist ein kreativer Märchenerzähler für Kinder. Deine Aufgabe ist es, eine fröhliche, gewaltfreie und positive Geschichte zu erfinden. Verwende nicht einfach die Tier- oder Objektbezeichnung als Namen (z.B. nenne einen Dino nicht 'Dino'). Passe die Komplexität des Wortschatzes, die Satzlänge und die Gesamtlänge der Geschichte an das Alter an: ${agePrompt}. Die Geschichte sollte immer gut enden und eine positive Botschaft haben. Beginne jede Geschichte mit 'Es war einmal...'. Antworte nur mit der Geschichte selbst, ohne zusätzliche Kommentare.`;
|
|
}
|
|
const userQuery = `Erzähle eine Geschichte über: ${userSelection.protagonist.text}, der/die ${userSelection.location.text} ${userSelection.event.text}.`;
|
|
const payload = { contents: [{ parts: [{ text: userQuery }] }], systemInstruction: { parts: [{ text: systemPrompt }] } };
|
|
const result = await fetchWithBackoff(apiUrl, payload);
|
|
if (!result.candidates?.[0]?.content?.parts?.[0]?.text) throw new Error("Konnte keine gültige Geschichte von der API empfangen.");
|
|
return result.candidates[0].content.parts[0].text;
|
|
}
|
|
|
|
async function createTitleFromStory(storyText) {
|
|
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent`;
|
|
let systemPrompt;
|
|
if (selectedAudience === '12 bis 17') {
|
|
systemPrompt = `Du bist ein Autor für Jugendbücher. Finde einen kurzen, fesselnden und modernen Titel (maximal 6 Wörter) für die folgende Geschichte. Der Titel sollte neugierig machen. Antworte nur mit dem Titel selbst, ohne Anführungszeichen oder zusätzliche Kommentare.`;
|
|
} else if (selectedAudience === 'Erwachsene') {
|
|
systemPrompt = `Du bist ein Autor von Groschenromanen. Finde einen kurzen, dramatischen und reißerischen Titel (maximal 6 Wörter) für die folgende Geschichte. Der Titel sollte mit einem Augenzwinkern die Neugier wecken. Antworte nur mit dem Titel selbst, ohne Anführungszeichen oder zusätzliche Kommentare.`;
|
|
} else { // Kinder
|
|
systemPrompt = `Du bist ein kreativer Autor für Kinderbücher. Finde einen kurzen, magischen und einprägsamen Titel (maximal 5 Wörter) für die folgende Geschichte. Der Titel sollte zur Altersgruppe "${selectedAudience}" passen. Antworte nur mit dem Titel selbst, ohne Anführungszeichen oder zusätzliche Kommentare.`;
|
|
}
|
|
const userQuery = `Finde einen Titel für diese Geschichte: ${storyText}`;
|
|
const payload = { contents: [{ parts: [{ text: userQuery }] }], systemInstruction: { parts: [{ text: systemPrompt }] } };
|
|
const result = await fetchWithBackoff(apiUrl, payload);
|
|
const title = result.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
if (!title) throw new Error("Could not generate a valid title.");
|
|
return title.trim();
|
|
}
|
|
|
|
async function createImagePromptFromStory(storyText) {
|
|
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent`;
|
|
let systemPrompt;
|
|
if (selectedAudience === '12 bis 17') {
|
|
systemPrompt = `Summarize the following youth story in a single, descriptive sentence for an image AI. Focus on the character and atmosphere. IMPORTANT: The image must be purely visual. Strictly ensure NO text, NO words, NO labels, NO typography, and NO letters appear anywhere in the image. The sentence should be in English.`;
|
|
} else if(selectedAudience === 'Erwachsene') {
|
|
systemPrompt = `Summarize the following dramatic story in a single, descriptive sentence for an image AI. Focus on the emotional scene and setting. IMPORTANT: The image must be purely visual. Strictly ensure NO text, NO words, NO labels, and NO letters appear in the image. The sentence should be in English.`;
|
|
} else {
|
|
systemPrompt = `Summarize the following children's story in a single, descriptive sentence for an image AI. Focus on the character and central action. Style should match ${selectedAudience}. IMPORTANT: The image must be purely visual. Strictly ensure NO text, NO labels, and NO letters appear in the image. The sentence should be in English.`;
|
|
}
|
|
const userQuery = `Fasse diese Geschichte zusammen: ${storyText}`;
|
|
const payload = { contents: [{ parts: [{ text: userQuery }] }], systemInstruction: { parts: [{ text: systemPrompt }] } };
|
|
const result = await fetchWithBackoff(apiUrl, payload);
|
|
const imagePrompt = result.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
if (!imagePrompt) throw new Error("Could not generate a valid image prompt.");
|
|
return imagePrompt;
|
|
}
|
|
|
|
async function generateImageWithImagen(prompt) {
|
|
const apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image-preview:generateContent';
|
|
let stylePrompt;
|
|
if(selectedAudience === 'Erwachsene') {
|
|
stylePrompt = "Dramatic and colorful book cover illustration, pulp novel style, oil painting. No text, no words, no letters on cover.";
|
|
} else {
|
|
stylePrompt = {
|
|
"Unter 6": "Simple and cute children's book illustration, bold lines, bright colors, cartoon style. Absolutely no text, no labels, no letters.",
|
|
"6 bis 12": "Colorful and detailed fantasy illustration for a children's book, storybook style. Absolutely no text, no labels, no letters.",
|
|
"12 bis 17": "Young adult digital painting style, evocative lighting. Absolutely no text, no labels, no letters."
|
|
}[selectedAudience];
|
|
}
|
|
const finalPrompt = `${stylePrompt}: ${prompt}`;
|
|
const payload = {
|
|
contents: [{ parts: [{ text: finalPrompt }] }],
|
|
generationConfig: { responseModalities: ["TEXT", "IMAGE"] }
|
|
};
|
|
const result = await fetchWithBackoff(apiUrl, payload);
|
|
const imageDataPart = result.candidates?.[0]?.content?.parts?.find(p => p.inlineData);
|
|
const base64Data = imageDataPart?.inlineData?.data;
|
|
if (!base64Data) throw new Error('No image data received from API');
|
|
return `data:image/png;base64,${base64Data}`;
|
|
}
|
|
|
|
async function generateAudioWithGeminiTTS(storyText) {
|
|
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent`;
|
|
let voicePrompt, voiceName;
|
|
if(selectedAudience === '12 bis 17') {
|
|
voicePrompt = "Sprich den folgenden Text mit einer natürlichen, jugendlichen und engagierten Erzählerstimme:";
|
|
voiceName = "Leda";
|
|
} else if(selectedAudience === 'Erwachsene') {
|
|
voicePrompt = "Sprich den folgenden Text mit einer dramatischen, gefühlvollen Erzählerstimme mit einem leichten Augenzwinkern:";
|
|
voiceName = "Gacrux";
|
|
} else { // Kinder
|
|
voicePrompt = "Sprich den folgenden Text mit einer freundlichen, klaren und etwas lebhafteren Märchenerzähler-Stimme:";
|
|
voiceName = "Achird";
|
|
}
|
|
|
|
const payload = {
|
|
contents: [{ parts: [{ text: `${voicePrompt} ${storyText}` }] }],
|
|
generationConfig: {
|
|
responseModalities: ["AUDIO"],
|
|
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voiceName } } }
|
|
}
|
|
};
|
|
|
|
const result = await fetchWithBackoff(apiUrl, payload);
|
|
const audioPart = result.candidates?.[0]?.content?.parts?.find(p => p.inlineData && p.inlineData.data);
|
|
const audioData = audioPart?.inlineData?.data;
|
|
|
|
if (!audioData) {
|
|
console.error("Unerwartete API-Antwort beim Audio:", result);
|
|
throw new Error("Konnte keine gültigen Audiodaten empfangen.");
|
|
}
|
|
|
|
const pcmData = base64ToArrayBuffer(audioData);
|
|
const sampleRate = 24000;
|
|
return pcmToWav(new Int16Array(pcmData), sampleRate);
|
|
}
|
|
|
|
function base64ToArrayBuffer(base64) {
|
|
const binaryString = atob(base64);
|
|
const len = binaryString.length;
|
|
const bytes = new Uint8Array(len);
|
|
for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); }
|
|
return bytes.buffer;
|
|
}
|
|
|
|
function pcmToWav(pcmData, sampleRate) {
|
|
const numChannels = 1;
|
|
const bitsPerSample = 16;
|
|
const byteRate = sampleRate * numChannels * bitsPerSample / 8;
|
|
const blockAlign = numChannels * bitsPerSample / 8;
|
|
const dataSize = pcmData.length * pcmData.BYTES_PER_ELEMENT;
|
|
const buffer = new ArrayBuffer(44 + dataSize);
|
|
const view = new DataView(buffer);
|
|
function writeString(view, offset, string) { for (let i = 0; i < string.length; i++) { view.setUint8(offset + i, string.charCodeAt(i)); } }
|
|
writeString(view, 0, 'RIFF'); view.setUint32(4, 36 + dataSize, true); writeString(view, 8, 'WAVE');
|
|
writeString(view, 12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
|
|
view.setUint16(22, numChannels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, byteRate, true);
|
|
view.setUint16(32, blockAlign, true); view.setUint16(34, bitsPerSample, true);
|
|
writeString(view, 36, 'data'); view.setUint32(40, dataSize, true);
|
|
new Int16Array(buffer, 44).set(pcmData);
|
|
return new Blob([view], { type: 'audio/wav' });
|
|
}
|
|
|
|
// --- UI & ANIMATION ---
|
|
function renderCategory(category, options) {
|
|
const cardGrid = document.querySelector(`[data-category="${category}"]`);
|
|
if (!cardGrid) return;
|
|
|
|
if (selectedAudience === 'Unter 6') {
|
|
cardGrid.innerHTML = options.map(option => `
|
|
<div class="card bg-white p-2 rounded-2xl border-4 border-transparent shadow-md cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg flex flex-col items-center justify-center text-center space-y-1 aspect-square" data-text="${option.text}">
|
|
<span class="text-6xl sm:text-8xl">${option.emoji}</span>
|
|
<p class="font-medium text-gray-700 hidden">${option.text}</p>
|
|
</div>`).join('');
|
|
} else {
|
|
cardGrid.innerHTML = options.map(option => `
|
|
<div class="card bg-white p-2 rounded-2xl border-4 border-transparent shadow-md cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg flex flex-col items-center justify-center text-center space-y-1 aspect-square" data-text="${option.text}">
|
|
<span class="text-4xl sm:text-6xl">${option.emoji}</span>
|
|
<p class="font-medium text-gray-700 text-sm leading-tight">${option.text}</p>
|
|
</div>`).join('');
|
|
}
|
|
cardGrid.querySelectorAll('.card').forEach(card => card.addEventListener('click', handleCardClick));
|
|
}
|
|
|
|
async function initializeUI() {
|
|
try {
|
|
elements.optionsLoader.classList.remove('hidden');
|
|
elements.cardArea.innerHTML = '';
|
|
storyParts = await generateStoryOptionsWithGemini();
|
|
|
|
for (const category in storyParts) {
|
|
shuffleArray(storyParts[category]);
|
|
}
|
|
|
|
elements.optionsLoader.classList.add('hidden');
|
|
|
|
const categoryHeadings = {
|
|
isChild: { protagonist: "Wer ist dein Held?", location: "Wo spielt die Geschichte?", event: "Was passiert Aufregendes?" },
|
|
isTeen: { protagonist: "Wer ist die Hauptfigur?", location: "Wo spielt die Geschichte?", event: "Was ist das zentrale Ereignis?" },
|
|
isAdult: { protagonist: "Wer ist die Hauptfigur?", location: "Wo spielt die Szene?", event: "Was ist das zentrale Ereignis?" }
|
|
};
|
|
|
|
let currentHeadings;
|
|
if (selectedAudience === '12 bis 17') {
|
|
currentHeadings = categoryHeadings.isTeen;
|
|
} else if (selectedAudience === 'Erwachsene') {
|
|
currentHeadings = categoryHeadings.isAdult;
|
|
} else {
|
|
currentHeadings = categoryHeadings.isChild;
|
|
}
|
|
|
|
|
|
for (const category in storyParts) {
|
|
const options = storyParts[category];
|
|
const categoryDiv = document.createElement('div');
|
|
categoryDiv.className = 'category-container flex flex-col space-y-4';
|
|
categoryDiv.id = `container-${category}`;
|
|
categoryDiv.innerHTML = `<h2 class="text-2xl font-bold text-teal-700">${currentHeadings[category]}</h2>
|
|
<div class="card-grid" data-category="${category}"></div>`;
|
|
elements.cardArea.appendChild(categoryDiv);
|
|
renderCategory(category, options);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to initialize UI:", error);
|
|
elements.optionsLoader.classList.add('hidden');
|
|
|
|
if (error.message === "LIMIT_EXCEEDED") {
|
|
elements.errorMessage.textContent = "Hoppla! Das magische Tageslimit der API ist leider erreicht (10 Geschichten pro Tag). Bitte versuche es morgen wieder!";
|
|
} else {
|
|
elements.errorMessage.textContent = "Hoppla! Die Verbindung zur Ideen-Quelle ist gestört. Bitte lade die Seite neu.";
|
|
}
|
|
|
|
elements.errorArea.classList.remove('hidden');
|
|
elements.generateButton.style.display = 'none';
|
|
elements.reloadOptionsButton.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function checkSelections() {
|
|
const totalCategories = Object.keys(storyParts).length;
|
|
const selectedCategories = Object.keys(userSelection).length;
|
|
elements.generateButton.disabled = selectedCategories < totalCategories;
|
|
}
|
|
|
|
function handleCardClick(event) {
|
|
const card = event.currentTarget;
|
|
const category = card.parentElement.dataset.category;
|
|
const text = card.dataset.text;
|
|
userSelection[category] = { text };
|
|
const allCardsInCategory = document.querySelectorAll(`[data-category="${category}"] .card`);
|
|
allCardsInCategory.forEach(c => {
|
|
c.classList.remove('card-selected', 'border-amber-400', 'bg-amber-50');
|
|
c.classList.add('border-transparent', 'bg-white');
|
|
});
|
|
card.classList.add('card-selected', 'border-amber-400', 'bg-amber-50');
|
|
card.classList.remove('border-transparent', 'bg-white');
|
|
checkSelections();
|
|
}
|
|
|
|
function setupKaraoke(storyText, audioUrl) {
|
|
const words = storyText.match(/\S+\s*/g) || [];
|
|
elements.storyTextContent.innerHTML = words.map((word) => `<span>${word}</span>`).join('');
|
|
wordSpans = Array.from(elements.storyTextContent.children);
|
|
wordTimings = [];
|
|
|
|
elements.audioPlayer.addEventListener('loadedmetadata', () => {
|
|
const duration = elements.audioPlayer.duration;
|
|
if (duration && isFinite(duration)) {
|
|
let accumulatedTime = 0;
|
|
const totalChars = storyText.replace(/\s+/g, '').length;
|
|
if (totalChars === 0) return;
|
|
|
|
wordSpans.forEach(span => {
|
|
const wordCharLength = span.textContent.replace(/\s+/g, '').length;
|
|
const estimatedWordDuration = (wordCharLength / totalChars) * duration;
|
|
wordTimings.push({
|
|
startTime: accumulatedTime,
|
|
endTime: accumulatedTime + estimatedWordDuration
|
|
});
|
|
accumulatedTime += estimatedWordDuration;
|
|
});
|
|
}
|
|
}, { once: true });
|
|
|
|
elements.audioPlayer.src = audioUrl;
|
|
}
|
|
|
|
function updateHighlight() {
|
|
const currentTime = elements.audioPlayer.currentTime;
|
|
let activeIndex = wordTimings.findIndex(timing => currentTime >= timing.startTime && currentTime < timing.endTime);
|
|
|
|
if (activeIndex !== currentHighlightIndex) {
|
|
if (currentHighlightIndex > -1 && wordSpans[currentHighlightIndex]) {
|
|
wordSpans[currentHighlightIndex].classList.remove('highlight');
|
|
}
|
|
if (activeIndex > -1 && wordSpans[activeIndex]) {
|
|
wordSpans[activeIndex].classList.add('highlight');
|
|
}
|
|
currentHighlightIndex = activeIndex;
|
|
}
|
|
}
|
|
|
|
async function generateStory() {
|
|
elements.selectionArea.classList.add('hidden');
|
|
elements.audienceSelectorContainer.classList.add('hidden');
|
|
elements.mainHeader.classList.add('hidden');
|
|
elements.resultArea.classList.remove('hidden');
|
|
elements.errorArea.classList.add('hidden');
|
|
|
|
// Reset visibility classes and show placeholders
|
|
document.querySelectorAll('.reveal-item').forEach(el => el.classList.remove('visible'));
|
|
elements.storyTextContent.innerHTML = '';
|
|
elements.resultTitle.innerHTML = '';
|
|
elements.titlePlaceholder.classList.remove('hidden');
|
|
elements.textPlaceholder.classList.remove('hidden');
|
|
elements.audioPlaceholder.classList.remove('hidden');
|
|
elements.audioPlayer.classList.add('hidden');
|
|
elements.finalButtons.classList.add('hidden');
|
|
|
|
updateProgress(5, 'Die Maschine läuft warm...');
|
|
|
|
try {
|
|
startCreeping(20); // Langsam auf 20% kriechen
|
|
const storyText = await generateStoryTextWithGemini();
|
|
updateProgress(25, 'Die Geschichte ist geschrieben!');
|
|
elements.textPlaceholder.classList.add('hidden');
|
|
elements.storyTextContent.textContent = storyText;
|
|
elements.storyTextContent.parentElement.classList.add('visible');
|
|
|
|
startCreeping(40); // Langsam weiterkriechen während die Parallel-Tasks laufen
|
|
|
|
// Task 1: Titel (erscheint sobald fertig)
|
|
const titlePromise = createTitleFromStory(storyText).then(storyTitle => {
|
|
updateProgress(Math.max(currentProgress, 35), 'Ein Titel wurde gefunden...');
|
|
elements.titlePlaceholder.classList.add('hidden');
|
|
elements.resultTitle.textContent = storyTitle;
|
|
elements.resultTitle.classList.add('visible');
|
|
return storyTitle;
|
|
});
|
|
|
|
// Task 2: Bild (Prompt -> Bild)
|
|
const imagePromise = (async () => {
|
|
const imagePrompt = await createImagePromptFromStory(storyText);
|
|
updateProgress(Math.max(currentProgress, 50), 'Die Illustration wird gemalt...');
|
|
const imageUrl = await generateImageWithImagen(imagePrompt);
|
|
elements.storyImage.src = imageUrl;
|
|
elements.storyImage.onload = () => {
|
|
elements.storyImage.classList.add('loaded');
|
|
updateProgress(Math.max(currentProgress, 70), 'Das Bild ist fertig!');
|
|
};
|
|
return imageUrl;
|
|
})();
|
|
|
|
// Task 3: Audio (dauert meist am längsten)
|
|
const audioPromise = (async () => {
|
|
const audioBlob = await generateAudioWithGeminiTTS(storyText);
|
|
const audioUrl = URL.createObjectURL(audioBlob);
|
|
setupKaraoke(storyText, audioUrl);
|
|
elements.downloadButton.href = audioUrl;
|
|
elements.audioPlaceholder.classList.add('hidden');
|
|
elements.audioPlayer.classList.remove('hidden');
|
|
elements.audioContainer.classList.add('visible');
|
|
updateProgress(Math.max(currentProgress, 90), 'Die Stimme ist bereit!');
|
|
return audioBlob;
|
|
})();
|
|
|
|
// Warten auf alles, aber mit zusätzlichem "Creeping"
|
|
startCreeping(95);
|
|
await Promise.all([titlePromise, imagePromise, audioPromise]);
|
|
|
|
updateProgress(100, 'Alles fertig! Viel Spaß beim Lesen!');
|
|
elements.finalButtons.classList.remove('hidden');
|
|
elements.finalButtons.classList.add('visible');
|
|
|
|
// Progress-Bar nach kurzem Moment ausblenden
|
|
setTimeout(() => {
|
|
elements.progressContainer.classList.add('hidden');
|
|
}, 2000);
|
|
|
|
} catch (error) {
|
|
stopCreeping();
|
|
console.error("Story generation failed:", error);
|
|
if (error.message === "LIMIT_EXCEEDED") {
|
|
elements.errorMessage.textContent = "Hoppla! Das magische Tageslimit der API ist leider erreicht (10 Geschichten pro Tag). Bitte versuche es morgen wieder!";
|
|
} else {
|
|
elements.errorMessage.textContent = "Oh nein! Die magische Tinte ist leer. Bitte versuche es noch einmal.";
|
|
}
|
|
elements.errorArea.classList.remove('hidden');
|
|
elements.resultArea.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
function resetApp() {
|
|
stopCreeping();
|
|
if (elements.audioPlayer.src.startsWith('blob:')) {
|
|
URL.revokeObjectURL(elements.audioPlayer.src);
|
|
}
|
|
Object.keys(userSelection).forEach(key => delete userSelection[key]);
|
|
|
|
elements.resultArea.classList.add('hidden');
|
|
elements.errorArea.classList.add('hidden');
|
|
elements.selectionArea.classList.remove('hidden');
|
|
elements.audienceSelectorContainer.classList.remove('hidden');
|
|
elements.mainHeader.classList.remove('hidden');
|
|
|
|
document.querySelectorAll('.reveal-item').forEach(el => el.classList.remove('visible'));
|
|
elements.titlePlaceholder.classList.remove('hidden');
|
|
elements.textPlaceholder.classList.remove('hidden');
|
|
elements.audioPlaceholder.classList.remove('hidden');
|
|
|
|
elements.storyImage.src = "";
|
|
elements.storyImage.classList.remove('loaded');
|
|
elements.storyTextContent.innerHTML = '';
|
|
elements.resultTitle.innerHTML = '';
|
|
|
|
wordSpans = [];
|
|
wordTimings = [];
|
|
currentHighlightIndex = -1;
|
|
|
|
elements.progressContainer.classList.remove('hidden');
|
|
elements.progressBar.style.width = `0%`;
|
|
|
|
// Reset categories view
|
|
elements.cardArea.innerHTML = ''; // Clear generated cards
|
|
elements.initialPromptMessage.classList.remove('hidden');
|
|
|
|
// Deselect audience buttons
|
|
document.querySelectorAll('#audience-selector .selector-btn').forEach(btn => btn.classList.remove('selector-selected'));
|
|
selectedAudience = null;
|
|
checkSelections();
|
|
}
|
|
|
|
function handleAudienceSelection(event) {
|
|
const selectedButton = event.currentTarget;
|
|
selectedAudience = selectedButton.dataset.audience;
|
|
|
|
document.querySelectorAll('#audience-selector .selector-btn').forEach(btn => btn.classList.remove('selector-selected'));
|
|
selectedButton.classList.add('selector-selected');
|
|
|
|
Object.keys(userSelection).forEach(key => delete userSelection[key]);
|
|
checkSelections();
|
|
elements.initialPromptMessage.classList.add('hidden');
|
|
initializeUI();
|
|
}
|
|
|
|
// --- EVENT LISTENERS ---
|
|
elements.generateButton.addEventListener('click', generateStory);
|
|
elements.resetButton.addEventListener('click', resetApp);
|
|
elements.reloadOptionsButton.addEventListener('click', () => {
|
|
Object.keys(userSelection).forEach(key => delete userSelection[key]);
|
|
checkSelections();
|
|
initializeUI();
|
|
});
|
|
elements.fullscreenButton.addEventListener('click', toggleFullScreen);
|
|
document.addEventListener('fullscreenchange', updateFullscreenIcons);
|
|
elements.audienceSelector.querySelectorAll('.selector-btn').forEach(btn => {
|
|
btn.addEventListener('click', handleAudienceSelection);
|
|
});
|
|
elements.audioPlayer.addEventListener('timeupdate', updateHighlight);
|
|
elements.audioPlayer.addEventListener('ended', () => {
|
|
if (currentHighlightIndex !== -1 && wordSpans[currentHighlightIndex]) {
|
|
wordSpans[currentHighlightIndex].classList.remove('highlight');
|
|
}
|
|
currentHighlightIndex = -1;
|
|
});
|
|
|
|
|
|
// --- INITIALIZE ---
|
|
checkSelections();
|
|
|
|
if (!document.fullscreenEnabled) {
|
|
elements.fullscreenButton.style.display = 'none';
|
|
}
|
|
});
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|