first commit

This commit is contained in:
Ashim Kumar
2026-01-09 21:06:30 +06:00
commit 11d715eb85
19 changed files with 8235 additions and 0 deletions

70
.dockerignore Normal file
View File

@@ -0,0 +1,70 @@
# Git
.git
.gitignore
.gitattributes
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
venv/
.venv/
ENV/
env/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Testing
.pytest_cache/
.coverage
htmlcov/
# Local environment
.env
.env.local
*.local
# Database (will be mounted from volume)
*.db
*.sqlite
*.sqlite3
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
# Documentation
README.md
docs/
*.md
# Docker
Dockerfile
docker-compose*.yml
.docker/

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Flask Configuration
SECRET_KEY=your-super-secret-key-change-this-in-production
FLASK_ENV=production
# Database Configuration
# For production with Coolify, this will be mounted as a volume
DATABASE_DIR=/opt/apps/audiobook-studio-pro-v3
# TTS API Configuration
TTS_API_URL=http://your-tts-api-server:5010/api/v1
TTS_API_KEY=your-tts-api-key-here

80
.gitignore vendored Normal file
View File

@@ -0,0 +1,80 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
.venv/
ENV/
env/
.env
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
.project
.pydevproject
.settings/
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
# Database files (will be created at runtime)
*.db
*.sqlite
*.sqlite3
audio_editor.db
# Logs
*.log
logs/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Local configuration
.env
.env.local
*.local
config.local.py
# Docker
.docker/
# Temporary files
tmp/
temp/
*.tmp
*.temp

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# Use Python 3.11 slim image for smaller size
FROM python:3.11-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV FLASK_APP=app.py
ENV FLASK_ENV=production
ENV DATABASE_DIR=/app/data
# Set working directory
WORKDIR /app
# Install system dependencies including ffmpeg for pydub
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libsndfile1 \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create the default database directory inside the container
# This will be used if no volume is mounted
RUN mkdir -p /app/data && chmod 777 /app/data
# Also create the external mount point directory
RUN mkdir -p /opt/apps/audiobook-studio-pro-v3 && chmod 777 /opt/apps/audiobook-studio-pro-v3
# Expose port
EXPOSE 5009
# Health check - with longer start period to allow DB initialization
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:5009/health || exit 1
# Use entrypoint script to ensure directory exists
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "--bind", "0.0.0.0:5009", "--workers", "2", "--threads", "4", "--timeout", "300", "--access-logfile", "-", "--error-logfile", "-", "app:app"]

1778
app.py Normal file

File diff suppressed because it is too large Load Diff

26
coolify.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "audiobook-studio-pro-v3",
"build": {
"dockerfile": "Dockerfile"
},
"settings": {
"port": 5009,
"health_check_path": "/health",
"health_check_interval": 30,
"health_check_timeout": 10
},
"volumes": [
{
"source": "/opt/apps/audiobook-studio-pro-v3",
"target": "/opt/apps/audiobook-studio-pro-v3",
"type": "bind"
}
],
"environment": {
"SECRET_KEY": "${SECRET_KEY}",
"DATABASE_DIR": "/opt/apps/audiobook-studio-pro-v3",
"TTS_API_URL": "${TTS_API_URL}",
"TTS_API_KEY": "${TTS_API_KEY}",
"FLASK_ENV": "production"
}
}

256
doc.py Normal file
View File

