first commit
This commit is contained in:
70
.dockerignore
Normal file
70
.dockerignore
Normal 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
11
.env.example
Normal 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
80
.gitignore
vendored
Normal 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
51
Dockerfile
Normal 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"]
|
||||
26
coolify.json
Normal file
26
coolify.json
Normal 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
256
doc.py
Normal 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
30
docker-compose.yml
Normal 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
503
reader_templates/Reader.html
Executable 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
496
reader_templates/index.html
Executable 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
10
requirements.txt
Normal 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
760
static/css/style.css
Normal 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
893
static/js/app.js
Normal 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
1070
static/js/editor.js
Normal file
File diff suppressed because it is too large
Load Diff
270
static/js/interactive-reader.js
Normal file
270
static/js/interactive-reader.js
Normal 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
460
static/js/timeline.js
Normal 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
703
templates/admin.html
Normal 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
493
templates/index.html
Normal 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
275
templates/login.html
Normal 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>
|
||||
Reference in New Issue
Block a user