@@ -0,0 +1,256 @@
import os
from pathlib import Path
import mimetypes
import markdown
# INPUT: Set your Application folder path here
APPLICATION_FOLDER = f'../Audio Transcription Editor' # Replace with your actual folder path
# Add these new variables
EXCLUDE_FOLDERS = {
'node_modules',
# 'node_modules',
# 'venv',
# 'env',
# '__pycache__',
# 'dist',
# 'build',
# '.pytest_cache'
}
EXCLUDE_FILES = {
'doc.py'
# '.DS_Store',
# 'Thumbs.db',
# 'package-lock.json'
}
# File extensions to exclude
EXCLUDE_EXTENSIONS = {
# '.pyc',
# '.pyo',
# '.log',
# '.tmp'
}
def is_text_file(file_path):
"""Check if a file is likely a text/code file based on extension and mime type."""
# Common code file extensions
code_extensions = {
'.py', '.js', '.html', '.css', '.java', '.cpp', '.c', '.h', '.hpp',
'.cs', '.php', '.rb', '.go', '.rs', '.swift', '.kt', '.ts', '.jsx',
'.tsx', '.vue', '.scss', '.sass', '.less', '.sql', '.json', '.xml',
'.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.sh', '.bat',
'.ps1', '.r', '.R', '.m', '.scala', '.clj', '.hs', '.elm', '.dart',
'.lua', '.pl', '.pm', '.tcl', '.awk', '.sed', '.dockerfile', '.md',
'.txt', '.log', '.gitignore', '.env', '.properties'
}
file_ext = Path(file_path).suffix.lower()
# Check by extension first
if file_ext in code_extensions:
return True
# Check by mime type for files without extension
if not file_ext:
mime_type, _ = mimetypes.guess_type(file_path)
if mime_type and mime_type.startswith('text/'):
return True
return False
def generate_tree_structure(root_path, prefix="", is_last=True, max_depth=None, current_depth=0):
"""Generate a tree-like directory structure."""
if max_depth is not None and current_depth > max_depth:
return ""
root = Path(root_path)
tree_str = ""
if current_depth == 0:
tree_str += f"{root.name}/\n"
try:
# Get all items and sort them (directories first, then files)
items = list(root.iterdir())
dirs = [item for item in items if item.is_dir() and not item.name.startswith('.') and item.name not in EXCLUDE_FOLDERS]
files = [item for item in items if item.is_file() and not item.name.startswith('.') and item.name not in EXCLUDE_FILES and item.suffix not in EXCLUDE_EXTENSIONS]
all_items = sorted(dirs) + sorted(files)
for i, item in enumerate(all_items):
is_last_item = i == len(all_items) - 1
if item.is_dir():
tree_str += f"{prefix}{'└── ' if is_last_item else '├── '}{item.name}/\n"
extension = " " if is_last_item else ""
tree_str += generate_tree_structure(
item,
prefix + extension,
is_last_item,
max_depth,
current_depth + 1
)
else:
tree_str += f"{prefix}{'└── ' if is_last_item else '├── '}{item.name}\n"
except PermissionError:
tree_str += f"{prefix}[Permission Denied]\n"
return tree_str
def generate_bash_command(root_folder):
"""Generate a bash command to recreate the directory and file structure."""
root_path = Path(root_folder)
dirs_to_create = []
files_to_create = []
for root, dirs, files in os.walk(root_folder, topdown=True):
# Skip hidden directories
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in EXCLUDE_FOLDERS]
for name in dirs:
dir_path = Path(root) / name
relative_dir = dir_path.relative_to(root_path)
dirs_to_create.append(f'"{relative_dir}"')
# Skip hidden files
files[:] = [f for f in files if not f.startswith('.') and f not in EXCLUDE_FILES and Path(f).suffix not in EXCLUDE_EXTENSIONS]
for name in files:
file_path = Path(root) / name
relative_file = file_path.relative_to(root_path)
files_to_create.append(f'"{relative_file}"')
command_parts = []
if dirs_to_create:
command_parts.append(f"mkdir -p {' '.join(dirs_to_create)}")
if files_to_create:
command_parts.append(f"touch {' '.join(files_to_create)}")
if not command_parts:
return "# No directories or files to create."
return " && ".join(command_parts)
def read_file_content(file_path):
"""Safely read file content with encoding detection."""
encodings = ['utf-8', 'utf-16', 'latin-1', 'cp1252']
for encoding in encodings:
try:
with open(file_path, 'r', encoding=encoding) as file:
return file.read()
except (UnicodeDecodeError, UnicodeError):
continue
except Exception as e:
return f"Error reading file: {str(e)}"
return "Unable to decode file content"
def get_language_from_extension(file_path):
"""Get the appropriate language identifier for markdown code blocks."""
ext = Path(file_path).suffix.lower()
language_map = {
'.py': 'python', '.js': 'javascript', '.ts': 'typescript', '.jsx': 'jsx',
'.tsx': 'tsx', '.html': 'html', '.css': 'css', '.scss': 'scss',
'.sass': 'sass', '.java': 'java', '.cpp': 'cpp', '.c': 'c', '.h': 'c',
'.hpp': 'cpp', '.cs': 'csharp', '.php': 'php', '.rb': 'ruby', '.go': 'go',
'.rs': 'rust', '.swift': 'swift', '.kt': 'kotlin', '.sql': 'sql',
'.json': 'json', '.xml': 'xml', '.yaml': 'yaml', '.yml': 'yaml',
'.toml': 'toml', '.sh': 'bash', '.bat': 'batch', '.ps1': 'powershell',
'.dockerfile': 'dockerfile', '.md': 'markdown', '.r': 'r', '.R': 'r',
'.scala': 'scala', '.clj': 'clojure', '.hs': 'haskell', '.lua': 'lua',
'.pl': 'perl', '.tcl': 'tcl',
}
return language_map.get(ext, 'text')
def generate_documentation(root_folder):
"""Generate complete markdown and HTML documentation for the project."""
root_path = Path(root_folder)
if not root_path.exists() or not root_path.is_dir():
print(f"Error: The folder '{root_folder}' does not exist or is not a directory.")
return
# Start building markdown content
markdown_content = [f"# {root_path.name} - Project Documentation\n"]
# Add project structure
markdown_content.append("## 📂 Project Structure\n")
markdown_content.append("```")
markdown_content.append(generate_tree_structure(root_path))
markdown_content.append("```\n")
# # Add bash command to recreate structure
# markdown_content.append("## ⚙️ Bash Command to Recreate Structure\n")
# markdown_content.append("```bash")
# markdown_content.append(generate_bash_command(root_path))
# markdown_content.append("```\n")
# Add files content
markdown_content.append("## 📄 Files Content\n")
for root, dirs, files in os.walk(root_folder):
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in EXCLUDE_FOLDERS]
for file in sorted(files):
if file.startswith('.') or file in EXCLUDE_FILES or Path(file).suffix in EXCLUDE_EXTENSIONS : continue
file_path = Path(root) / file
if is_text_file(file_path):
relative_path = file_path.relative_to(root_path)
markdown_content.append(f"### 📜 `{relative_path}`\n")
content = read_file_content(file_path)
language = get_language_from_extension(file_path)
markdown_content.append(f"```{language}\n{content}\n```\n")
final_markdown = '\n'.join(markdown_content)
# Write to markdown file
output_md_file = f"../{root_path.name}_documentation.md"
try:
with open(output_md_file, 'w', encoding='utf-8') as f:
f.write(final_markdown)
print(f"✅ Markdown documentation generated: {Path(output_md_file).resolve()}")
except Exception as e:
print(f"❌ Error writing markdown file: {str(e)}")
# Generate and write HTML file
html_template = """
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>{title}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; line-height: 1.6; padding: 2em; max-width: 1024px; margin: 0 auto; color: #333; }}
h1, h2, h3 {{ border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }}
code {{ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; background-color: #f6f8fa; padding: 0.2em 0.4em; margin: 0; font-size: 85%; border-radius: 6px; }}
pre {{ background-color: #f6f8fa; padding: 16px; overflow: auto; border-radius: 6px; }}
pre code {{ padding: 0; margin: 0; font-size: 100%; background-color: transparent; border: none; }}
</style></head><body>{content}</body></html>
"""
html_content = markdown.markdown(final_markdown, extensions=['fenced_code', 'tables'])
final_html = html_template.format(title=f"{root_path.name} Documentation", content=html_content)
output_html_file = f"../{root_path.name}_documentation.html"
try:
with open(output_html_file, 'w', encoding='utf-8') as f:
f.write(final_html)
print(f"✅ HTML documentation generated: {Path(output_html_file).resolve()}")
except Exception as e:
print(f"❌ Error writing HTML file: {str(e)}")
# Main execution
if __name__ == "__main__":
if not APPLICATION_FOLDER or APPLICATION_FOLDER == "/path/to/your/Application":
print("⚠️ Please set the APPLICATION_FOLDER variable to your actual folder path.")
else:
print(f"🚀 Generating documentation for: {APPLICATION_FOLDER}")
generate_documentation(APPLICATION_FOLDER)

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
version: '3.8'
services:
audiobook-studio:
build:
context: .
dockerfile: Dockerfile
container_name: audiobook-studio-pro
ports:
- "5009:5009"
environment:
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-in-production}
- DATABASE_DIR=/opt/apps/audiobook-studio-pro-v3
- TTS_API_URL=${TTS_API_URL:-http://localhost:5010/api/v1}
- TTS_API_KEY=${TTS_API_KEY:-}
- FLASK_ENV=production
volumes:
# Persist database outside container
- audiobook_data:/opt/apps/audiobook-studio-pro-v3
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5009/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
audiobook_data:
driver: local

503
reader_templates/Reader.html Executable file
View File

@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Interactive Reader (Local)</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<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=Lora:ital,wght@0,400..700;1,400..700&family=Poppins:wght@500;700&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
@import url("https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Poppins:wght@500;700&display=swap");
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes highlightFade { 0% { background-color: #c7d2fe; } 100% { background-color: transparent; } }
html { scroll-behavior: smooth; }
body {
background-image: linear-gradient(to top, #f3e7e9 0%, #e3eeff 99%, #e3eeff 100%);
color: #1f2937; font-family: "Lora", serif;
}
.story-title { font-family: "Poppins", sans-serif; font-weight: 700; font-size: 2.5rem; color: #111827; }
.story-subtitle { font-family: "Poppins", sans-serif; color: #4b5563; font-weight: 500; font-size: 1.1rem; }
.main-content-card {
background-color: rgba(255, 255, 255, 0.9); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-radius: 1rem; padding: 3rem 4rem; box-shadow: 0 10px 35px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2); max-width: 1400px; margin: 0 auto;
animation: fadeIn 0.5s ease-in-out;
}
#load-folder-btn {
background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%); background-size: 200% auto;
border: none; transition: all 0.4s ease-in-out !important; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
#load-folder-btn:hover { background-position: right center; transform: translateY(-3px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25); }
/* Typography */
.story-text-container { font-size: 36px; line-height: 2.1; color: #1f2937; cursor: pointer; }
.story-text-container h1, .story-text-container h2, .story-text-container h3 {
font-family: "Poppins", sans-serif; color: #111827;
line-height: 1.8; margin-top: 1.5em; margin-bottom: 0.8em;
}
.story-text-container h1 { font-size: 2.2em; }
.story-text-container h2 { font-size: 1.8em; }
.story-text-container h3 { font-size: 1.5em; }
.story-text-container p { margin-bottom: 1.2em; }
.story-text-container strong { font-weight: bold; }
.story-text-container em { font-style: italic; }
.sentence, .word { transition: all 0.15s ease; border-radius: 3px; border-bottom: 2px solid transparent; }
.word:hover { background-color: #f1f5f9; }
.current-sentence-bg {
-webkit-box-decoration-break: clone; box-decoration-break: clone; background-color: #e0e7ff;
padding: 0.1em 0.25em; margin: 0 -0.2em; border-radius: 8px;
}
.current-word { color: #3d4e81; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; font-weight: 700; }
.resume-highlight { animation: highlightFade 2.5s ease-out forwards; }
/* Floating Button */
#floating-player-btn {
position: fixed; top: 2rem; right: 2rem; height: 60px; min-width: 60px; padding: 0 24px;
border-radius: 30px; background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%);
background-size: 200% auto; border: none; color: white; box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
display: none; align-items: center; justify-content: center; cursor: pointer; z-index: 1050;
transition: transform 0.2s ease-out, opacity 0.3s ease-out, width 0.3s ease, padding 0.3s ease, border-radius 0.3s ease;
opacity: 0; transform: scale(0.8);
}
#floating-player-btn.visible { display: flex; opacity: 1; transform: scale(1); }
#floating-player-btn:hover { transform: scale(1.05); }
#floating-player-btn:active { transform: scale(0.95); }
#floating-player-btn svg { width: 28px; height: 28px; }
#fp-start-text { font-weight: 600; margin-right: 10px; font-family: "Poppins", sans-serif; font-size: 1.1rem; display: inline-block; }
#floating-player-btn.active-mode { width: 60px; padding: 0; border-radius: 50%; }
/* Navigation */
#story-nav {
position: fixed; left: 2rem; top: 50%; background-color: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-radius: 25px; padding: 0.5rem;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); z-index: 1000; max-height: 80vh; overflow-y: auto;
opacity: 0; transform: translateY(-50%) translateX(-20px); transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
#story-nav.visible { opacity: 1; transform: translateY(-50%) translateX(0); }
#story-nav ul { padding-left: 0; margin-bottom: 0; }
#story-nav a {
display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; margin: 0.5rem 0;
border-radius: 50%; text-decoration: none; color: #4b5563; font-family: "Poppins", sans-serif;
font-weight: 500; background-color: transparent; transition: all 0.3s ease;
}
#story-nav a:hover { background-color: #e0e7ff; color: #3d4e81; }
#story-nav a.active { background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%); color: white; transform: scale(1.1); }
@media (max-width: 992px) {
#story-nav { left: 1rem; }
#story-nav a { width: 35px; height: 35px; font-size: 0.9rem; }
}
@media (max-width: 768px) {
.main-content-card { padding: 1.5rem; }
.story-title { font-size: 2rem; }
.story-text-container { font-size: 24px; line-height: 1.9; }
#floating-player-btn { top: 1rem; right: 1rem; height: 50px; min-width: 50px; }
#floating-player-btn.active-mode { width: 50px; }
#floating-player-btn svg { width: 22px; height: 22px; }
#story-nav { display: none; }
}
</style>
</head>
<body>
<nav id="story-nav">
<ul id="story-nav-list" class="list-unstyled"></ul>
</nav>
<button id="floating-player-btn">
<span id="fp-start-text">Start</span>
<svg id="fp-pause-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display: none;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<svg id="fp-play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<div class="container-fluid my-5 px-md-5">
<main id="main-content" class="main-content-card">
<header class="text-center mb-5" id="main-header">
<h1 class="story-title">Interactive Reader</h1>
<p class="story-subtitle">Select your assets folder to begin.</p>
</header>
<div id="resume-alert" class="alert alert-info d-flex justify-content-between align-items-center" style="display: none;">
<span>Welcome back! Resume from where you left off?</span>
<button id="resume-btn" class="btn btn-primary btn-sm">Resume Playback</button>
</div>
<div class="text-center" id="loader-section">
<p>Please select the folder containing your files (e.g., the 'assets' folder).</p>
<input type="file" id="folder-input" webkitdirectory directory multiple style="display: none;" />
<button id="load-folder-btn" class="btn btn-dark btn-lg">Select a Folder</button>
<div id="info-alert" class="alert mt-4" style="display: none;"></div>
</div>
<div id="stories-main-container" class="d-none"></div>
</main>
<footer class="text-center text-muted mt-4"></footer>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const mainContainer = document.getElementById("stories-main-container");
const folderInput = document.getElementById("folder-input");
const loadFolderBtn = document.getElementById("load-folder-btn");
const floatingPlayerBtn = document.getElementById("floating-player-btn");
const fpStartText = document.getElementById("fp-start-text");
const fpPauseIcon = document.getElementById("fp-pause-icon");
const fpPlayIcon = document.getElementById("fp-play-icon");
const storyNav = document.getElementById("story-nav");
const storyNavList = document.getElementById("story-nav-list");
const resumeAlert = document.getElementById('resume-alert');
const resumeBtn = document.getElementById('resume-btn');
let storyInstances = [];
let currentlyPlayingInstance = null;
let navObserver;
let currentBookId = null;
let validStoryParts = [];
const PROGRESS_KEY = 'interactiveReaderProgress';
loadFolderBtn.addEventListener("click", () => folderInput.click());
folderInput.addEventListener("change", handleFolderSelection);
mainContainer.addEventListener("click", handleTextClick);
floatingPlayerBtn.addEventListener('click', handleFloatingBtnClick);
window.addEventListener('beforeunload', saveCurrentProgress);
function saveCurrentProgress() {
if (!currentlyPlayingInstance || !currentBookId) return;
const instanceIndex = storyInstances.indexOf(currentlyPlayingInstance);
const timestamp = currentlyPlayingInstance.getAudioElement().currentTime;
saveProgress(currentBookId, instanceIndex, timestamp);
}
function saveProgress(bookId, instanceIndex, timestamp) {
const progress = { bookId, instanceIndex, timestamp, lastUpdate: Date.now() };
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
}
function loadProgress(bookId) {
const savedProgress = localStorage.getItem(PROGRESS_KEY);
if (!savedProgress) return;
const progress = JSON.parse(savedProgress);
if (progress.bookId !== bookId) return;
const targetInstance = storyInstances[progress.instanceIndex];
if (!targetInstance) return;
resumeAlert.style.display = 'flex';
resumeBtn.onclick = () => {
resumeAlert.style.display = 'none';
currentlyPlayingInstance = targetInstance;
targetInstance.playAt(progress.timestamp);
};
}
function updateFloatingButton(state) {
if (fpStartText) fpStartText.style.display = 'none';
floatingPlayerBtn.classList.add('active-mode');
if (state === 'playing') {
fpPauseIcon.style.display = 'block';
fpPlayIcon.style.display = 'none';
} else {
fpPauseIcon.style.display = 'none';
fpPlayIcon.style.display = 'block';
}
}
function handleFloatingBtnClick() {
if (!currentlyPlayingInstance) {
if (storyInstances.length > 0) {
currentlyPlayingInstance = storyInstances[0];
currentlyPlayingInstance.playAt(0);
updateFloatingButton('playing');
}
return;
}
currentlyPlayingInstance.getAudioElement().paused ? currentlyPlayingInstance.play() : currentlyPlayingInstance.pause();
}
function handleTextClick(event) {
const wordSpan = event.target.closest(".word");
if (!wordSpan) return;
const storyBlock = event.target.closest('.story-block');
const instanceIndex = parseInt(storyBlock.dataset.instanceIndex, 10);
const targetInstance = storyInstances[instanceIndex];
// SMART SYNC TIME LOOKUP
const timestamp = targetInstance.getTimeForSpan(wordSpan);
if (timestamp !== null) {
wordSpan.scrollIntoView({ behavior: 'auto', block: 'center' });
if (currentlyPlayingInstance && currentlyPlayingInstance !== targetInstance) {
currentlyPlayingInstance.stopAndReset();
}
currentlyPlayingInstance = targetInstance;
currentlyPlayingInstance.playAt(timestamp);
}
}
function handleFolderSelection(event) {
const files = event.target.files; if (files.length === 0) return;
const storyPartsMap = new Map();
for (const file of files) {
const match = file.name.match(/^([\d\.]+_)/);
if (match) {
const prefix = match[0];
const extension = file.name.split('.').pop().toLowerCase();
if (!storyPartsMap.has(prefix)) storyPartsMap.set(prefix, {});
const part = storyPartsMap.get(prefix);
if (['wav', 'mp3'].includes(extension)) part.audioFile = file;
else if (extension === 'txt') part.textFile = file;
else if (extension === 'json') part.jsonFile = file;
else if (['jpg', 'jpeg', 'png', 'gif'].includes(extension)) part.imageFile = file;
}
}
validStoryParts = Array.from(storyPartsMap.entries())
.filter(([p, f]) => f.audioFile && f.textFile && f.jsonFile)
.sort(([pA], [pB]) => pA.localeCompare(pB, undefined, { numeric: true }));
if (validStoryParts.length === 0) {
document.getElementById('info-alert').textContent = 'No valid story parts found. Ensure files are named correctly (e.g., "1.1_intro.txt").';
document.getElementById('info-alert').style.display = 'block'; return;
}
currentBookId = validStoryParts.map(([prefix]) => prefix).join('|');
document.getElementById('loader-section').style.display = 'none';
document.getElementById('main-header').querySelector('.story-subtitle').textContent = "An interactive reading experience.";
mainContainer.classList.remove('d-none'); mainContainer.innerHTML = '';
storyNavList.innerHTML = '';
resumeAlert.style.display = 'none';
let lastChapter = null;
validStoryParts.forEach(([prefix, files], index) => {
const storyHtml = `<div id="story-block-${index}" class="story-block ${index > 0 ? 'mt-5' : ''}" data-instance-index="${index}"><div class="image-container text-center mb-4"></div><div class="loading-indicator text-center p-5"><div class="spinner-border"></div></div><audio class="audio-player" style="display: none;"></audio><article class="story-text-container" style="display: none;"></article></div>`;
mainContainer.innerHTML += storyHtml;
const chapter = prefix.split('.')[0];
if (chapter !== lastChapter) {
const navItem = document.createElement('li');
const navLink = document.createElement('a');
navLink.href = `#story-block-${index}`;
navLink.textContent = chapter;
navLink.dataset.chapter = chapter;
navItem.appendChild(navLink);
storyNavList.appendChild(navItem);
lastChapter = chapter;
}
});
storyNav.classList.add('visible');
floatingPlayerBtn.classList.add('visible');
storyInstances = validStoryParts.map(([prefix, files], index) => {
const storyBlock = document.getElementById(`story-block-${index}`);
if (files.imageFile) {
const imgContainer = storyBlock.querySelector('.image-container');
const img = document.createElement('img');
img.src = URL.createObjectURL(files.imageFile);
img.className = 'img-fluid rounded shadow-sm';
img.style.maxHeight = '60vh';
imgContainer.appendChild(img);
}
return createStoryPlayer(document.getElementById(`story-block-${index}`), files, index);
});
Promise.all(storyInstances.map(inst => inst.isReady())).then(() => {
loadProgress(currentBookId);
});
setupNavObserver();
}
function setupNavObserver() {
if (navObserver) navObserver.disconnect();
const options = { root: null, rootMargin: '0px', threshold: 0.4 };
navObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const index = entry.target.dataset.instanceIndex;
const storyPart = validStoryParts[parseInt(index, 10)];
if (storyPart) {
const prefix = storyPart[0];
const chapter = prefix.split('.')[0];
storyNavList.querySelectorAll('a').forEach(l => l.classList.remove('active'));
const activeLink = storyNavList.querySelector(`a[data-chapter='${chapter}']`);
if (activeLink) activeLink.classList.add('active');
}
}
});
}, options);
document.querySelectorAll('.story-block').forEach(block => navObserver.observe(block));
}
function createStoryPlayer(storyBlock, files, instanceIndex) {
const audioPlayer = storyBlock.querySelector(".audio-player");
const storyContainer = storyBlock.querySelector(".story-text-container");
let wordTimestamps = [], sentenceData = [], allWordSpans = [], wordMap = [];
let animationFrameId = null, lastHighlightedWordSpan = null, lastHighlightedSentenceSpans = [];
const readyPromise = new Promise(async (resolve) => {
const readFileAsText = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsText(file);
});
const [storyText, jsonText] = await Promise.all([ readFileAsText(files.textFile), readFileAsText(files.jsonFile) ]);
wordTimestamps = JSON.parse(jsonText);
audioPlayer.src = URL.createObjectURL(files.audioFile);
renderStoryWithMarkdown(storyText);
// --- CRITICAL FIX: Run Smart Sync ---
runSmartSync();
storyBlock.querySelector('.loading-indicator').style.display = 'none';
storyContainer.style.display = 'block';
audioPlayer.addEventListener('play', () => { startLoop(); updateFloatingButton('playing'); currentlyPlayingInstance = storyInstances[instanceIndex]; });
audioPlayer.addEventListener('pause', () => { stopLoop(); updateFloatingButton('paused'); saveCurrentProgress(); });
audioPlayer.addEventListener('ended', () => {
stopLoop(); updateFloatingButton('paused');
if (instanceIndex + 1 < storyInstances.length && currentlyPlayingInstance === storyInstances[instanceIndex]) {
currentlyPlayingInstance = storyInstances[instanceIndex + 1];
currentlyPlayingInstance.playAt(0);
}
});
resolve();
});
function renderStoryWithMarkdown(text) {
storyContainer.innerHTML = ''; allWordSpans = [];
const div = document.createElement('div');
div.innerHTML = marked.parse(text, { breaks: true, gfm: true });
function processNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
const words = node.textContent.split(/(\s+)/);
const fragment = document.createDocumentFragment();
words.forEach(part => {
if (part.trim().length > 0) {
const span = document.createElement("span");
span.className = "word";
span.textContent = part;
allWordSpans.push(span);
fragment.appendChild(span);
} else {
fragment.appendChild(document.createTextNode(part));
}
});
node.parentNode.replaceChild(fragment, node);
} else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(processNode);
}
}
processNode(div);
while (div.firstChild) storyContainer.appendChild(div.firstChild);
}
// --- SMART SYNC LOGIC (The Fix) ---
function runSmartSync() {
wordMap = new Array(allWordSpans.length).fill(undefined);
let aiIdx = 0;
allWordSpans.forEach((span, i) => {
const textWord = span.textContent.toLowerCase().replace(/[^\w]/g, '');
// Lookahead fuzzy matching
for(let off = 0; off < 5; off++) {
if (aiIdx + off >= wordTimestamps.length) break;
const aiWord = wordTimestamps[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, '');
if (textWord === aiWord) {
wordMap[i] = aiIdx + off;
aiIdx += off + 1;
return;
}
}
});
mapSentencesToTimestamps();
}
function mapSentencesToTimestamps() {
sentenceData = []; let buffer = [], startIdx = 0;
allWordSpans.forEach((span, i) => {
buffer.push(span);
if (/[.!?]["']?$/.test(span.textContent.trim())) {
let startT = 0, endT = 0;
// Find first/last mapped word
for(let k=startIdx; k<=i; k++) if(wordMap[k]!==undefined) { startT=wordTimestamps[wordMap[k]].start; break; }
for(let k=i; k>=startIdx; k--) if(wordMap[k]!==undefined) { endT=wordTimestamps[wordMap[k]].end; break; }
if(endT > startT) sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
buffer = []; startIdx = i + 1;
}
});
}
function highlightLoop() {
if (audioPlayer.paused) return;
const currentTime = audioPlayer.currentTime;
// Word Highlight using Map
const activeAiIndex = wordTimestamps.findIndex(w => currentTime >= w.start && currentTime < w.end);
if (activeAiIndex !== -1) {
const activeTextIndex = wordMap.findIndex(i => i === activeAiIndex);
if (activeTextIndex !== -1) {
const activeSpan = allWordSpans[activeTextIndex];
if (activeSpan !== lastHighlightedWordSpan) {
if (lastHighlightedWordSpan) lastHighlightedWordSpan.classList.remove("current-word");
activeSpan.classList.add("current-word");
const rect = activeSpan.getBoundingClientRect();
if (rect.top < window.innerHeight * 0.3 || rect.bottom > window.innerHeight * 0.7) {
activeSpan.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
lastHighlightedWordSpan = activeSpan;
}
}
}
// Sentence Highlight
const activeSentence = sentenceData.find(s => currentTime >= s.startTime && currentTime <= s.endTime);
if (activeSentence && activeSentence.spans !== lastHighlightedSentenceSpans) {
lastHighlightedSentenceSpans.forEach(s => s.classList.remove("current-sentence-bg"));
activeSentence.spans.forEach(s => s.classList.add("current-sentence-bg"));
lastHighlightedSentenceSpans = activeSentence.spans;
}
animationFrameId = requestAnimationFrame(highlightLoop);
}
function clearAllHighlights() {
if (lastHighlightedWordSpan) lastHighlightedWordSpan.classList.remove("current-word");
lastHighlightedSentenceSpans.forEach(s => s.classList.remove("current-sentence-bg"));
lastHighlightedWordSpan = null; lastHighlightedSentenceSpans = [];
}
function startLoop() { cancelAnimationFrame(animationFrameId); animationFrameId = requestAnimationFrame(highlightLoop); }
function stopLoop() { cancelAnimationFrame(animationFrameId); }
return {
play: () => audioPlayer.play(),
pause: () => audioPlayer.pause(),
playAt: (time) => { audioPlayer.currentTime = time; audioPlayer.play(); },
stopAndReset: () => { audioPlayer.pause(); audioPlayer.currentTime = 0; clearAllHighlights(); },
getAudioElement: () => audioPlayer,
getWordSpans: () => allWordSpans,
getTimestamps: () => wordTimestamps,
getTimeForSpan: (span) => {
const idx = allWordSpans.indexOf(span);
const aiIdx = wordMap[idx];
return aiIdx !== undefined ? wordTimestamps[aiIdx].start : null;
},
isReady: () => readyPromise
};
}
});
</script>
</body>
</html>

496
reader_templates/index.html Executable file
View File

@@ -0,0 +1,496 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Interactive Reader (Local)</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<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=Lora:ital,wght@0,400..700;1,400..700&family=Poppins:wght@500;700&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
@import url("https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Poppins:wght@500;700&display=swap");
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes highlightFade { 0% { background-color: #c7d2fe; } 100% { background-color: transparent; } }
html { scroll-behavior: smooth; }
body {
background-image: linear-gradient(to top, #f3e7e9 0%, #e3eeff 99%, #e3eeff 100%);
color: #1f2937; font-family: "Lora", serif;
}
.story-title { font-family: "Poppins", sans-serif; font-weight: 700; font-size: 2.5rem; color: #111827; }
.story-subtitle { font-family: "Poppins", sans-serif; color: #4b5563; font-weight: 500; font-size: 1.1rem; }
.main-content-card {
background-color: rgba(255, 255, 255, 0.9); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-radius: 1rem; padding: 3rem 4rem; box-shadow: 0 10px 35px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2); max-width: 1400px; margin: 0 auto;
animation: fadeIn 0.5s ease-in-out;
}
#load-folder-btn {
background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%); background-size: 200% auto;
border: none; transition: all 0.4s ease-in-out !important; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
#load-folder-btn:hover { background-position: right center; transform: translateY(-3px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25); }
.story-text-container { font-size: 36px; line-height: 2.1; color: #1f2937; cursor: pointer; }
.story-text-container h1, .story-text-container h2, .story-text-container h3 {
font-family: "Poppins", sans-serif; color: #111827; line-height: 1.8; margin-top: 1.5em; margin-bottom: 0.8em;
}
.story-text-container h1 { font-size: 2.2em; }
.story-text-container h2 { font-size: 1.8em; }
.story-text-container h3 { font-size: 1.5em; }
.story-text-container p { margin-bottom: 1.2em; }
.story-text-container strong { font-weight: bold; }
.story-text-container em { font-style: italic; }
.sentence, .word { transition: all 0.15s ease; border-radius: 3px; border-bottom: 2px solid transparent; }
.word:hover { background-color: #f1f5f9; }
.current-sentence-bg {
-webkit-box-decoration-break: clone; box-decoration-break: clone; background-color: #e0e7ff;
padding: 0.1em 0.25em; margin: 0 -0.2em; border-radius: 8px;
}
.current-word { color: #3d4e81; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; font-weight: 700; }
.resume-highlight { animation: highlightFade 2.5s ease-out forwards; }
#floating-player-btn {
position: fixed; top: 2rem; right: 2rem; height: 60px; min-width: 60px; padding: 0 24px;
border-radius: 30px; background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%);
background-size: 200% auto; border: none; color: white; box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
display: none; align-items: center; justify-content: center; cursor: pointer; z-index: 1050;
transition: transform 0.2s ease-out, opacity 0.3s ease-out, width 0.3s ease, padding 0.3s ease, border-radius 0.3s ease;
opacity: 0; transform: scale(0.8);
}
#floating-player-btn.visible { display: flex; opacity: 1; transform: scale(1); }
#floating-player-btn:hover { transform: scale(1.05); }
#floating-player-btn:active { transform: scale(0.95); }
#floating-player-btn svg { width: 28px; height: 28px; }
#fp-start-text { font-weight: 600; margin-right: 10px; font-family: "Poppins", sans-serif; font-size: 1.1rem; display: inline-block; }
#floating-player-btn.active-mode { width: 60px; padding: 0; border-radius: 50%; }
#story-nav {
position: fixed; left: 2rem; top: 50%; background-color: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-radius: 25px; padding: 0.5rem;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); z-index: 1000; max-height: 80vh; overflow-y: auto;
opacity: 0; transform: translateY(-50%) translateX(-20px); transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
#story-nav.visible { opacity: 1; transform: translateY(-50%) translateX(0); }
#story-nav ul { padding-left: 0; margin-bottom: 0; }
#story-nav a {
display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; margin: 0.5rem 0;
border-radius: 50%; text-decoration: none; color: #4b5563; font-family: "Poppins", sans-serif;
font-weight: 500; background-color: transparent; transition: all 0.3s ease;
}
#story-nav a:hover { background-color: #e0e7ff; color: #3d4e81; }
#story-nav a.active { background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%); color: white; transform: scale(1.1); }
@media (max-width: 992px) {
#story-nav { left: 1rem; }
#story-nav a { width: 35px; height: 35px; font-size: 0.9rem; }
}
@media (max-width: 768px) {
.main-content-card { padding: 1.5rem; }
.story-title { font-size: 2rem; }
.story-text-container { font-size: 24px; line-height: 1.9; }
#floating-player-btn { top: 1rem; right: 1rem; height: 50px; min-width: 50px; }
#floating-player-btn.active-mode { width: 50px; }
#floating-player-btn svg { width: 22px; height: 22px; }
#story-nav { display: none; }
}
</style>
</head>
<body>
<nav id="story-nav">
<ul id="story-nav-list" class="list-unstyled"></ul>
</nav>
<button id="floating-player-btn">
<span id="fp-start-text">Start</span>
<svg id="fp-pause-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display: none;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<svg id="fp-play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<div class="container-fluid my-5 px-md-5">
<main id="main-content" class="main-content-card">
<header class="text-center mb-5" id="main-header">
<h1 class="story-title">Interactive Reader</h1>
<p class="story-subtitle">Select your assets folder to begin.</p>
</header>
<div id="resume-alert" class="alert alert-info d-flex justify-content-between align-items-center" style="display: none;">
<span>Welcome back! Resume from where you left off?</span>
<button id="resume-btn" class="btn btn-primary btn-sm">Resume Playback</button>
</div>
<div class="text-center" id="loader-section">
<p>Please select the folder containing your files (e.g., the 'assets' folder).</p>
<input type="file" id="folder-input" webkitdirectory directory multiple style="display: none;" />
<button id="load-folder-btn" class="btn btn-dark btn-lg">Select a Folder</button>
<div id="info-alert" class="alert mt-4" style="display: none;"></div>
</div>
<div id="stories-main-container" class="d-none"></div>
</main>
<footer class="text-center text-muted mt-4"></footer>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const mainContainer = document.getElementById("stories-main-container");
const folderInput = document.getElementById("folder-input");
const loadFolderBtn = document.getElementById("load-folder-btn");
const floatingPlayerBtn = document.getElementById("floating-player-btn");
const fpStartText = document.getElementById("fp-start-text");
const fpPauseIcon = document.getElementById("fp-pause-icon");
const fpPlayIcon = document.getElementById("fp-play-icon");
const storyNav = document.getElementById("story-nav");
const storyNavList = document.getElementById("story-nav-list");
const resumeAlert = document.getElementById('resume-alert');
const resumeBtn = document.getElementById('resume-btn');
let storyInstances = [];
let currentlyPlayingInstance = null;
let navObserver;
let currentBookId = null;
let validStoryParts = [];
const PROGRESS_KEY = 'interactiveReaderProgress';
loadFolderBtn.addEventListener("click", () => folderInput.click());
folderInput.addEventListener("change", handleFolderSelection);
mainContainer.addEventListener("click", handleTextClick);
floatingPlayerBtn.addEventListener('click', handleFloatingBtnClick);
window.addEventListener('beforeunload', saveCurrentProgress);
function saveCurrentProgress() {
if (!currentlyPlayingInstance || !currentBookId) return;
const instanceIndex = storyInstances.indexOf(currentlyPlayingInstance);
const timestamp = currentlyPlayingInstance.getAudioElement().currentTime;
saveProgress(currentBookId, instanceIndex, timestamp);
}
function saveProgress(bookId, instanceIndex, timestamp) {
const progress = { bookId, instanceIndex, timestamp, lastUpdate: Date.now() };
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
}
function loadProgress(bookId) {
const savedProgress = localStorage.getItem(PROGRESS_KEY);
if (!savedProgress) return;
const progress = JSON.parse(savedProgress);
if (progress.bookId !== bookId) return;
const targetInstance = storyInstances[progress.instanceIndex];
if (!targetInstance) return;
resumeAlert.style.display = 'flex';
resumeBtn.onclick = () => {
resumeAlert.style.display = 'none';
currentlyPlayingInstance = targetInstance;
targetInstance.playAt(progress.timestamp);
};
}
function updateFloatingButton(state) {
if (fpStartText) fpStartText.style.display = 'none';
floatingPlayerBtn.classList.add('active-mode');
if (state === 'playing') {
fpPauseIcon.style.display = 'block';
fpPlayIcon.style.display = 'none';
} else {
fpPauseIcon.style.display = 'none';
fpPlayIcon.style.display = 'block';
}
}
function handleFloatingBtnClick() {
if (!currentlyPlayingInstance) {
if (storyInstances.length > 0) {
currentlyPlayingInstance = storyInstances[0];
currentlyPlayingInstance.playAt(0);
updateFloatingButton('playing');
}
return;
}
currentlyPlayingInstance.getAudioElement().paused ? currentlyPlayingInstance.play() : currentlyPlayingInstance.pause();
}
function handleTextClick(event) {
const wordSpan = event.target.closest(".word");
if (!wordSpan) return;
const storyBlock = event.target.closest('.story-block');
const instanceIndex = parseInt(storyBlock.dataset.instanceIndex, 10);
const targetInstance = storyInstances[instanceIndex];
// SMART SYNC LOOKUP
const timestamp = targetInstance.getTimeForSpan(wordSpan);
if (timestamp !== null) {
wordSpan.scrollIntoView({ behavior: 'auto', block: 'center' });
if (currentlyPlayingInstance && currentlyPlayingInstance !== targetInstance) {
currentlyPlayingInstance.stopAndReset();
}
currentlyPlayingInstance = targetInstance;
currentlyPlayingInstance.playAt(timestamp);
}
}
function handleFolderSelection(event) {
const files = event.target.files; if (files.length === 0) return;
const storyPartsMap = new Map();
for (const file of files) {
const match = file.name.match(/^([\d\.]+_)/);
if (match) {
const prefix = match[0];
const extension = file.name.split('.').pop().toLowerCase();
if (!storyPartsMap.has(prefix)) storyPartsMap.set(prefix, {});
const part = storyPartsMap.get(prefix);
if (['wav', 'mp3'].includes(extension)) part.audioFile = file;
else if (extension === 'txt') part.textFile = file;
else if (extension === 'json') part.jsonFile = file;
else if (['jpg', 'jpeg', 'png', 'gif'].includes(extension)) part.imageFile = file;
}
}
validStoryParts = Array.from(storyPartsMap.entries())
.filter(([p, f]) => f.audioFile && f.textFile && f.jsonFile)
.sort(([pA], [pB]) => pA.localeCompare(pB, undefined, { numeric: true }));
if (validStoryParts.length === 0) {
document.getElementById('info-alert').textContent = 'No valid story parts found. Ensure files are named correctly (e.g., "1.1_intro.txt").';
document.getElementById('info-alert').style.display = 'block'; return;
}
currentBookId = validStoryParts.map(([prefix]) => prefix).join('|');
document.getElementById('loader-section').style.display = 'none';
document.getElementById('main-header').querySelector('.story-subtitle').textContent = "An interactive reading experience.";
mainContainer.classList.remove('d-none'); mainContainer.innerHTML = '';
storyNavList.innerHTML = '';
resumeAlert.style.display = 'none';
let lastChapter = null;
validStoryParts.forEach(([prefix, files], index) => {
const storyHtml = `<div id="story-block-${index}" class="story-block ${index > 0 ? 'mt-5' : ''}" data-instance-index="${index}"><div class="image-container text-center mb-4"></div><div class="loading-indicator text-center p-5"><div class="spinner-border"></div></div><audio class="audio-player" style="display: none;"></audio><article class="story-text-container" style="display: none;"></article></div>`;
mainContainer.innerHTML += storyHtml;
const chapter = prefix.split('.')[0];
if (chapter !== lastChapter) {
const navItem = document.createElement('li');
const navLink = document.createElement('a');
navLink.href = `#story-block-${index}`;
navLink.textContent = chapter;
navLink.dataset.chapter = chapter;
navItem.appendChild(navLink);
storyNavList.appendChild(navItem);
lastChapter = chapter;
}
});
storyNav.classList.add('visible');
floatingPlayerBtn.classList.add('visible');
storyInstances = validStoryParts.map(([prefix, files], index) => {
const storyBlock = document.getElementById(`story-block-${index}`);
if (files.imageFile) {
const imgContainer = storyBlock.querySelector('.image-container');
const img = document.createElement('img');
img.src = URL.createObjectURL(files.imageFile);
img.className = 'img-fluid rounded shadow-sm';
img.style.maxHeight = '60vh';
imgContainer.appendChild(img);
}
return createStoryPlayer(document.getElementById(`story-block-${index}`), files, index);
});
Promise.all(storyInstances.map(inst => inst.isReady())).then(() => {
loadProgress(currentBookId);
});
setupNavObserver();
}
function setupNavObserver() {
if (navObserver) navObserver.disconnect();
const options = { root: null, rootMargin: '0px', threshold: 0.4 };
navObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const index = entry.target.dataset.instanceIndex;
const storyPart = validStoryParts[parseInt(index, 10)];
if (storyPart) {
const prefix = storyPart[0];
const chapter = prefix.split('.')[0];
storyNavList.querySelectorAll('a').forEach(l => l.classList.remove('active'));
const activeLink = storyNavList.querySelector(`a[data-chapter='${chapter}']`);
if (activeLink) activeLink.classList.add('active');
}
}
});
}, options);
document.querySelectorAll('.story-block').forEach(block => navObserver.observe(block));
}
function createStoryPlayer(storyBlock, files, instanceIndex) {
const audioPlayer = storyBlock.querySelector(".audio-player");
const storyContainer = storyBlock.querySelector(".story-text-container");
let wordTimestamps = [], sentenceData = [], allWordSpans = [], wordMap = [];
let animationFrameId = null, lastHighlightedWordSpan = null, lastHighlightedSentenceSpans = [];
const readyPromise = new Promise(async (resolve) => {
const readFileAsText = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsText(file);
});
const [storyText, jsonText] = await Promise.all([ readFileAsText(files.textFile), readFileAsText(files.jsonFile) ]);
wordTimestamps = JSON.parse(jsonText);
audioPlayer.src = URL.createObjectURL(files.audioFile);
renderStoryWithMarkdown(storyText);
// CRITICAL FIX: Run Smart Sync
runSmartSync();
storyBlock.querySelector('.loading-indicator').style.display = 'none';
storyContainer.style.display = 'block';
audioPlayer.addEventListener('play', () => { startLoop(); updateFloatingButton('playing'); currentlyPlayingInstance = storyInstances[instanceIndex]; });
audioPlayer.addEventListener('pause', () => { stopLoop(); updateFloatingButton('paused'); saveCurrentProgress(); });
audioPlayer.addEventListener('ended', () => {
stopLoop(); updateFloatingButton('paused');
if (instanceIndex + 1 < storyInstances.length && currentlyPlayingInstance === storyInstances[instanceIndex]) {
currentlyPlayingInstance = storyInstances[instanceIndex + 1];
currentlyPlayingInstance.playAt(0);
}
});
resolve();
});
function renderStoryWithMarkdown(text) {
storyContainer.innerHTML = ''; allWordSpans = [];
const div = document.createElement('div');
div.innerHTML = marked.parse(text, { breaks: true, gfm: true });
function processNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
const words = node.textContent.trim().split(/\s+/).filter(w => w.length > 0);
if (words.length > 0) {
const fragment = document.createDocumentFragment();
words.forEach(wordText => {
const wordSpan = document.createElement("span");
wordSpan.className = "word";
wordSpan.textContent = wordText;
allWordSpans.push(wordSpan);
fragment.appendChild(wordSpan);
fragment.appendChild(document.createTextNode(" "));
});
node.parentNode.replaceChild(fragment, node);
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
[...node.childNodes].forEach(processNode);
}
}
processNode(div);
while (div.firstChild) storyContainer.appendChild(div.firstChild);
}
// --- SMART SYNC (The Fix) ---
function runSmartSync() {
wordMap = new Array(allWordSpans.length).fill(undefined);
let aiIdx = 0;
allWordSpans.forEach((span, i) => {
const textWord = span.textContent.toLowerCase().replace(/[^\w]/g, '');
for(let off = 0; off < 5; off++) {
if (aiIdx + off >= wordTimestamps.length) break;
const aiWord = wordTimestamps[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, '');
if (textWord === aiWord) {
wordMap[i] = aiIdx + off;
aiIdx += off + 1;
return;
}
}
});
mapSentencesToTimestamps();
}
function mapSentencesToTimestamps() {
sentenceData = []; let buffer = [], startIdx = 0;
allWordSpans.forEach((span, i) => {
buffer.push(span);
if (/[.!?]["']?$/.test(span.textContent.trim())) {
let startT = 0, endT = 0;
for(let k=startIdx; k<=i; k++) if(wordMap[k]!==undefined) { startT=wordTimestamps[wordMap[k]].start; break; }
for(let k=i; k>=startIdx; k--) if(wordMap[k]!==undefined) { endT=wordTimestamps[wordMap[k]].end; break; }
if(endT > startT) sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
buffer = []; startIdx = i + 1;
}
});
}
function highlightLoop() {
if (audioPlayer.paused) return;
const currentTime = audioPlayer.currentTime;
// Word Highlight using Map
const activeAiIndex = wordTimestamps.findIndex(w => currentTime >= w.start && currentTime < w.end);
if (activeAiIndex !== -1) {
const activeTextIndex = wordMap.findIndex(i => i === activeAiIndex);
if (activeTextIndex !== -1) {
const activeSpan = allWordSpans[activeTextIndex];
if (activeSpan !== lastHighlightedWordSpan) {
if (lastHighlightedWordSpan) lastHighlightedWordSpan.classList.remove("current-word");
activeSpan.classList.add("current-word");
const rect = activeSpan.getBoundingClientRect();
if (rect.top < window.innerHeight * 0.3 || rect.bottom > window.innerHeight * 0.7) {
activeSpan.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
lastHighlightedWordSpan = activeSpan;
}
}
}
// Sentence Highlight
const activeSentence = sentenceData.find(s => currentTime >= s.startTime && currentTime <= s.endTime);
if (activeSentence && activeSentence.spans !== lastHighlightedSentenceSpans) {
lastHighlightedSentenceSpans.forEach(s => s.classList.remove("current-sentence-bg"));
activeSentence.spans.forEach(s => s.classList.add("current-sentence-bg"));
lastHighlightedSentenceSpans = activeSentence.spans;
}
animationFrameId = requestAnimationFrame(highlightLoop);
}
function clearAllHighlights() {
if (lastHighlightedWordSpan) lastHighlightedWordSpan.classList.remove("current-word");
lastHighlightedSentenceSpans.forEach(s => s.classList.remove("current-sentence-bg"));
lastHighlightedWordSpan = null; lastHighlightedSentenceSpans = [];
}
function startLoop() { cancelAnimationFrame(animationFrameId); animationFrameId = requestAnimationFrame(highlightLoop); }
function stopLoop() { cancelAnimationFrame(animationFrameId); }
return {
play: () => audioPlayer.play(),
pause: () => audioPlayer.pause(),
playAt: (time) => { audioPlayer.currentTime = time; audioPlayer.play(); },
stopAndReset: () => { audioPlayer.pause(); audioPlayer.currentTime = 0; clearAllHighlights(); },
getAudioElement: () => audioPlayer,
getWordSpans: () => allWordSpans,
getTimestamps: () => wordTimestamps,
getTimeForSpan: (span) => {
const idx = allWordSpans.indexOf(span);
const aiIdx = wordMap[idx];
return aiIdx !== undefined ? wordTimestamps[aiIdx].start : null;
},
isReady: () => readyPromise
};
}
});
</script>
</body>
</html>

10
requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
# Flask and Web Framework
flask==3.1.2
python-dotenv==1.0.0
requests==2.31.0
# Audio Processing (CPU-only)
pydub==0.25.1
# Production WSGI Server
gunicorn==21.2.0

760
static/css/style.css Normal file
View File

@@ -0,0 +1,760 @@
/* ============================================
CSS Variables
============================================= */
:root {
--bg-gradient-start: #f0f4f8;
--bg-gradient-end: #e2e8f0;
--bg-card: #ffffff;
--editor-bg: #1a1f2e;
--audio-track-bg: #242b3d;
--transcript-track-bg: #2d3548;
--track-border: #3d4558;
--pill-bg-gradient-start: #667eea;
--pill-bg-gradient-end: #764ba2;
--pill-text: #ffffff;
--accent-primary: #667eea;
--reader-bg: rgba(255, 255, 255, 0.95);
--reader-text: #1f2937;
--highlight-word: #2563eb;
--highlight-bg: #dbeafe;
--unmatched-color: #ef4444;
}
/* ============================================
Base Styles
============================================= */
* { box-sizing: border-box; }
body {
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
font-family: 'Inter', sans-serif;
color: #1e293b;
min-height: 100vh;
padding: 20px 0 100px 0;
}
.main-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
}
/* ============================================
App Header
============================================= */
.app-header {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--pill-bg-gradient-end) 100%);
color: white;
padding: 24px 32px;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(102, 126, 234, 0.3);
margin-bottom: 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.version-badge {
background: rgba(255, 255, 255, 0.25);
padding: 8px 16px;
border-radius: 50px;
font-weight: 700;
font-size: 14px;
backdrop-filter: blur(10px);
}
/* ============================================
Input Card & Tabs
============================================= */
.input-card {
background: var(--bg-card);
border-radius: 20px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.05);
margin-bottom: 32px;
overflow: hidden;
position: relative;
}
.nav-tabs .nav-link {
color: #64748b;
font-weight: 600;
border: none;
padding: 1rem 1.5rem;
}
.nav-tabs .nav-link.active {
color: var(--accent-primary);
border-bottom: 3px solid var(--accent-primary);
background: transparent;
}
/* ============================================
Quill Editor Styles
============================================= */
#quill-editor {
height: 300px;
font-family: 'Lora', serif;
font-size: 18px;
line-height: 1.8;
color: #333;
border: none;
}
.ql-container.ql-snow {
border: none !important;
}
.ql-toolbar.ql-snow {
border: none !important;
border-bottom: 1px solid #f0f0f0 !important;
background: #fafafa;
}
.editor-actions {
background: #fafafa;
border-top: 1px solid #eee;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
/* ============================================
Bulk Editor (Notion-like)
============================================= */
.notion-editor-wrapper {
position: relative;
min-height: 500px;
background: #fff;
padding: 40px;
font-family: 'Lora', serif;
}
#bulk-editor {
outline: none;
font-size: 18px;
line-height: 1.8;
color: #333;
min-height: 60vh;
height: auto;
overflow-y: visible;
padding-bottom: 100px;
}
#bulk-editor p { margin-bottom: 1em; }
#bulk-editor h1 {
font-size: 2em;
font-weight: bold;
margin: 1em 0 0.5em;
font-family: 'Poppins', sans-serif;
}
#bulk-editor h2 {
font-size: 1.5em;
font-weight: bold;
margin: 1em 0 0.5em;
font-family: 'Poppins', sans-serif;
}
#bulk-editor h3 {
font-size: 1.2em;
font-weight: bold;
margin: 1em 0 0.5em;
font-family: 'Poppins', sans-serif;
}
/* ============================================
Floating Action Buttons
============================================= */
.floating-controls {
position: fixed;
top: 50%;
right: 20px;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 15px;
z-index: 1000;
}
.floating-btn {
width: 60px;
height: 60px;
border-radius: 50%;
border: none;
color: white;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
position: relative;
}
.floating-btn:hover { transform: scale(1.1); }
.floating-btn.chapter-btn { background: linear-gradient(135deg, #FF6B6B 0%, #EE5253 100%); }
.floating-btn.section-btn { background: linear-gradient(135deg, #4834d4 0%, #686de0 100%); }
.tooltip-text {
position: absolute;
right: 70px;
background: rgba(0,0,0,0.7);
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 12px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
white-space: nowrap;
}
.floating-btn:hover .tooltip-text { opacity: 1; }
/* ============================================
Chapter/Section Markers
============================================= */
.editor-marker {
padding: 15px;
margin: 20px 0;
border-radius: 10px;
user-select: none;
cursor: default;
position: relative;
border: 1px solid rgba(0,0,0,0.1);
}
.chapter-marker {
background: #fff0f0;
border-left: 5px solid #FF6B6B;
}
.section-marker {
background: #f0f0ff;
border-left: 5px solid #4834d4;
margin-left: 20px;
}
.marker-header {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.marker-title {
font-family: 'Poppins', sans-serif;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
.chapter-marker .marker-title { color: #d63031; }
.section-marker .marker-title { color: #4834d4; }
.marker-controls {
display: flex;
align-items: center;
gap: 10px;
}
/* ============================================
Audio Control Panel
============================================= */
.control-panel {
background: var(--bg-card);
border-radius: 16px;
padding: 15px 25px;
margin-bottom: 24px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
/* ============================================
Timeline/Waveform Editor
============================================= */
.timeline-wrapper {
background-color: var(--editor-bg);
border-radius: 20px;
position: relative;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
overflow-x: scroll;
overflow-y: hidden;
margin-bottom: 40px;
padding-bottom: 10px;
}
.timeline-wrapper::-webkit-scrollbar {
height: 12px;
background: #1a1f2e;
}
.timeline-wrapper::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 10px;
border: 2px solid #1a1f2e;
}
.timeline-content {
position: relative;
min-width: 100%;
}
#timeline-ruler {
height: 25px;
background: #1a1f2e;
border-bottom: 1px solid var(--track-border);
position: sticky;
top: 0;
z-index: 50;
}
.audio-track-container {
background: var(--audio-track-bg);
border-bottom: 3px solid var(--track-border);
height: 120px;
padding: 10px 0;
position: relative;
}
.transcription-track-container {
background: var(--transcript-track-bg);
height: 120px;
padding: 10px 0;
position: relative;
}
.track-label {
position: absolute;
left: 20px;
top: 10px;
font-size: 10px;
font-weight: 800;
letter-spacing: 2px;
text-transform: uppercase;
color: rgba(255,255,255,0.5);
background: rgba(0,0,0,0.3);
padding: 4px 10px;
border-radius: 6px;
z-index: 100;
pointer-events: none;
}
/* ============================================
Playhead
============================================= */
#custom-playhead {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background-color: #ef4444;
z-index: 300;
left: 0;
cursor: ew-resize;
pointer-events: all;
}
#custom-playhead::after {
content: '';
position: absolute;
top: 0;
left: -6px;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 10px solid #ef4444;
}
/* ============================================
Word Pills (Transcription)
============================================= */
.word-pill {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--pill-bg-gradient-start) 0%, var(--pill-bg-gradient-end) 100%);
color: var(--pill-text);
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: grab;
border: 1px solid rgba(255,255,255,0.2);
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
top: 50%;
transform: translateY(-50%);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
z-index: 200;
padding: 0 5px;
}
.word-pill.selected {
border-color: #fcd34d;
box-shadow: 0 0 0 2px rgba(252, 211, 77, 0.4);
z-index: 210;
}
.resize-handle {
position: absolute;
top: 0;
bottom: 0;
width: 5px;
cursor: ew-resize;
z-index: 220;
}
.resize-handle-left { left: 0; }
.resize-handle-right { right: 0; }
/* ============================================
Interactive Reader
============================================= */
.reader-section {
background-color: var(--reader-bg);
backdrop-filter: blur(12px);
border-radius: 1rem;
padding: 3rem 4rem;
box-shadow: 0 10px 35px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(255, 255, 255, 0.4);
margin-top: 40px;
animation: fadeIn 0.5s ease-in-out;
min-height: 400px;
}
.reader-header {
font-family: "Poppins", sans-serif;
font-weight: 700;
font-size: 1.5rem;
color: #111827;
margin-bottom: 1rem;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.story-text-container {
font-family: "Lora", serif;
font-size: 24px;
line-height: 2.1;
color: var(--reader-text);
cursor: text;
}
.story-text-container h1,
.story-text-container h2 {
font-family: "Poppins", sans-serif;
margin-top: 1.5em;
font-weight: 700;
}
.story-text-container p { margin-bottom: 1.2em; }
/* ============================================
Word Highlighting
============================================= */
.word {
transition: background-color 0.15s;
border-radius: 3px;
padding: 2px 0;
display: inline;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.word:hover { background-color: #f1f5f9; }
.show-mismatches .word.unmatched {
color: var(--unmatched-color);
text-decoration: underline wavy var(--unmatched-color);
opacity: 1 !important;
}
.current-word {
color: var(--highlight-word);
text-decoration: underline;
text-decoration-thickness: 3px;
text-underline-offset: 3px;
font-weight: 700;
}
.current-sentence-bg {
background-color: var(--highlight-bg);
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
border-radius: 6px;
}
/* ============================================
Loading Overlay
============================================= */
.loading-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255,255,255,0.9);
z-index: 9999;
flex-direction: column;
justify-content: center;
align-items: center;
}
/* ============================================
Library Modal Items
============================================= */
.library-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.library-item:hover {
background: #f1f5f9;
border-color: var(--accent-primary);
}
.library-item-info { flex: 1; }
.library-item-title {
font-weight: 600;
color: #1e293b;
margin-bottom: 4px;
}
.library-item-meta {
font-size: 12px;
color: #64748b;
}
.library-item-actions {
display: flex;
gap: 8px;
}
/* ============================================
Database Stats
============================================= */
.db-stats {
background: linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%);
border-radius: 12px;
padding: 15px 20px;
display: flex;
gap: 30px;
flex-wrap: wrap;
}
.stat-item { text-align: center; }
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--accent-primary);
}
.stat-label {
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 1px;
}
/* ============================================
Animations
============================================= */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ============================================
Image Upload Styles for Section Markers
============================================= */
.section-image-container {
margin: 10px 0;
padding: 10px;
background: #f8f9fa;
border-radius: 8px;
border: 2px dashed #dee2e6;
transition: all 0.3s ease;
}
.section-image-container.drag-over {
border-color: #667eea;
background: #f0f4ff;
}
.section-image-container.has-image {
border-style: solid;
border-color: #28a745;
background: #f8fff8;
}
.image-drop-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 80px;
cursor: pointer;
color: #6c757d;
font-size: 14px;
}
.image-drop-zone i {
font-size: 24px;
margin-bottom: 8px;
color: #adb5bd;
}
.image-drop-zone:hover {
color: #495057;
}
.image-drop-zone:hover i {
color: #667eea;
}
.image-preview-wrapper {
position: relative;
display: inline-block;
max-width: 100%;
}
.section-image-preview {
max-width: 200px;
max-height: 150px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
object-fit: contain;
}
.image-actions {
position: absolute;
top: 5px;
right: 5px;
display: flex;
gap: 5px;
}
.image-actions button {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.9);
color: #333;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: all 0.2s;
}
.image-actions button:hover {
transform: scale(1.1);
}
.image-actions .btn-remove:hover {
background: #dc3545;
color: white;
}
.image-info {
font-size: 11px;
color: #6c757d;
margin-top: 5px;
text-align: center;
}
/* Hidden file input */
.image-file-input {
display: none;
}
/* Save Button Styles */
.save-project-btn {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
border: none;
color: white;
font-weight: bold;
padding: 8px 20px;
border-radius: 8px;
transition: all 0.3s ease;
}
.save-project-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
color: white;
}
.save-project-btn:active {
transform: translateY(0);
}
/* Section marker with image indicator */
.section-marker.has-image .marker-title::after {
content: '🖼️';
margin-left: 8px;
font-size: 12px;
}
/* Image in editor content area */
.section-content-image {
display: block;
max-width: 300px;
max-height: 200px;
margin: 10px 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* ============================================
User Menu Styles
============================================= */
.dropdown-menu {
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(0, 0, 0, 0.05);
padding: 8px;
}
.dropdown-item {
border-radius: 8px;
padding: 10px 16px;
font-weight: 500;
transition: all 0.2s;
}
.dropdown-item:hover {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--pill-bg-gradient-end) 100%);
color: white;
}
.dropdown-item i {
width: 20px;
}
.dropdown-divider {
margin: 8px 0;
}

893
static/js/app.js Normal file
View File

@@ -0,0 +1,893 @@
/**
* Main Application Module
* Handles app initialization, API calls, database operations, and library management
*/
// ==========================================
// LOADER FUNCTIONS
// ==========================================
/**
* Show loading overlay
* @param {string} msg - Main message
* @param {string} subtext - Subtext message
*/
function showLoader(msg, subtext = '') {
document.getElementById('loadingText').textContent = msg;
document.getElementById('loadingSubtext').textContent = subtext || 'Please wait...';
document.getElementById('loader').style.display = 'flex';
}
/**
* Hide loading overlay
*/
function hideLoader() {
document.getElementById('loader').style.display = 'none';
}
// ==========================================
// UTILITY FUNCTIONS
// ==========================================
/**
* Format bytes to human readable string
* @param {number} bytes - Byte count
* @returns {string} Formatted string
*/
function formatBytes(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Format date to human readable string
* @param {string} dateStr - Date string
* @returns {string} Formatted date
*/
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
}
// ==========================================
// APP INITIALIZATION
// ==========================================
/**
* Initialize app with data
* @param {Object} data - Audio and transcription data
*/
function initApp(data) {
document.getElementById('editorSection').classList.remove('d-none');
document.getElementById('editorSection').scrollIntoView({ behavior: 'smooth' });
transcriptionData = data.transcription;
currentAudioData = data.audio_data;
currentAudioFormat = data.audio_format;
initWaveSurferFromBase64(data.audio_data, data.audio_format);
initReader(data.text_content);
refreshLibraryStats();
}
// ==========================================
// UPLOAD FORM HANDLER
// ==========================================
/**
* Initialize upload form handler
*/
function initUploadForm() {
document.getElementById('uploadForm').addEventListener('submit', async function(e) {
e.preventDefault();
const audioInput = document.getElementById('audioFile').files[0];
const txtInput = document.getElementById('txtFile').files[0];
if (!audioInput || !txtInput) return;
const fd = new FormData();
fd.append('audioFile', audioInput);
fd.append('txtFile', txtInput);
showLoader("Uploading...", "Processing audio and text files...");
try {
const res = await fetch('/upload', { method: 'POST', body: fd });
const data = await res.json();
if (data.error) throw new Error(data.error);
initApp(data);
await refreshLibraryStats();
} catch (e) {
alert(e.message);
} finally {
hideLoader();
}
});
}
// ==========================================
// PROJECT/DATABASE FUNCTIONS
// ==========================================
/**
* Get or create project by name
* @returns {Promise<number|null>} Project ID or null
*/
async function getOrCreateProject() {
const projectName = document.getElementById('bulkProjectName').value.trim() || 'Book-1';
try {
const listRes = await fetch('/projects');
const listData = await listRes.json();
const existing = listData.projects.find(p => p.name === projectName);
if (existing) {
currentProjectId = existing.id;
return existing.id;
}
const createRes = await fetch('/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: projectName })
});
const createData = await createRes.json();
if (createData.error) {
console.error('Error creating project:', createData.error);
return null;
}
currentProjectId = createData.project_id;
return createData.project_id;
} catch (e) {
console.error('Error getting/creating project:', e);
return null;
}
}
/**
* Save all sections to database
* @returns {Promise<boolean>} Success status
*/
async function saveAllSectionsToDatabase() {
const projectId = await getOrCreateProject();
if (!projectId) {
console.error('Could not get project ID');
return false;
}
const sections = collectAllSectionsFromEditor();
console.log(`💾 Saving ${sections.length} sections to database...`);
for (const sec of sections) {
try {
await fetch(`/projects/${projectId}/sections/save`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
chapter: sec.chapter,
section: sec.section,
text: sec.text || '',
html_content: sec.htmlContent || '',
tts_text: sec.ttsText || '',
audio_data: sec.audioData || '',
audio_format: sec.audioFormat || 'mp3',
transcription: sec.transcription || [],
voice: sec.voice || 'af_heart',
image_data: sec.imageData || '',
image_format: sec.imageFormat || 'png'
})
});
console.log(` ✅ Saved Ch${sec.chapter}.Sec${sec.section}`);
} catch (e) {
console.error(` ❌ Error saving Ch${sec.chapter}.Sec${sec.section}:`, e);
}
}
await refreshLibraryStats();
return true;
}
/**
* Save single section to database
* @param {number} chapterNum - Chapter number
* @param {number} sectionNum - Section number
* @param {Object} data - Section data
* @returns {Promise<boolean>} Success status
*/
async function saveSectionToDatabase(chapterNum, sectionNum, data) {
const projectId = await getOrCreateProject();
if (!projectId) {
console.error('Could not get project ID');
return false;
}
try {
const res = await fetch(`/projects/${projectId}/sections/save`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
chapter: chapterNum,
section: sectionNum,
text: data.text || '',
html_content: data.htmlContent || '',
tts_text: data.ttsText || '',
audio_data: data.audioData || '',
audio_format: data.audioFormat || 'mp3',
transcription: data.transcription || [],
voice: data.voice || 'af_heart',
image_data: data.imageData || '',
image_format: data.imageFormat || 'png'
})
});
const result = await res.json();
if (result.error) {
console.error('Error saving section:', result.error);
return false;
}
console.log(`✅ Saved Ch${chapterNum}.Sec${sectionNum} to database`);
return true;
} catch (e) {
console.error('Error saving section to database:', e);
return false;
}
}
// ==========================================
// AUDIO GENERATION FUNCTIONS
// ==========================================
/**
* Generate audio for a marker (section or chapter)
* @param {string} id - Marker ID
*/
async function generateMarkerAudio(id) {
const markerEl = document.getElementById(`marker-${id}`);
if (!markerEl) return;
const type = markerEl.dataset.type;
const num = markerEl.querySelector('input[type="number"]').value;
const voice = markerEl.querySelector('select').value;
const allMarkers = Array.from(document.querySelectorAll('.editor-marker'));
const myIdx = allMarkers.indexOf(markerEl);
// Save all sections first
showLoader('Saving all sections...', 'Preserving your content before generation...');
await saveAllSectionsToDatabase();
// --- Generate for single section ---
if (type === 'section') {
const htmlContent = extractHtmlForMarker(markerEl, allMarkers);
const displayText = extractMarkdownForMarker(markerEl, allMarkers);
if (!displayText || displayText.trim().length < 2) {
hideLoader();
alert("No text found.");
return;
}
let genText = markerState[id]?.ttsText || extractPlainTextForMarker(markerEl, allMarkers);
// Get image data from marker state
const imageData = markerState[id]?.imageData || '';
const imageFormat = markerState[id]?.imageFormat || 'png';
// Find chapter number
let chapterNum = 0;
for (let i = myIdx - 1; i >= 0; i--) {
if (allMarkers[i].classList.contains('chapter-marker')) {
chapterNum = allMarkers[i].querySelector('input[type="number"]').value;
break;
}
}
const trackId = await processSingleSection(chapterNum, num, genText, displayText, htmlContent, voice, id, true, imageData, imageFormat);
if (trackId) loadTrackFromPlaylist(trackId);
}
// --- Generate for entire chapter ---
else if (type === 'chapter') {
await generateChapterAudio(markerEl, allMarkers, myIdx, num, voice, id);
}
}
/**
* Generate audio for entire chapter
* @param {Element} markerEl - Chapter marker element
* @param {Element[]} allMarkers - All marker elements
* @param {number} myIdx - Index of chapter marker
* @param {number} num - Chapter number
* @param {string} voice - Voice ID
* @param {string} id - Marker ID
*/
async function generateChapterAudio(markerEl, allMarkers, myIdx, num, voice, id) {
const sectionsToGenerate = [];
// Check for implicit content after chapter marker
let hasDirectContent = false;
let nextEl = markerEl.nextSibling;
while (nextEl) {
if (nextEl.nodeType === 1 && nextEl.classList && nextEl.classList.contains('editor-marker')) {
if (nextEl.classList.contains('section-marker')) break;
else if (nextEl.classList.contains('chapter-marker')) break;
} else if (nextEl.nodeType === 1 || (nextEl.nodeType === 3 && nextEl.textContent.trim())) {
hasDirectContent = true;
}
nextEl = nextEl.nextSibling;
}
// Add implicit section if content exists without section markers
if (hasDirectContent) {
let nextMarkerIdx = myIdx + 1;
let nextMarker = nextMarkerIdx < allMarkers.length ? allMarkers[nextMarkerIdx] : null;
if (!nextMarker || nextMarker.classList.contains('chapter-marker')) {
const secHtml = extractHtmlForMarker(markerEl, allMarkers);
const secDisplay = extractMarkdownForMarker(markerEl, allMarkers);
const secPlain = extractPlainTextForMarker(markerEl, allMarkers);
if (secDisplay && secDisplay.trim().length > 1) {
sectionsToGenerate.push({
id: id + '_implicit_1',
num: 1,
genText: secPlain,
displayText: secDisplay,
htmlContent: secHtml,
voice: voice,
imageData: '',
imageFormat: 'png'
});
}
}
}
// Collect explicit section markers in this chapter
for (let i = myIdx + 1; i < allMarkers.length; i++) {
const m = allMarkers[i];
if (m.classList.contains('chapter-marker')) break;
if (m.classList.contains('section-marker')) {
const secId = m.dataset.markerId;
const secNum = m.querySelector('input[type="number"]').value;
const secVoice = m.querySelector('select').value;
const secHtml = extractHtmlForMarker(m, allMarkers);
const secDisplay = extractMarkdownForMarker(m, allMarkers);
const secPlain = extractPlainTextForMarker(m, allMarkers);
const secGen = markerState[secId]?.ttsText || secPlain;
const imageData = markerState[secId]?.imageData || '';
const imageFormat = markerState[secId]?.imageFormat || 'png';
if (secDisplay && secDisplay.trim().length > 1) {
sectionsToGenerate.push({
id: secId,
num: secNum,
genText: secGen,
displayText: secDisplay,
htmlContent: secHtml,
voice: secVoice,
imageData: imageData,
imageFormat: imageFormat
});
}
}
}
if (sectionsToGenerate.length === 0) {
hideLoader();
alert("No sections found in this chapter.");
return;
}
console.log(`📚 Found ${sectionsToGenerate.length} sections in Chapter ${num}`);
// Generate audio for each section
showLoader(`Generating ${sectionsToGenerate.length} sections...`, 'This may take a while...');
let firstId = null;
for (let i = 0; i < sectionsToGenerate.length; i++) {
const sec = sectionsToGenerate[i];
document.getElementById('loadingText').textContent = `Generating section ${i + 1} of ${sectionsToGenerate.length}...`;
document.getElementById('loadingSubtext').textContent = `Chapter ${num}, Section ${sec.num}`;
console.log(`🔊 Generating Ch${num}.Sec${sec.num}...`);
const trackId = await processSingleSection(num, sec.num, sec.genText, sec.displayText, sec.htmlContent, sec.voice, sec.id, false, sec.imageData, sec.imageFormat);
if (trackId && i === 0) firstId = trackId;
}
hideLoader();
await refreshLibraryStats();
if (firstId) {
loadTrackFromPlaylist(firstId);
alert(`Chapter ${num} generation complete! Generated ${sectionsToGenerate.length} sections.`);
}
}
/**
* Process single section generation
* @param {number} cNum - Chapter number
* @param {number} sNum - Section number
* @param {string} genText - Text for TTS generation
* @param {string} displayText - Display text (markdown)
* @param {string} htmlContent - HTML content
* @param {string} voice - Voice ID
* @param {string} markerId - Marker ID
* @param {boolean} autoHide - Whether to auto-hide loader
* @param {string} imageData - Base64 image data
* @param {string} imageFormat - Image format (png, jpg, etc.)
* @returns {Promise<string|null>} Track ID or null
*/
async function processSingleSection(cNum, sNum, genText, displayText, htmlContent, voice, markerId, autoHide = true, imageData = '', imageFormat = 'png') {
if (autoHide) showLoader(`Generating Section ${cNum}.${sNum}...`, 'Creating audio and timestamps...');
try {
const res = await fetch('/generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ text: genText, voice, save_to_db: false })
});
const data = await res.json();
if (data.error) throw new Error(data.error);
const track = {
id: markerId || (Date.now() + '_' + Math.random().toString(36).substr(2, 9)),
chapter: cNum,
section: sNum,
audioData: data.audio_data,
audioFormat: data.audio_format,
transcription: data.transcription,
text: displayText,
htmlContent: htmlContent,
ttsText: genText,
voice: voice,
imageData: imageData,
imageFormat: imageFormat
};
// Update playlist
const existingIdx = playlist.findIndex(t => t.chapter == cNum && t.section == sNum);
if (existingIdx !== -1) {
playlist[existingIdx] = track;
} else {
playlist.push(track);
}
// Sort playlist by chapter then section
playlist.sort((a, b) => {
if (Number(a.chapter) !== Number(b.chapter)) return Number(a.chapter) - Number(b.chapter);
return Number(a.section) - Number(b.section);
});
updatePlaylistUI();
// Save to database
await saveSectionToDatabase(cNum, sNum, track);
if (autoHide) {
hideLoader();
await refreshLibraryStats();
}
return track.id;
} catch (e) {
console.error('Generation error:', e);
if (autoHide) {
hideLoader();
alert("Error: " + e.message);
}
return null;
}
}
// ==========================================
// LIBRARY MODAL FUNCTIONS
// ==========================================
let libraryModal = null;
/**
* Open library modal
*/
function openLibrary() {
if (!libraryModal) libraryModal = new bootstrap.Modal(document.getElementById('libraryModal'));
loadLibraryData();
libraryModal.show();
}
/**
* Refresh library statistics
*/
async function refreshLibraryStats() {
try {
const res = await fetch('/db/stats');
const stats = await res.json();
document.getElementById('statUploads').textContent = stats.uploads;
document.getElementById('statGenerations').textContent = stats.generations;
document.getElementById('statProjects').textContent = stats.projects;
document.getElementById('statSections').textContent = stats.sections;
document.getElementById('statDbSize').textContent = stats.database_size_mb + ' MB';
console.log('📊 Stats:', stats);
} catch (e) {
console.error('Stats error:', e);
}
}
/**
* Load all library data
*/
async function loadLibraryData() {
await refreshLibraryStats();
await Promise.all([loadUploads(), loadGenerations(), loadProjects()]);
}
/**
* Load uploads list
*/
async function loadUploads() {
const container = document.getElementById('uploadsList');
try {
const data = await (await fetch('/uploads')).json();
container.innerHTML = data.uploads.length === 0
? '<div class="text-center text-muted py-4">No uploads yet</div>'
: data.uploads.map(u => `
<div class="library-item">
<div class="library-item-info">
<div class="library-item-title"><i class="bi bi-file-earmark-music me-2"></i>${u.filename}</div>
<div class="library-item-meta">${u.audio_format.toUpperCase()}${formatBytes(u.audio_size)}${formatDate(u.created_at)}</div>
</div>
<div class="library-item-actions">
<button class="btn btn-sm btn-outline-primary" onclick="loadUpload(${u.id})"><i class="bi bi-play-fill"></i></button>
<button class="btn btn-sm btn-outline-success" onclick="downloadUpload(${u.id})"><i class="bi bi-download"></i></button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteUpload(${u.id})"><i class="bi bi-trash"></i></button>
</div>
</div>`).join('');
} catch (e) {
container.innerHTML = '<div class="text-danger">Failed to load</div>';
}
}
/**
* Load generations list
*/
async function loadGenerations() {
const container = document.getElementById('generationsList');
try {
const data = await (await fetch('/generations')).json();
container.innerHTML = data.generations.length === 0
? '<div class="text-center text-muted py-4">No generations yet</div>'
: data.generations.map(g => `
<div class="library-item">
<div class="library-item-info">
<div class="library-item-title"><i class="bi bi-soundwave me-2"></i>${g.name}</div>
<div class="library-item-meta">Voice: ${g.voice}${formatBytes(g.audio_size)}${formatDate(g.created_at)}</div>
</div>
<div class="library-item-actions">
<button class="btn btn-sm btn-outline-primary" onclick="loadGeneration(${g.id})"><i class="bi bi-play-fill"></i></button>
<button class="btn btn-sm btn-outline-success" onclick="downloadGeneration(${g.id})"><i class="bi bi-download"></i></button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteGeneration(${g.id})"><i class="bi bi-trash"></i></button>
</div>
</div>`).join('');
} catch (e) {
container.innerHTML = '<div class="text-danger">Failed to load</div>';
}
}
/**
* Load projects list
*/
async function loadProjects() {
const container = document.getElementById('projectsList');
try {
const data = await (await fetch('/projects')).json();
container.innerHTML = data.projects.length === 0
? '<div class="text-center text-muted py-4">No projects yet</div>'
: data.projects.map(p => `
<div class="library-item">
<div class="library-item-info">
<div class="library-item-title"><i class="bi bi-folder me-2"></i>${p.name}</div>
<div class="library-item-meta">${p.section_count} sections • ${formatDate(p.updated_at)}</div>
</div>
<div class="library-item-actions">
<button class="btn btn-sm btn-outline-primary" onclick="loadProject(${p.id})"><i class="bi bi-folder-symlink"></i></button>
<button class="btn btn-sm btn-outline-success" onclick="downloadProject(${p.id})"><i class="bi bi-download"></i></button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteProject(${p.id})"><i class="bi bi-trash"></i></button>
</div>
</div>`).join('');
} catch (e) {
container.innerHTML = '<div class="text-danger">Failed to load</div>';
}
}
// ==========================================
// LIBRARY ITEM LOADERS
// ==========================================
/**
* Load upload by ID
* @param {number} id - Upload ID
*/
async function loadUpload(id) {
showLoader('Loading...', 'Retrieving upload data...');
try {
const data = await (await fetch(`/uploads/${id}`)).json();
if (data.error) throw new Error(data.error);
if (libraryModal) libraryModal.hide();
initApp(data);
} catch (e) {
alert(e.message);
} finally {
hideLoader();
}
}
/**
* Load generation by ID
* @param {number} id - Generation ID
*/
async function loadGeneration(id) {
showLoader('Loading...', 'Retrieving generation data...');
try {
const data = await (await fetch(`/generations/${id}`)).json();
if (data.error) throw new Error(data.error);
if (libraryModal) libraryModal.hide();
initApp(data);
} catch (e) {
alert(e.message);
} finally {
hideLoader();
}
}
/**
* Load project by ID
* @param {number} id - Project ID
*/
async function loadProject(id) {
showLoader('Loading project...', 'Retrieving all sections...');
try {
const data = await (await fetch(`/projects/${id}`)).json();
if (data.error) throw new Error(data.error);
if (libraryModal) libraryModal.hide();
document.getElementById('bulk-tab').click();
document.getElementById('bulkProjectName').value = data.name;
currentProjectId = id;
const editor = document.getElementById('bulk-editor');
editor.innerHTML = '';
Object.keys(markerState).forEach(key => delete markerState[key]);
chapterCounter = 1;
sectionCounter = 1;
// Group sections by chapter
const chapters = {};
data.sections.forEach(sec => {
if (!chapters[sec.chapter]) chapters[sec.chapter] = [];
chapters[sec.chapter].push(sec);
});
// Rebuild editor
const sortedChapterNums = Object.keys(chapters).sort((a, b) => Number(a) - Number(b));
sortedChapterNums.forEach(chapterNum => {
const chapterSections = chapters[chapterNum];
const chapterVoice = chapterSections[0]?.voice || 'af_alloy';
const chapterMarkerId = `ch_${chapterNum}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
// Insert chapter marker
const chapterMarkerHtml = createMarkerHTML('chapter', chapterNum, chapterVoice, chapterMarkerId);
editor.insertAdjacentHTML('beforeend', chapterMarkerHtml);
if (Number(chapterNum) >= chapterCounter) chapterCounter = Number(chapterNum) + 1;
// Sort and insert sections
chapterSections.sort((a, b) => a.section - b.section);
chapterSections.forEach(sec => {
const secMarkerId = `sec_${chapterNum}_${sec.section}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
const secMarkerHtml = createMarkerHTML('section', sec.section, sec.voice, secMarkerId);
editor.insertAdjacentHTML('beforeend', secMarkerHtml);
const marker = document.getElementById(`marker-${secMarkerId}`);
if (marker) {
let content = sec.html_content;
if (!content || content.trim() === '') {
content = sec.text_content ? `<p>${sec.text_content}</p>` : '<p><br></p>';
}
// Remove placeholder paragraph
let nextEl = marker.nextElementSibling;
if (nextEl && nextEl.tagName === 'P' && (nextEl.innerHTML === '<br>' || nextEl.innerHTML.trim() === '')) {
nextEl.remove();
}
// Insert content after marker
const tempContainer = document.createElement('div');
tempContainer.innerHTML = content;
const insertBeforeElement = marker.nextSibling;
while (tempContainer.firstChild) {
editor.insertBefore(tempContainer.firstChild, insertBeforeElement);
}
// Ensure at least one paragraph exists after content
if (!marker.nextSibling || (marker.nextSibling.classList && marker.nextSibling.classList.contains('editor-marker'))) {
const emptyP = document.createElement('p');
emptyP.innerHTML = '<br>';
if (insertBeforeElement) {
editor.insertBefore(emptyP, insertBeforeElement);
} else {
editor.appendChild(emptyP);
}
}
}
// Restore TTS text to marker state
if (sec.tts_text) {
updateMarkerData(secMarkerId, 'ttsText', sec.tts_text);
}
// Restore image data to marker state and show preview
if (sec.image_data && sec.image_data.length > 0) {
updateMarkerData(secMarkerId, 'imageData', sec.image_data);
updateMarkerData(secMarkerId, 'imageFormat', sec.image_format || 'png');
// Show image preview after DOM is ready
setTimeout(() => {
const imgFormat = sec.image_format || 'png';
const dataUrl = `data:image/${imgFormat};base64,${sec.image_data}`;
showImagePreview(secMarkerId, dataUrl, 'Loaded image', 0, imgFormat);
}, 100);
}
if (Number(sec.section) >= sectionCounter) sectionCounter = Number(sec.section) + 1;
});
});
// Initialize image handlers for newly created markers
setTimeout(() => {
initializeImageHandlers();
}, 300);
// Build playlist from sections with audio
playlist = data.sections
.filter(s => s.audio_data && s.audio_data.length > 0)
.map(sec => ({
id: `sec_${sec.chapter}_${sec.section}_loaded`,
chapter: sec.chapter,
section: sec.section,
audioData: sec.audio_data,
audioFormat: sec.audio_format || 'mp3',
transcription: sec.transcription || [],
text: sec.text_content,
htmlContent: sec.html_content,
ttsText: sec.tts_text,
voice: sec.voice,
imageData: sec.image_data || '',
imageFormat: sec.image_format || 'png'
}))
.sort((a, b) => {
if (Number(a.chapter) !== Number(b.chapter)) return Number(a.chapter) - Number(b.chapter);
return Number(a.section) - Number(b.section);
});
updatePlaylistUI();
document.getElementById('editorSection').classList.remove('d-none');
// Load first track if available
if (playlist.length > 0) {
loadTrackFromPlaylist(playlist[0].id);
}
console.log(`✅ Loaded project: ${data.name} with ${data.sections.length} sections, ${playlist.length} with audio`);
} catch (e) {
alert(e.message);
console.error('Load project error:', e);
} finally {
hideLoader();
}
}
// ==========================================
// DOWNLOAD FUNCTIONS
// ==========================================
/**
* Download upload by ID
* @param {number} id - Upload ID
*/
function downloadUpload(id) {
window.location.href = `/uploads/${id}/download`;
}
/**
* Download generation by ID
* @param {number} id - Generation ID
*/
function downloadGeneration(id) {
window.location.href = `/generations/${id}/download`;
}
/**
* Download project by ID
* @param {number} id - Project ID
*/
function downloadProject(id) {
window.location.href = `/projects/${id}/download`;
}
// ==========================================
// DELETE FUNCTIONS
// ==========================================
/**
* Delete upload by ID
* @param {number} id - Upload ID
*/
async function deleteUpload(id) {
if (!confirm('Delete this upload?')) return;
showLoader('Deleting...', 'Removing upload from database...');
try {
await fetch(`/uploads/${id}`, { method: 'DELETE' });
await loadLibraryData();
} catch (e) {
alert(e.message);
} finally {
hideLoader();
}
}
/**
* Delete generation by ID
* @param {number} id - Generation ID
*/
async function deleteGeneration(id) {
if (!confirm('Delete this generation?')) return;
showLoader('Deleting...', 'Removing generation from database...');
try {
await fetch(`/generations/${id}`, { method: 'DELETE' });
await loadLibraryData();
} catch (e) {
alert(e.message);
} finally {
hideLoader();
}
}
/**
* Delete project by ID
* @param {number} id - Project ID
*/
async function deleteProject(id) {
if (!confirm('Delete this project and all its sections?')) return;
showLoader('Deleting...', 'Removing project from database...');
try {
await fetch(`/projects/${id}`, { method: 'DELETE' });
await loadLibraryData();
} catch (e) {
alert(e.message);
} finally {
hideLoader();
}
}
// ==========================================
// INITIALIZATION
// ==========================================
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', function() {
initUploadForm();
refreshLibraryStats();
});

1070
static/js/editor.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,270 @@
/**
* Interactive Reader Module
* Handles text display, word highlighting, and audio sync
*/
// ==========================================
// READER STATE VARIABLES
// ==========================================
let allWordSpans = [];
let wordMap = [];
let sentenceData = [];
let lastHighlightedWordSpan = null;
let lastHighlightedSentenceSpans = [];
// ==========================================
// READER INITIALIZATION
// ==========================================
/**
* Initialize reader with markdown text
* @param {string} markdownText - Text content in markdown format
*/
function initReader(markdownText) {
const container = document.getElementById('readerContent');
container.innerHTML = '';
allWordSpans = [];
wordMap = [];
const html = marked.parse(markdownText || '', { breaks: true });
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
// Process nodes to wrap words in spans
processNode(tempDiv);
while (tempDiv.firstChild) {
container.appendChild(tempDiv.firstChild);
}
runSmartSync();
}
/**
* Process DOM node to wrap words in clickable spans
* @param {Node} node - DOM node to process
*/
function processNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
const words = node.textContent.split(/(\s+|[^\w'])/g);
const fragment = document.createDocumentFragment();
words.forEach(part => {
if (part.trim().length > 0) {
const span = document.createElement('span');
span.className = 'word';
span.textContent = part;
span.onclick = handleWordClick;
allWordSpans.push(span);
fragment.appendChild(span);
} else {
fragment.appendChild(document.createTextNode(part));
}
});
node.parentNode.replaceChild(fragment, node);
} else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(processNode);
}
}
/**
* Handle click on a word span
* @param {MouseEvent} e - Click event
*/
function handleWordClick(e) {
e.stopPropagation();
const spanIndex = allWordSpans.indexOf(e.target);
const aiIdx = wordMap[spanIndex];
if (aiIdx !== undefined && transcriptionData[aiIdx] && wavesurfer) {
wavesurfer.setTime(transcriptionData[aiIdx].start);
wavesurfer.play();
}
}
// ==========================================
// TEXT-AUDIO SYNC FUNCTIONS
// ==========================================
/**
* Run smart sync between text words and transcription data
*/
function runSmartSync() {
wordMap = new Array(allWordSpans.length).fill(undefined);
let aiIdx = 0;
let matchCount = 0;
allWordSpans.forEach((span, i) => {
const clean = span.textContent.toLowerCase().replace(/[^\w]/g, '');
if (clean.length === 0) {
span.classList.add('unmatched');
return;
}
// Look ahead up to 5 positions for a match
for (let off = 0; off < 5; off++) {
if (aiIdx + off >= transcriptionData.length) break;
const transcriptWord = transcriptionData[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, '');
if (transcriptWord === clean) {
wordMap[i] = aiIdx + off;
aiIdx += off + 1;
span.classList.remove('unmatched');
matchCount++;
return;
}
}
span.classList.add('unmatched');
});
mapSentences();
updateSyncStatus(matchCount);
}
/**
* Update sync status badge
* @param {number} matchCount - Number of matched words
*/
function updateSyncStatus(matchCount) {
const badge = document.getElementById('syncStatus');
badge.textContent = `Synced (${matchCount}/${allWordSpans.length})`;
badge.className = matchCount > allWordSpans.length * 0.8
? 'badge bg-success'
: 'badge bg-warning text-dark';
}
/**
* Map sentences for sentence-level highlighting
*/
function mapSentences() {
sentenceData = [];
let buffer = [];
let startIdx = 0;
allWordSpans.forEach((span, i) => {
buffer.push(span);
// Check for sentence-ending punctuation
if (/[.!?]["']?$/.test(span.textContent.trim())) {
let startT = 0;
let endT = 0;
// Find start time
for (let k = startIdx; k <= i; k++) {
if (wordMap[k] !== undefined) {
startT = transcriptionData[wordMap[k]].start;
break;
}
}
// Find end time
for (let k = i; k >= startIdx; k--) {
if (wordMap[k] !== undefined) {
endT = transcriptionData[wordMap[k]].end;
break;
}
}
if (endT > startT) {
sentenceData.push({
spans: [...buffer],
start: startT,
end: endT
});
}
buffer = [];
startIdx = i + 1;
}
});
}
/**
* Sync reader highlighting with audio playback time
* @param {number} t - Current playback time in seconds
*/
function syncReader(t) {
// Highlight current word
highlightCurrentWord(t);
// Highlight current sentence
highlightCurrentSentence(t);
}
/**
* Highlight the current word based on playback time
* @param {number} t - Current playback time
*/
function highlightCurrentWord(t) {
const aiIdx = transcriptionData.findIndex(d => t >= d.start && t < d.end);
if (aiIdx !== -1) {
const txtIdx = wordMap.findIndex(i => i === aiIdx);
if (txtIdx !== -1 && allWordSpans[txtIdx] !== lastHighlightedWordSpan) {
// Remove previous highlight
if (lastHighlightedWordSpan) {
lastHighlightedWordSpan.classList.remove('current-word');
}
// Add new highlight
lastHighlightedWordSpan = allWordSpans[txtIdx];
lastHighlightedWordSpan.classList.add('current-word');
// Scroll into view if needed
scrollWordIntoView(lastHighlightedWordSpan);
}
}
}
/**
* Highlight the current sentence based on playback time
* @param {number} t - Current playback time
*/
function highlightCurrentSentence(t) {
const sent = sentenceData.find(s => t >= s.start && t <= s.end);
if (sent && sent.spans !== lastHighlightedSentenceSpans) {
// Remove previous highlight
if (lastHighlightedSentenceSpans) {
lastHighlightedSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg'));
}
// Add new highlight
lastHighlightedSentenceSpans = sent.spans;
lastHighlightedSentenceSpans.forEach(s => s.classList.add('current-sentence-bg'));
}
}
/**
* Scroll word into view if outside visible area
* @param {Element} wordSpan - Word span element
*/
function scrollWordIntoView(wordSpan) {
const r = wordSpan.getBoundingClientRect();
const c = document.querySelector('.reader-section').getBoundingClientRect();
if (r.top < c.top || r.bottom > c.bottom) {
wordSpan.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
}
// ==========================================
// MISMATCH TOGGLE
// ==========================================
/**
* Toggle display of mismatched words
*/
function toggleMismatches() {
const toggle = document.getElementById('mismatchToggle');
document.getElementById('readerContent').classList.toggle('show-mismatches', toggle.checked);
}

460
static/js/timeline.js Normal file
View File

@@ -0,0 +1,460 @@
/**
* Timeline Module
* Handles WaveSurfer, audio playback, word pills, and timeline interactions
*/
// ==========================================
// GLOBAL STATE VARIABLES
// ==========================================
// --- WaveSurfer Instance ---
let wavesurfer = null;
// --- Transcription Data ---
let transcriptionData = [];
// --- Timeline Settings ---
let pixelsPerSecond = 100;
let audioDuration = 0;
// --- Current Audio ---
let currentAudioData = "";
let currentAudioFormat = "mp3";
let currentProjectId = null;
// --- Playlist ---
let playlist = [];
let currentTrackIndex = -1;
// --- Drag/Resize State ---
let isDragging = false;
let isResizing = false;
let isScrubbing = false;
let currentPill = null;
let currentIndex = -1;
let selectedPillIndex = -1;
let hasMovedDuringDrag = false;
// ==========================================
// WAVESURFER INITIALIZATION
// ==========================================
/**
* Initialize WaveSurfer from base64 audio data
* @param {string} base64Data - Base64 encoded audio
* @param {string} format - Audio format (mp3, wav, etc.)
*/
function initWaveSurferFromBase64(base64Data, format) {
if (wavesurfer) wavesurfer.destroy();
const byteArray = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
const blob = new Blob([byteArray], { type: `audio/${format}` });
const url = URL.createObjectURL(blob);
wavesurfer = WaveSurfer.create({
container: '#waveform',
waveColor: '#a5b4fc',
progressColor: '#818cf8',
url,
height: 120,
normalize: true,
minPxPerSec: pixelsPerSecond,
hideScrollbar: true,
plugins: [
WaveSurfer.Timeline.create({
container: '#timeline-ruler',
height: 25
})
]
});
wavesurfer.on('decode', () => {
audioDuration = wavesurfer.getDuration();
updateTimelineWidth();
renderPills();
});
wavesurfer.on('timeupdate', (t) => {
if (!isScrubbing) {
document.getElementById('custom-playhead').style.left = `${t * pixelsPerSecond}px`;
const wrapper = document.getElementById('timelineWrapper');
if (t * pixelsPerSecond > wrapper.clientWidth / 2) {
wrapper.scrollLeft = t * pixelsPerSecond - wrapper.clientWidth / 2;
}
}
syncReader(t);
});
wavesurfer.on('finish', updatePlayBtn);
initScrubber();
}
// ==========================================
// PLAYHEAD/SCRUBBER FUNCTIONS
// ==========================================
/**
* Initialize scrubber interactions
*/
function initScrubber() {
const playhead = document.getElementById('custom-playhead');
const wrapper = document.getElementById('timelineContent');
playhead.onmousedown = (e) => {
isScrubbing = true;
e.preventDefault();
};
document.onmousemove = (e) => {
if (!isScrubbing) return;
const time = Math.max(0, Math.min(
(e.clientX - wrapper.getBoundingClientRect().left) / pixelsPerSecond,
audioDuration
));
playhead.style.left = `${time * pixelsPerSecond}px`;
wavesurfer.setTime(time);
};
document.onmouseup = () => isScrubbing = false;
wrapper.onclick = (e) => {
if (!isDragging && !e.target.closest('.word-pill')) {
const time = (e.clientX - wrapper.getBoundingClientRect().left) / pixelsPerSecond;
if (time >= 0) wavesurfer.setTime(time);
}
};
}
// ==========================================
// TIMELINE WIDTH UPDATE
// ==========================================
/**
* Update timeline width based on audio duration and zoom
*/
function updateTimelineWidth() {
if (wavesurfer) {
const w = wavesurfer.getDuration() * pixelsPerSecond;
document.getElementById('timelineContent').style.width = w + 'px';
document.getElementById('waveform').style.width = w + 'px';
}
}
// ==========================================
// WORD PILL FUNCTIONS
// ==========================================
/**
* Insert new pill at playhead position
*/
function insertPillAtPlayhead() {
if (!wavesurfer) return;
const t = wavesurfer.getCurrentTime();
transcriptionData.push({ word: "New", start: t, end: t + 0.5 });
transcriptionData.sort((a, b) => a.start - b.start);
renderPills();
runSmartSync();
}
/**
* Delete currently selected pill
*/
function deleteSelectedPill() {
if (selectedPillIndex === -1) return;
transcriptionData.splice(selectedPillIndex, 1);
selectedPillIndex = -1;
renderPills();
runSmartSync();
document.getElementById('deleteBtn').disabled = true;
}
/**
* Select a pill by index
* @param {number} index - Pill index
*/
function selectPill(index) {
document.querySelectorAll('.word-pill').forEach(p => p.classList.remove('selected'));
const pills = document.querySelectorAll('.word-pill');
if (pills[index]) {
pills[index].classList.add('selected');
selectedPillIndex = index;
document.getElementById('deleteBtn').disabled = false;
}
}
/**
* Render all word pills on the timeline
*/
function renderPills() {
const container = document.getElementById('transcription-content');
container.innerHTML = '';
transcriptionData.forEach((item, index) => {
const pill = document.createElement('div');
pill.className = `word-pill ${index === selectedPillIndex ? 'selected' : ''}`;
pill.textContent = item.word;
pill.dataset.index = index;
pill.style.left = `${item.start * pixelsPerSecond}px`;
pill.style.width = `${(item.end - item.start) * pixelsPerSecond}px`;
// Resize handles
const lh = document.createElement('div');
lh.className = 'resize-handle resize-handle-left';
const rh = document.createElement('div');
rh.className = 'resize-handle resize-handle-right';
pill.append(lh, rh);
// Event handlers
pill.onmousedown = handleDragStart;
lh.onmousedown = (e) => handleResizeStart(e, 'left');
rh.onmousedown = (e) => handleResizeStart(e, 'right');
pill.onclick = (e) => {
e.stopPropagation();
selectPill(index);
};
pill.ondblclick = (e) => {
e.stopPropagation();
const input = document.createElement('input');
input.value = item.word;
input.style.cssText = 'all:unset;width:100%;text-align:center;';
pill.innerHTML = '';
pill.appendChild(input);
input.focus();
input.onblur = () => {
item.word = input.value;
renderPills();
runSmartSync();
};
input.onkeydown = (ev) => {
if (ev.key === 'Enter') input.blur();
};
};
container.appendChild(pill);
});
}
// ==========================================
// PILL DRAG/RESIZE HANDLERS
// ==========================================
/**
* Handle pill drag start
* @param {MouseEvent} e - Mouse event
*/
function handleDragStart(e) {
if (e.target.classList.contains('resize-handle') || e.target.tagName === 'INPUT') return;
isDragging = true;
hasMovedDuringDrag = false;
currentPill = e.currentTarget;
currentIndex = parseInt(currentPill.dataset.index);
const startX = e.clientX;
const initialLeft = parseFloat(currentPill.style.left);
selectPill(currentIndex);
const onMove = (me) => {
hasMovedDuringDrag = true;
const newStart = Math.max(0, (initialLeft + (me.clientX - startX)) / pixelsPerSecond);
const dur = transcriptionData[currentIndex].end - transcriptionData[currentIndex].start;
transcriptionData[currentIndex].start = newStart;
transcriptionData[currentIndex].end = newStart + dur;
currentPill.style.left = `${newStart * pixelsPerSecond}px`;
};
const onUp = () => {
isDragging = false;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (hasMovedDuringDrag) {
transcriptionData.sort((a, b) => a.start - b.start);
renderPills();
}
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
/**
* Handle pill resize start
* @param {MouseEvent} e - Mouse event
* @param {string} side - 'left' or 'right'
*/
function handleResizeStart(e, side) {
e.stopPropagation();
isResizing = true;
currentPill = e.target.parentElement;
currentIndex = parseInt(currentPill.dataset.index);
selectPill(currentIndex);
const onMove = (me) => {
const time = (me.clientX - document.getElementById('transcription-content').getBoundingClientRect().left) / pixelsPerSecond;
if (side === 'left') {
transcriptionData[currentIndex].start = Math.max(0, Math.min(time, transcriptionData[currentIndex].end - 0.1));
} else {
transcriptionData[currentIndex].end = Math.max(time, transcriptionData[currentIndex].start + 0.1);
}
renderPills();
};
const onUp = () => {
isResizing = false;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// ==========================================
// PLAYBACK CONTROL FUNCTIONS
// ==========================================
/**
* Toggle play/pause
*/
function togglePlayPause() {
if (wavesurfer) wavesurfer.playPause();
updatePlayBtn();
}
/**
* Stop audio playback
*/
function stopAudio() {
if (wavesurfer) wavesurfer.stop();
updatePlayBtn();
}
/**
* Update play button state
*/
function updatePlayBtn() {
const playing = wavesurfer?.isPlaying();
document.getElementById('playIcon').className = playing
? 'bi bi-pause-fill text-primary'
: 'bi bi-play-fill text-primary';
document.getElementById('playText').textContent = playing ? 'Pause' : 'Play';
}
// ==========================================
// PLAYLIST FUNCTIONS
// ==========================================
/**
* Update playlist dropdown UI
*/
function updatePlaylistUI() {
const select = document.getElementById('trackSelect');
select.innerHTML = playlist.length === 0
? '<option>No tracks generated yet...</option>'
: playlist.map(t =>
`<option value="${t.id}">Chapter ${t.chapter} - Section ${t.section}</option>`
).join('');
}
/**
* Load track from playlist by ID
* @param {string} id - Track ID
*/
function loadTrackFromPlaylist(id) {
const idx = playlist.findIndex(t => t.id == id);
if (idx === -1) return;
currentTrackIndex = idx;
const track = playlist[idx];
document.getElementById('trackSelect').value = track.id;
document.getElementById('editorSection').classList.remove('d-none');
document.getElementById('editorSection').scrollIntoView({ behavior: 'smooth' });
document.getElementById('trackInfo').textContent = `Now Playing: Ch ${track.chapter} / Sec ${track.section}`;
transcriptionData = track.transcription || [];
currentAudioData = track.audioData;
currentAudioFormat = track.audioFormat;
if (track.audioData) {
initWaveSurferFromBase64(track.audioData, track.audioFormat);
}
initReader(track.text);
}
/**
* Play next track in playlist
*/
function playNextTrack() {
if (currentTrackIndex < playlist.length - 1) {
loadTrackFromPlaylist(playlist[currentTrackIndex + 1].id);
}
}
/**
* Play previous track in playlist
*/
function playPrevTrack() {
if (currentTrackIndex > 0) {
loadTrackFromPlaylist(playlist[currentTrackIndex - 1].id);
}
}
// ==========================================
// SLIDER EVENT HANDLERS
// ==========================================
/**
* Initialize slider event handlers
*/
function initSliders() {
// Zoom slider
document.getElementById('zoomSlider').oninput = (e) => {
pixelsPerSecond = parseInt(e.target.value);
if (wavesurfer) {
wavesurfer.zoom(pixelsPerSecond);
updateTimelineWidth();
renderPills();
}
};
// Speed slider
document.getElementById('speedSlider').oninput = (e) => {
const rate = parseFloat(e.target.value);
if (wavesurfer) wavesurfer.setPlaybackRate(rate);
document.getElementById('speedDisplay').textContent = rate.toFixed(1) + 'x';
};
}
// ==========================================
// KEYBOARD SHORTCUTS
// ==========================================
/**
* Initialize keyboard shortcuts
*/
function initKeyboardShortcuts() {
document.onkeydown = (e) => {
// Ignore if typing in input/textarea or bulk editor
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) ||
document.getElementById('bulk-editor').contains(e.target)) return;
// Space - Play/Pause
if (e.code === 'Space') {
e.preventDefault();
togglePlayPause();
}
// Delete - Delete selected pill
if (e.code === 'Delete') deleteSelectedPill();
};
}
// ==========================================
// INITIALIZATION
// ==========================================
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', function() {
initSliders();
initKeyboardShortcuts();
});

703
templates/admin.html Normal file
View File

@@ -0,0 +1,703 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - Audio Transcription Editor</title>
<!-- External CSS Libraries -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<!-- Google Fonts -->
<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=Inter:wght@400;500;600;700;800&family=Poppins:wght@500;700&display=swap" rel="stylesheet">
<style>
:root {
--accent-primary: #667eea;
--accent-secondary: #764ba2;
--bg-gradient-start: #f0f4f8;
--bg-gradient-end: #e2e8f0;
}
* { box-sizing: border-box; }
body {
font-family: 'Inter', sans-serif;
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
min-height: 100vh;
padding: 20px;
color: #1e293b;
}
.main-container {
max-width: 1200px;
margin: 0 auto;
}
.admin-header {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
color: white;
padding: 24px 32px;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(102, 126, 234, 0.3);
margin-bottom: 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.admin-header h1 {
font-family: 'Poppins', sans-serif;
font-weight: 700;
font-size: 1.5rem;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
.btn-header {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
padding: 10px 20px;
border-radius: 10px;
font-weight: 600;
transition: all 0.3s;
}
.btn-header:hover {
background: rgba(255, 255, 255, 0.3);
color: white;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: white;
border-radius: 16px;
padding: 24px;
text-align: center;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
.stat-value {
font-size: 2.5rem;
font-weight: 800;
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
font-size: 0.85rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
margin-top: 8px;
}
.content-card {
background: white;
border-radius: 20px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.card-header {
background: #f8fafc;
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h2 {
font-family: 'Poppins', sans-serif;
font-weight: 700;
font-size: 1.2rem;
margin: 0;
color: #1e293b;
}
.card-body {
padding: 24px;
}
.btn-create {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
border: none;
color: white;
padding: 10px 20px;
border-radius: 10px;
font-weight: 600;
transition: all 0.3s;
}
.btn-create:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
color: white;
}
.user-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.user-table th {
background: #f8fafc;
padding: 14px 16px;
text-align: left;
font-weight: 600;
color: #64748b;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid #e2e8f0;
}
.user-table td {
padding: 16px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.user-table tr:hover td {
background: #f8fafc;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 1rem;
}
.badge-admin {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-user {
background: #e2e8f0;
color: #64748b;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.btn-action {
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
margin-right: 4px;
}
.btn-action-edit {
background: #e0e7ff;
color: #4f46e5;
}
.btn-action-edit:hover {
background: #c7d2fe;
}
.btn-action-delete {
background: #fee2e2;
color: #dc2626;
}
.btn-action-delete:hover {
background: #fecaca;
}
.modal-content {
border-radius: 20px;
border: none;
overflow: hidden;
}
.modal-header {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
padding: 20px 24px;
}
.modal-title {
font-family: 'Poppins', sans-serif;
font-weight: 700;
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
}
.form-label {
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
.form-control, .form-select {
border: 2px solid #e5e7eb;
border-radius: 10px;
padding: 12px 16px;
}
.form-control:focus, .form-select:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.15);
}
.form-check-input:checked {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
}
.text-muted-custom {
color: #94a3b8;
font-size: 0.85rem;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
}
.empty-state i {
font-size: 4rem;
margin-bottom: 20px;
opacity: 0.5;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
flex-direction: column;
}
.alert {
border-radius: 12px;
border: none;
}
</style>
</head>
<body>
<!-- Loading Overlay -->
<div class="loading-overlay" id="loader">
<div class="spinner-border text-primary mb-3" role="status"></div>
<h5 class="text-muted">Loading...</h5>
</div>
<!-- Create/Edit User Modal -->
<div class="modal fade" id="userModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="userModalTitle">Create User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="userForm">
<input type="hidden" id="userId">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" id="userUsername" required minlength="3">
<div class="text-muted-custom mt-1">At least 3 characters</div>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" id="userPassword">
<div class="text-muted-custom mt-1" id="passwordHint">At least 4 characters</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="userIsAdmin">
<label class="form-check-label fw-bold" for="userIsAdmin">Administrator</label>
</div>
<div class="text-muted-custom mt-1">Admins can manage users</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-create" id="saveUserBtn" onclick="saveUser()">Save User</button>
</div>
</div>
</div>
</div>
<!-- Confirm Delete Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete user <strong id="deleteUsername"></strong>?</p>
<p class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>This will also delete all their projects and data.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-sm" id="confirmDeleteBtn">Delete</button>
</div>
</div>
</div>
</div>
<div class="main-container">
<!-- Admin Header -->
<div class="admin-header">
<div>
<h1><i class="bi bi-shield-lock me-2"></i>Admin Dashboard</h1>
</div>
<div class="header-actions">
<a href="/" class="btn btn-header">
<i class="bi bi-arrow-left me-2"></i>Back to Editor
</a>
<button class="btn btn-header" onclick="logout()">
<i class="bi bi-box-arrow-right me-2"></i>Logout
</button>
</div>
</div>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="statUsers">0</div>
<div class="stat-label">Users</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statProjects">0</div>
<div class="stat-label">Projects</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statSections">0</div>
<div class="stat-label">Sections</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statUploads">0</div>
<div class="stat-label">Uploads</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statGenerations">0</div>
<div class="stat-label">Generations</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statDbSize">0</div>
<div class="stat-label">Database (MB)</div>
</div>
</div>
<!-- Users Table -->
<div class="content-card">
<div class="card-header">
<h2><i class="bi bi-people me-2"></i>User Management</h2>
<button class="btn btn-create" onclick="openCreateUserModal()">
<i class="bi bi-plus-lg me-2"></i>Create User
</button>
</div>
<div class="card-body">
<div id="alertContainer"></div>
<div id="usersContainer">
<div class="empty-state">
<i class="bi bi-person-circle"></i>
<p>Loading users...</p>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
let userModal = null;
let deleteModal = null;
let currentDeleteUserId = null;
document.addEventListener('DOMContentLoaded', function() {
userModal = new bootstrap.Modal(document.getElementById('userModal'));
deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
loadStats();
loadUsers();
});
function showLoader(show) {
document.getElementById('loader').style.display = show ? 'flex' : 'none';
}
function showAlert(message, type = 'success') {
const container = document.getElementById('alertContainer');
container.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
<i class="bi bi-${type === 'success' ? 'check-circle' : 'exclamation-circle'} me-2"></i>
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
setTimeout(() => container.innerHTML = '', 5000);
}
async function loadStats() {
try {
const res = await fetch('/api/admin/stats');
const data = await res.json();
document.getElementById('statUsers').textContent = data.users;
document.getElementById('statProjects').textContent = data.projects;
document.getElementById('statSections').textContent = data.sections;
document.getElementById('statUploads').textContent = data.uploads;
document.getElementById('statGenerations').textContent = data.generations;
document.getElementById('statDbSize').textContent = data.database_size_mb;
} catch (e) {
console.error('Failed to load stats:', e);
}
}
async function loadUsers() {
try {
const res = await fetch('/api/admin/users');
const data = await res.json();
const container = document.getElementById('usersContainer');
if (data.users.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="bi bi-person-circle"></i>
<p>No users found</p>
</div>
`;
return;
}
container.innerHTML = `
<table class="user-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Projects</th>
<th>Created</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${data.users.map(user => `
<tr>
<td>
<div class="d-flex align-items-center gap-3">
<div class="user-avatar">${user.username.charAt(0).toUpperCase()}</div>
<div>
<div class="fw-bold">${escapeHtml(user.username)}</div>
<div class="text-muted-custom">ID: ${user.id}</div>
</div>
</div>
</td>
<td>
<span class="${user.is_admin ? 'badge-admin' : 'badge-user'}">
${user.is_admin ? 'Admin' : 'User'}
</span>
</td>
<td>${user.project_count}</td>
<td class="text-muted-custom">${formatDate(user.created_at)}</td>
<td class="text-muted-custom">${user.last_login ? formatDate(user.last_login) : 'Never'}</td>
<td>
<button class="btn-action btn-action-edit" title="Edit" onclick="openEditUserModal(${user.id}, '${escapeHtml(user.username)}', ${user.is_admin})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn-action btn-action-delete" title="Delete" onclick="confirmDelete(${user.id}, '${escapeHtml(user.username)}')">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (e) {
console.error('Failed to load users:', e);
document.getElementById('usersContainer').innerHTML = `
<div class="alert alert-danger">Failed to load users. Please refresh the page.</div>
`;
}
}
function openCreateUserModal() {
document.getElementById('userModalTitle').textContent = 'Create User';
document.getElementById('userId').value = '';
document.getElementById('userUsername').value = '';
document.getElementById('userPassword').value = '';
document.getElementById('userPassword').required = true;
document.getElementById('passwordHint').textContent = 'At least 4 characters';
document.getElementById('userIsAdmin').checked = false;
document.getElementById('saveUserBtn').textContent = 'Create User';
userModal.show();
}
function openEditUserModal(id, username, isAdmin) {
document.getElementById('userModalTitle').textContent = 'Edit User';
document.getElementById('userId').value = id;
document.getElementById('userUsername').value = username;
document.getElementById('userPassword').value = '';
document.getElementById('userPassword').required = false;
document.getElementById('passwordHint').textContent = 'Leave blank to keep current password';
document.getElementById('userIsAdmin').checked = isAdmin;
document.getElementById('saveUserBtn').textContent = 'Save Changes';
userModal.show();
}
async function saveUser() {
const userId = document.getElementById('userId').value;
const username = document.getElementById('userUsername').value.trim();
const password = document.getElementById('userPassword').value;
const isAdmin = document.getElementById('userIsAdmin').checked;
if (!username || username.length < 3) {
showAlert('Username must be at least 3 characters', 'danger');
return;
}
if (!userId && (!password || password.length < 4)) {
showAlert('Password must be at least 4 characters', 'danger');
return;
}
showLoader(true);
try {
const url = userId ? `/api/admin/users/${userId}` : '/api/admin/users';
const method = userId ? 'PUT' : 'POST';
const body = { username, is_admin: isAdmin };
if (password) body.password = password;
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (res.ok) {
userModal.hide();
showAlert(userId ? 'User updated successfully' : 'User created successfully');
loadUsers();
loadStats();
} else {
showAlert(data.error || 'Operation failed', 'danger');
}
} catch (e) {
showAlert('Connection error. Please try again.', 'danger');
} finally {
showLoader(false);
}
}
function confirmDelete(userId, username) {
currentDeleteUserId = userId;
document.getElementById('deleteUsername').textContent = username;
document.getElementById('confirmDeleteBtn').onclick = () => deleteUser(userId);
deleteModal.show();
}
async function deleteUser(userId) {
showLoader(true);
deleteModal.hide();
try {
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
const data = await res.json();
if (res.ok) {
showAlert('User deleted successfully');
loadUsers();
loadStats();
} else {
showAlert(data.error || 'Delete failed', 'danger');
}
} catch (e) {
showAlert('Connection error. Please try again.', 'danger');
} finally {
showLoader(false);
}
}
async function logout() {
try {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
} catch (e) {
window.location.href = '/login';
}
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>

493
templates/index.html Normal file
View File

@@ -0,0 +1,493 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audio Transcription Editor & Reader</title>
<!-- External CSS Libraries -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<!-- Google Fonts -->
<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=Inter:wght@400;500;600;700;800&family=Lora:ital,wght@0,400..700;1,400..700&family=Poppins:wght@500;700&display=swap" rel="stylesheet">
<!-- Quill Rich Text Editor -->
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
<!-- Custom CSS -->
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<!-- Loading Overlay -->
<div class="loading-overlay" id="loader">
<div class="spinner-border text-primary mb-3" role="status"></div>
<h4 id="loadingText" class="fw-bold text-muted">Thinking...</h4>
<p class="text-muted small" id="loadingSubtext">Generating audio or aligning text...</p>
</div>
<!-- Floating Action Buttons -->
<div class="floating-controls" id="floatingControls">
<button class="floating-btn chapter-btn" onclick="insertChapterMarker()" title="Add Chapter Marker">
<i class="bi bi-bookmark-star"></i>
<span class="tooltip-text">Add Chapter</span>
</button>
<button class="floating-btn section-btn" onclick="insertSectionMarker()" title="Add Section Marker">
<i class="bi bi-file-earmark-text"></i>
<span class="tooltip-text">Add Section</span>
</button>
</div>
<!-- TTS Text Edit Modal -->
<div class="modal fade" id="ttsEditModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold"><i class="bi bi-soundwave me-2"></i>Edit Text for TTS Generation</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-2">
<i class="bi bi-info-circle-fill me-1"></i>
This text will be used to <strong>generate audio</strong>. The original text in the editor will still be used for the Reader and Export.
</p>
<textarea id="ttsTextInput" class="form-control" rows="10" style="font-family: 'Lora', serif; font-size: 1.1em;"></textarea>
<input type="hidden" id="currentMarkerId">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary fw-bold" onclick="saveTTSText()">Save TTS Text</button>
</div>
</div>
</div>
</div>
<!-- Library Modal -->
<div class="modal fade" id="libraryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold"><i class="bi bi-archive me-2"></i>My Library</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Database Stats -->
<div class="db-stats mb-4" id="dbStats">
<div class="stat-item">
<div class="stat-value" id="statUploads">0</div>
<div class="stat-label">Uploads</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statGenerations">0</div>
<div class="stat-label">Generations</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statProjects">0</div>
<div class="stat-label">Projects</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statSections">0</div>
<div class="stat-label">Sections</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statDbSize">0 MB</div>
<div class="stat-label">Database Size</div>
</div>
</div>
<!-- Library Tabs -->
<ul class="nav nav-pills mb-3" id="libraryTabs">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="pill" data-bs-target="#uploadsTab">Uploads</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#generationsTab">Generations</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#projectsTab">Projects</button>
</li>
</ul>
<!-- Library Tab Content -->
<div class="tab-content">
<div class="tab-pane fade show active" id="uploadsTab">
<div id="uploadsList"></div>
</div>
<div class="tab-pane fade" id="generationsTab">
<div id="generationsList"></div>
</div>
<div class="tab-pane fade" id="projectsTab">
<div id="projectsList"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Main Container -->
<div class="main-container">
<!-- App Header with User Menu -->
<div class="app-header">
<div><h1 class="m-0" style="font-size: 24px;"><i class="bi bi-soundwave me-2"></i>Audio Editor & Reader</h1></div>
<div class="d-flex align-items-center gap-3">
<button class="btn btn-light fw-bold" onclick="openLibrary()">
<i class="bi bi-archive me-2"></i>Library
</button>
<!-- User Menu Dropdown -->
<div class="dropdown">
<button class="btn btn-light fw-bold dropdown-toggle" type="button" data-bs-toggle="dropdown" id="userMenuBtn">
<i class="bi bi-person-circle me-2"></i><span id="currentUsername">User</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li class="px-3 py-2 text-muted small" id="userInfoHeader">
<i class="bi bi-info-circle me-1"></i>Logged in as <strong id="userInfoName">User</strong>
</li>
<li><hr class="dropdown-divider"></li>
<li id="adminLinkItem" style="display: none;">
<a class="dropdown-item" href="/admin">
<i class="bi bi-shield-lock me-2"></i>Admin Dashboard
</a>
</li>
<li id="adminDivider" style="display: none;"><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-danger" href="#" onclick="logout(); return false;">
<i class="bi bi-box-arrow-right me-2"></i>Logout
</a>
</li>
</ul>
</div>
<span class="version-badge">Kokoro AI Edition</span>
</div>
</div>
<!-- Input Card with Tabs -->
<div class="input-card">
<!-- Tab Navigation -->
<ul class="nav nav-tabs px-4 pt-3" id="inputTabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" id="upload-tab" data-bs-toggle="tab" data-bs-target="#upload-panel" type="button">
<i class="bi bi-upload me-2"></i>Upload File
</button>
</li>
<li class="nav-item">
<button class="nav-link" id="write-tab" data-bs-toggle="tab" data-bs-target="#write-panel" type="button">
<i class="bi bi-pen me-2"></i>Write & Generate
</button>
</li>
<li class="nav-item">
<button class="nav-link" id="bulk-tab" data-bs-toggle="tab" data-bs-target="#bulk-panel" type="button">
<i class="bi bi-layers-half me-2"></i>Bulk Audio Processing
</button>
</li>
</ul>
<!-- Tab Content -->
<div class="tab-content">
<!-- Upload Tab Panel -->
<div class="tab-pane fade show active p-5" id="upload-panel">
<form id="uploadForm" class="row g-3 align-items-end">
<div class="col-md-6">
<label class="form-label fw-bold small text-uppercase text-muted">Audio File</label>
<input class="form-control form-control-lg" type="file" id="audioFile" accept=".mp3, .wav">
</div>
<div class="col-md-6">
<label class="form-label fw-bold small text-uppercase text-muted">Story Text</label>
<input class="form-control form-control-lg" type="file" id="txtFile" accept=".txt">
</div>
<div class="col-12 mt-4">
<button type="submit" class="btn btn-dark w-100 py-3 fw-bold">Upload & Align</button>
</div>
</form>
</div>
<!-- Write & Generate Tab Panel -->
<div class="tab-pane fade" id="write-panel">
<div id="quill-editor"></div>
<div class="editor-actions">
<div class="d-flex gap-3 align-items-center">
<div>
<label class="small fw-bold text-muted d-block mb-1">VOICE</label>
<select class="form-select form-select-sm" id="voiceSelect" style="width: 220px;">
<option value="af_heart" selected>Heart (Fem)</option>
</select>
</div>
</div>
<button class="btn btn-primary fw-bold px-4 py-2" onclick="generateAudio()">
<i class="bi bi-magic me-2"></i>Generate Audio
</button>
</div>
</div>
<!-- Bulk Audio Processing Tab Panel -->
<div class="tab-pane fade" id="bulk-panel">
<div class="notion-editor-wrapper">
<div id="bulk-editor" contenteditable="true" spellcheck="false">
<p><br></p>
</div>
</div>
<div class="editor-actions">
<div class="d-flex align-items-center gap-3 w-100 justify-content-between">
<!-- Left side: Project name and Save button -->
<div class="d-flex align-items-center gap-3">
<div class="input-group" style="max-width: 300px;">
<span class="input-group-text bg-white fw-bold">Project Name</span>
<input type="text" id="bulkProjectName" class="form-control" placeholder="Book-1" value="Book-1">
</div>
<button class="btn save-project-btn" onclick="saveProject()" title="Save project without generating audio">
<i class="bi bi-save me-2"></i>Save Project
</button>
</div>
<!-- Right side: Export button -->
<button class="btn btn-dark fw-bold px-4 py-2" onclick="exportEverything()">
<i class="bi bi-box-arrow-up-right me-2"></i>Export Everything
</button>
</div>
</div>
<!-- Help text for bulk editor -->
<div class="px-4 pb-3">
<small class="text-muted">
<i class="bi bi-info-circle me-1"></i>
<strong>Tips:</strong> Use floating buttons (right side) to add Chapter/Section markers.
Each section can have an image (drag & drop, browse, or paste from clipboard).
Click <strong>Save Project</strong> to save without generating audio, or <strong>Export Everything</strong> to save and download as ZIP.
</small>
</div>
</div>
</div>
</div>
<!-- Editor Section (Timeline & Reader) -->
<div id="editorSection" class="d-none animate-fade-in">
<!-- Playlist Navigator -->
<div class="card mb-4 border-0 shadow-sm bg-light" id="playlistNavigator" style="display: none;">
<div class="card-body py-2">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-dark fw-bold" style="letter-spacing: 1px;">PLAYLIST</span>
<div class="btn-group">
<button class="btn btn-sm btn-white border" title="Previous" onclick="playPrevTrack()"><i class="bi bi-skip-backward-fill"></i></button>
<button class="btn btn-sm btn-white border" title="Next" onclick="playNextTrack()"><i class="bi bi-skip-forward-fill"></i></button>
</div>
</div>
<div class="flex-grow-1" style="max-width: 500px;">
<select class="form-select form-select-sm fw-bold text-dark" id="trackSelect" onchange="loadTrackFromPlaylist(this.value)">
<option>No tracks generated yet...</option>
</select>
</div>
<div class="small text-muted fw-bold" id="trackInfo"></div>
</div>
</div>
</div>
<!-- Audio Control Panel -->
<div class="control-panel">
<!-- Playback Controls -->
<div class="d-flex flex-wrap gap-3 align-items-center">
<div class="btn-group">
<button class="btn btn-light border fw-bold" onclick="togglePlayPause()">
<i class="bi bi-play-fill text-primary" id="playIcon"></i> <span id="playText">Play</span>
</button>
<button class="btn btn-light border" onclick="stopAudio()"><i class="bi bi-stop-fill"></i></button>
</div>
<!-- Pill Insert/Delete -->
<div class="btn-group">
<button class="btn btn-outline-primary fw-bold" onclick="insertPillAtPlayhead()"><i class="bi bi-plus-lg me-1"></i>Insert</button>
<button class="btn btn-outline-danger fw-bold" id="deleteBtn" onclick="deleteSelectedPill()" disabled><i class="bi bi-trash me-1"></i>Delete</button>
</div>
</div>
<!-- Single Export Controls -->
<div class="d-flex align-items-center gap-2 border-start ps-3" id="singleExportGroup">
<div class="input-group input-group-sm">
<span class="input-group-text bg-white fw-bold">Filename</span>
<input type="text" id="exportFilename" class="form-control" placeholder="1.1_task-1" value="1.1_task-1" style="max-width: 150px;">
<button class="btn btn-dark fw-bold" onclick="exportSingle()">
<i class="bi bi-box-arrow-up-right me-2"></i>Export
</button>
</div>
</div>
<!-- Speed & Zoom Controls -->
<div class="d-flex align-items-center gap-4">
<div class="d-flex align-items-center gap-2">
<span class="text-muted small fw-bold">SPEED</span>
<input type="range" class="form-range" id="speedSlider" min="0.5" max="2.0" step="0.1" value="1.0" style="width: 80px;">
<span id="speedDisplay" class="badge bg-secondary">1.0x</span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted small fw-bold">ZOOM</span>
<input type="range" class="form-range" id="zoomSlider" min="50" max="500" value="100" style="width: 100px;">
</div>
</div>
</div>
<!-- Timeline/Waveform Editor -->
<div class="timeline-wrapper" id="timelineWrapper">
<div class="timeline-content" id="timelineContent">
<div id="custom-playhead"></div>
<div id="timeline-ruler"></div>
<div class="audio-track-container">
<div class="track-label">Audio</div>
<div id="waveform"></div>
</div>
<div class="transcription-track-container" id="transcriptionTrack">
<div class="track-label">Transcript</div>
<div id="transcription-content"></div>
</div>
</div>
</div>
<!-- Interactive Reader -->
<div class="reader-section">
<div class="reader-header">
<div class="d-flex align-items-center gap-3">
<span>Interactive Reader</span>
<span id="syncStatus" class="badge bg-secondary fs-6">Not Loaded</span>
</div>
<div class="form-check form-switch fs-6">
<input class="form-check-input" type="checkbox" id="mismatchToggle" onchange="toggleMismatches()">
<label class="form-check-label fw-bold text-muted" for="mismatchToggle">Show Mismatches</label>
</div>
</div>
<article class="story-text-container" id="readerContent"></article>
</div>
</div>
</div>
<!-- External JS Libraries -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/wavesurfer.js@7/dist/wavesurfer.min.js"></script>
<script src="https://unpkg.com/wavesurfer.js@7/dist/plugins/timeline.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill@1.3.6/dist/quill.min.js"></script>
<!-- Application JS Modules -->
<script src="/static/js/editor.js"></script>
<script src="/static/js/timeline.js"></script>
<script src="/static/js/interactive-reader.js"></script>
<script src="/static/js/app.js"></script>
<!-- User Authentication & Tab Change Handling -->
<script>
// ==========================================
// USER AUTHENTICATION
// ==========================================
/**
* Load current user info and update UI
*/
async function loadCurrentUser() {
try {
const res = await fetch('/api/me');
if (res.ok) {
const data = await res.json();
const username = data.user.username;
// Update username displays
document.getElementById('currentUsername').textContent = username;
document.getElementById('userInfoName').textContent = username;
// Show admin link if user is admin
if (data.user.is_admin) {
document.getElementById('adminLinkItem').style.display = 'block';
document.getElementById('adminDivider').style.display = 'block';
}
console.log(`👤 Logged in as: ${username} ${data.user.is_admin ? '(Admin)' : ''}`);
} else if (res.status === 401) {
// Not authenticated, redirect to login
console.log('🔒 Not authenticated, redirecting to login...');
window.location.href = '/login';
}
} catch (e) {
console.error('Failed to load user info:', e);
// On error, redirect to login as a safety measure
window.location.href = '/login';
}
}
/**
* Logout and redirect to login page
*/
async function logout() {
try {
await fetch('/api/logout', { method: 'POST' });
console.log('👋 Logged out successfully');
} catch (e) {
console.error('Logout error:', e);
}
window.location.href = '/login';
}
// ==========================================
// TAB CHANGE EVENT HANDLING
// ==========================================
document.addEventListener('DOMContentLoaded', function() {
// Load current user info
loadCurrentUser();
// Handle tab switching for floating controls
const tabElements = document.querySelectorAll('#inputTabs button[data-bs-toggle="tab"]');
tabElements.forEach(tab => {
tab.addEventListener('shown.bs.tab', function(event) {
const targetId = event.target.getAttribute('data-bs-target');
if (targetId === '#bulk-panel') {
// Show floating controls for bulk processing
toggleFloatingControls(true);
// Re-initialize image handlers
setTimeout(initializeImageHandlers, 100);
} else {
// Hide floating controls for other tabs
toggleFloatingControls(false);
}
});
});
// Initial state - hide floating controls since Upload tab is active by default
toggleFloatingControls(false);
});
// ==========================================
// SESSION CHECK (Periodic)
// ==========================================
/**
* Periodically check if session is still valid
*/
function startSessionCheck() {
// Check session every 5 minutes
setInterval(async () => {
try {
const res = await fetch('/api/me');
if (res.status === 401) {
console.log('🔒 Session expired, redirecting to login...');
window.location.href = '/login';
}
} catch (e) {
// Network error - don't redirect, might be temporary
console.warn('Session check failed:', e);
}
}, 5 * 60 * 1000); // 5 minutes
}
// Start session checking
startSessionCheck();
</script>
</body>
</html>

275
templates/login.html Normal file
View File

@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Audio Transcription Editor</title>
<!-- External CSS Libraries -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<!-- Google Fonts -->
<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=Inter:wght@400;500;600;700;800&family=Poppins:wght@500;700&display=swap" rel="stylesheet">
<style>
:root {
--accent-primary: #667eea;
--accent-secondary: #764ba2;
}
* { box-sizing: border-box; }
body {
font-family: 'Inter', sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-container {
width: 100%;
max-width: 420px;
}
.login-card {
background: white;
border-radius: 20px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.login-header {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
color: white;
padding: 40px 30px;
text-align: center;
}
.login-header h1 {
font-family: 'Poppins', sans-serif;
font-weight: 700;
font-size: 1.5rem;
margin-bottom: 8px;
}
.login-header p {
opacity: 0.9;
margin: 0;
font-size: 0.95rem;
}
.login-icon {
width: 80px;
height: 80px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
font-size: 2.5rem;
}
.login-body {
padding: 40px 30px;
}
.form-label {
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
.form-control {
border: 2px solid #e5e7eb;
border-radius: 12px;
padding: 14px 16px;
font-size: 1rem;
transition: all 0.3s;
}
.form-control:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.15);
}
.input-group-text {
background: #f9fafb;
border: 2px solid #e5e7eb;
border-right: none;
border-radius: 12px 0 0 12px;
color: #6b7280;
}
.input-group .form-control {
border-radius: 0 12px 12px 0;
border-left: none;
}
.input-group:focus-within .input-group-text {
border-color: var(--accent-primary);
}
.btn-login {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
border: none;
border-radius: 12px;
padding: 14px;
font-weight: 700;
font-size: 1rem;
color: white;
width: 100%;
transition: all 0.3s;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
color: white;
}
.btn-login:active {
transform: translateY(0);
}
.btn-login:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.alert {
border-radius: 12px;
border: none;
font-weight: 500;
}
.alert-danger {
background: #fef2f2;
color: #dc2626;
}
.version-badge {
text-align: center;
margin-top: 20px;
color: rgba(255, 255, 255, 0.8);
font-size: 0.85rem;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="login-icon">
<i class="bi bi-soundwave"></i>
</div>
<h1>Audio Transcription Editor</h1>
<p>Sign in to access your projects</p>
</div>
<div class="login-body">
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
<i class="bi bi-exclamation-circle me-2"></i>
<span id="errorMessage"></span>
</div>
<form id="loginForm">
<div class="mb-4">
<label class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input type="text" class="form-control" id="username" placeholder="Enter your username" required autofocus>
</div>
</div>
<div class="mb-4">
<label class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock"></i></span>
<input type="password" class="form-control" id="password" placeholder="Enter your password" required>
</div>
</div>
<button type="submit" class="btn btn-login" id="loginBtn">
<span id="loginText">Sign In</span>
<span id="loginSpinner" class="d-none">
<span class="spinner-border spinner-border-sm me-2"></span>
Signing in...
</span>
</button>
</form>
</div>
</div>
<div class="version-badge">
<i class="bi bi-shield-lock me-1"></i> Secure Login • Kokoro AI Edition
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const loginBtn = document.getElementById('loginBtn');
const loginText = document.getElementById('loginText');
const loginSpinner = document.getElementById('loginSpinner');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
// Show loading state
loginBtn.disabled = true;
loginText.classList.add('d-none');
loginSpinner.classList.remove('d-none');
errorAlert.classList.add('d-none');
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
// Redirect to main app
window.location.href = '/';
} else {
// Show error
errorMessage.textContent = data.error || 'Login failed. Please try again.';
errorAlert.classList.remove('d-none');
}
} catch (error) {
errorMessage.textContent = 'Connection error. Please try again.';
errorAlert.classList.remove('d-none');
} finally {
// Reset button state
loginBtn.disabled = false;
loginText.classList.remove('d-none');
loginSpinner.classList.add('d-none');
}
});
// Focus username field on load
document.getElementById('username').focus();
</script>
</body>
</html>