Add auth, Nextcloud, voice lock, saved docs, progress saving, and all new TTS voices

- Auth system: JWT login/register with Cloudflare Turnstile, admin + user roles
- Admin panel: user management (role/active toggle, delete), Nextcloud config
- 6 TTS models: OpenAI TTS / TTS-HD, ElevenLabs, Gemini 2.5 Flash/Pro
- Voice lock: prevent model/voice changes while audio is playing
- Nextcloud browser: PROPFIND WebDAV, folder navigation, PDF loading
- Saved documents: save/load with PUT/POST, progress auto-saves every 2s
- Resume playback: remembers sentence position per user per document
- Better sentence highlighting with glow effect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ifedan-ed 2026-04-11 03:23:18 +02:00
parent 87d38183c0
commit fb8e1340f1
9 changed files with 1555 additions and 349 deletions

58
auth.py Normal file
View file

@ -0,0 +1,58 @@
from passlib.context import CryptContext
from jose import JWTError, jwt
from datetime import datetime, timedelta
from typing import Optional
from fastapi import Depends, HTTPException, status, Cookie
from sqlalchemy.orm import Session
from models import get_db, User
SECRET_KEY = "speechify-jwt-secret-2025-change-in-prod"
ALGORITHM = "HS256"
TOKEN_EXPIRE_HOURS = 48
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd.verify(plain, hashed)
def create_token(user_id: int, username: str, role: str) -> str:
expire = datetime.utcnow() + timedelta(hours=TOKEN_EXPIRE_HOURS)
return jwt.encode(
{"sub": str(user_id), "username": username, "role": role, "exp": expire},
SECRET_KEY,
algorithm=ALGORITHM,
)
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
return None
def get_current_user(
token: Optional[str] = Cookie(None, alias="auth_token"),
db: Session = Depends(get_db),
) -> User:
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
payload = decode_token(token)
if not payload:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(User).filter(User.id == int(payload["sub"])).first()
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
def require_admin(user: User = Depends(get_current_user)) -> User:
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required")
return user

612
main.py
View file

@ -1,139 +1,212 @@
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Response, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, Response
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel
import httpx
import re
import os
from sqlalchemy.orm import Session
from typing import Optional
import httpx, re, uuid, requests as req_lib
from datetime import datetime
app = FastAPI(title="Speechify Clone")
from models import get_db, init_db, User, Document, Progress, Setting
from auth import (
hash_password, verify_password, create_token, get_current_user, require_admin
)
# ── CONFIG ──────────────────────────────────────────────────────────
LITELLM_BASE = "http://localhost:4000"
LITELLM_KEY = "sk-c5c58adcc8dda288067f034ee927be23509142d274fe48418c25009dfb507a52"
LITELLM_KEY = "sk-c5c58adcc8dda288067f034ee927be23509142d274fe48418c25009dfb507a52"
TURNSTILE_SECRET = "0x4AAAAAAC0VtE0VOo0vpGoTvmHSk8yRN4Y"
TTS_MODELS = {
"openai-tts-1": {
"name": "OpenAI TTS",
"quality": "Standard",
"voices": [
{"id": "alloy", "name": "Alloy"},
{"id": "echo", "name": "Echo"},
{"id": "fable", "name": "Fable"},
{"id": "onyx", "name": "Onyx"},
{"id": "nova", "name": "Nova"},
{"id": "alloy", "name": "Alloy"},
{"id": "ash", "name": "Ash"},
{"id": "coral", "name": "Coral"},
{"id": "echo", "name": "Echo"},
{"id": "fable", "name": "Fable"},
{"id": "nova", "name": "Nova"},
{"id": "onyx", "name": "Onyx"},
{"id": "sage", "name": "Sage"},
{"id": "shimmer", "name": "Shimmer"},
]
],
},
"openai-tts-1-hd": {
"name": "OpenAI TTS HD",
"quality": "High Definition",
"voices": [
{"id": "alloy", "name": "Alloy"},
{"id": "echo", "name": "Echo"},
{"id": "fable", "name": "Fable"},
{"id": "onyx", "name": "Onyx"},
{"id": "nova", "name": "Nova"},
{"id": "alloy", "name": "Alloy"},
{"id": "ash", "name": "Ash"},
{"id": "coral", "name": "Coral"},
{"id": "echo", "name": "Echo"},
{"id": "fable", "name": "Fable"},
{"id": "nova", "name": "Nova"},
{"id": "onyx", "name": "Onyx"},
{"id": "sage", "name": "Sage"},
{"id": "shimmer", "name": "Shimmer"},
]
],
},
"elevenlabs-v3": {
"name": "ElevenLabs v3",
"quality": "Premium",
"voices": [
{"id": "Rachel", "name": "Rachel"},
{"id": "Drew", "name": "Drew"},
{"id": "Clyde", "name": "Clyde"},
{"id": "Paul", "name": "Paul"},
{"id": "Domi", "name": "Domi"},
{"id": "Dave", "name": "Dave"},
{"id": "Fin", "name": "Fin"},
{"id": "Bella", "name": "Bella"},
{"id": "Antoni", "name": "Antoni"},
{"id": "Thomas", "name": "Thomas"},
{"id": "Rachel", "name": "Rachel"},
{"id": "Drew", "name": "Drew"},
{"id": "Clyde", "name": "Clyde"},
{"id": "Paul", "name": "Paul"},
{"id": "Domi", "name": "Domi"},
{"id": "Dave", "name": "Dave"},
{"id": "Fin", "name": "Fin"},
{"id": "Bella", "name": "Bella"},
{"id": "Antoni", "name": "Antoni"},
{"id": "Thomas", "name": "Thomas"},
{"id": "Charlie", "name": "Charlie"},
{"id": "Emily", "name": "Emily"},
{"id": "Elli", "name": "Elli"},
{"id": "Callum", "name": "Callum"},
{"id": "Emily", "name": "Emily"},
{"id": "Elli", "name": "Elli"},
{"id": "Callum", "name": "Callum"},
{"id": "Patrick", "name": "Patrick"},
{"id": "Harry", "name": "Harry"},
{"id": "Liam", "name": "Liam"},
{"id": "Harry", "name": "Harry"},
{"id": "Liam", "name": "Liam"},
{"id": "Dorothy", "name": "Dorothy"},
{"id": "Josh", "name": "Josh"},
{"id": "Arnold", "name": "Arnold"},
{"id": "Adam", "name": "Adam"},
{"id": "Sam", "name": "Sam"},
]
{"id": "Josh", "name": "Josh"},
{"id": "Arnold", "name": "Arnold"},
{"id": "Adam", "name": "Adam"},
{"id": "Sam", "name": "Sam"},
],
},
"elevenlabs-multilingual": {
"name": "ElevenLabs Multilingual",
"quality": "Premium Multilingual",
"voices": [
{"id": "Rachel", "name": "Rachel"},
{"id": "Drew", "name": "Drew"},
{"id": "Clyde", "name": "Clyde"},
{"id": "Paul", "name": "Paul"},
{"id": "Domi", "name": "Domi"},
{"id": "Dave", "name": "Dave"},
{"id": "Fin", "name": "Fin"},
{"id": "Bella", "name": "Bella"},
{"id": "Antoni", "name": "Antoni"},
{"id": "Thomas", "name": "Thomas"},
{"id": "Rachel", "name": "Rachel"},
{"id": "Drew", "name": "Drew"},
{"id": "Clyde", "name": "Clyde"},
{"id": "Paul", "name": "Paul"},
{"id": "Domi", "name": "Domi"},
{"id": "Dave", "name": "Dave"},
{"id": "Fin", "name": "Fin"},
{"id": "Bella", "name": "Bella"},
{"id": "Antoni", "name": "Antoni"},
{"id": "Thomas", "name": "Thomas"},
{"id": "Charlie", "name": "Charlie"},
{"id": "Emily", "name": "Emily"},
{"id": "Elli", "name": "Elli"},
{"id": "Callum", "name": "Callum"},
{"id": "Emily", "name": "Emily"},
{"id": "Elli", "name": "Elli"},
{"id": "Callum", "name": "Callum"},
{"id": "Patrick", "name": "Patrick"},
{"id": "Harry", "name": "Harry"},
{"id": "Liam", "name": "Liam"},
{"id": "Harry", "name": "Harry"},
{"id": "Liam", "name": "Liam"},
{"id": "Dorothy", "name": "Dorothy"},
{"id": "Josh", "name": "Josh"},
{"id": "Arnold", "name": "Arnold"},
{"id": "Adam", "name": "Adam"},
{"id": "Sam", "name": "Sam"},
]
{"id": "Josh", "name": "Josh"},
{"id": "Arnold", "name": "Arnold"},
{"id": "Adam", "name": "Adam"},
{"id": "Sam", "name": "Sam"},
],
},
"vertex-gemini-2.5-flash-tts": {
"name": "Gemini 2.5 Flash TTS",
"quality": "Fast",
"voices": [
{"id": "Aoede", "name": "Aoede"},
{"id": "Charon", "name": "Charon"},
{"id": "Fenrir", "name": "Fenrir"},
{"id": "Kore", "name": "Kore"},
{"id": "Puck", "name": "Puck"},
{"id": "Orbit", "name": "Orbit"},
{"id": "Zephyr", "name": "Zephyr"},
{"id": "Autonoe", "name": "Autonoe"},
{"id": "Enceladus", "name": "Enceladus"},
{"id": "Laomedeia", "name": "Laomedeia"},
{"id": "Achernar", "name": "Achernar"},
{"id": "Aoede", "name": "Aoede"},
{"id": "Charon", "name": "Charon"},
{"id": "Fenrir", "name": "Fenrir"},
{"id": "Kore", "name": "Kore"},
{"id": "Puck", "name": "Puck"},
{"id": "Orbit", "name": "Orbit"},
{"id": "Zephyr", "name": "Zephyr"},
{"id": "Autonoe", "name": "Autonoe"},
{"id": "Enceladus", "name": "Enceladus"},
{"id": "Laomedeia", "name": "Laomedeia"},
{"id": "Achernar", "name": "Achernar"},
{"id": "Rasalgethi", "name": "Rasalgethi"},
{"id": "Schedar", "name": "Schedar"},
{"id": "Sulafat", "name": "Sulafat"},
]
{"id": "Schedar", "name": "Schedar"},
{"id": "Sulafat", "name": "Sulafat"},
],
},
"vertex-gemini-2.5-pro-tts": {
"name": "Gemini 2.5 Pro TTS",
"quality": "High Quality",
"voices": [
{"id": "Aoede", "name": "Aoede"},
{"id": "Charon", "name": "Charon"},
{"id": "Fenrir", "name": "Fenrir"},
{"id": "Kore", "name": "Kore"},
{"id": "Puck", "name": "Puck"},
{"id": "Orbit", "name": "Orbit"},
{"id": "Zephyr", "name": "Zephyr"},
{"id": "Autonoe", "name": "Autonoe"},
{"id": "Enceladus", "name": "Enceladus"},
{"id": "Laomedeia", "name": "Laomedeia"},
{"id": "Achernar", "name": "Achernar"},
{"id": "Aoede", "name": "Aoede"},
{"id": "Charon", "name": "Charon"},
{"id": "Fenrir", "name": "Fenrir"},
{"id": "Kore", "name": "Kore"},
{"id": "Puck", "name": "Puck"},
{"id": "Orbit", "name": "Orbit"},
{"id": "Zephyr", "name": "Zephyr"},
{"id": "Autonoe", "name": "Autonoe"},
{"id": "Enceladus", "name": "Enceladus"},
{"id": "Laomedeia", "name": "Laomedeia"},
{"id": "Achernar", "name": "Achernar"},
{"id": "Rasalgethi", "name": "Rasalgethi"},
{"id": "Schedar", "name": "Schedar"},
{"id": "Sulafat", "name": "Sulafat"},
]
{"id": "Schedar", "name": "Schedar"},
{"id": "Sulafat", "name": "Sulafat"},
],
},
}
# ── APP ─────────────────────────────────────────────────────────────
app = FastAPI(title="Speechify Clone")
@app.on_event("startup")
def startup():
init_db()
db = next(get_db())
# Create default admin if no users exist
if db.query(User).count() == 0:
admin = User(
username="admin",
email="admin@speechify.local",
password_hash=hash_password("admin123"),
role="admin",
)
db.add(admin)
db.commit()
print("\n" + "="*50)
print("Default admin created: admin / admin123")
print("="*50 + "\n")
db.close()
# ── HELPERS ─────────────────────────────────────────────────────────
def verify_turnstile(token: str, ip: str = "") -> bool:
try:
r = req_lib.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data={"secret": TURNSTILE_SECRET, "response": token, "remoteip": ip},
timeout=10,
)
return r.json().get("success", False)
except Exception:
return False
def get_setting(db: Session, key: str, default: str = "") -> str:
s = db.query(Setting).filter(Setting.key == key).first()
return s.value if s else default
def set_setting(db: Session, key: str, value: str):
s = db.query(Setting).filter(Setting.key == key).first()
if s:
s.value = value
else:
db.add(Setting(key=key, value=value))
db.commit()
# ── PYDANTIC MODELS ──────────────────────────────────────────────────
class LoginRequest(BaseModel):
username: str
password: str
turnstile_token: str
class RegisterRequest(BaseModel):
username: str
email: str
password: str
turnstile_token: str
class TTSRequest(BaseModel):
text: str
@ -141,23 +214,306 @@ class TTSRequest(BaseModel):
voice: str
speed: float = 1.0
class URLRequest(BaseModel):
url: str
class SaveDocRequest(BaseModel):
title: str
content: str
source_type: str = "text"
class ProgressRequest(BaseModel):
sentence_index: int
class NextcloudConfigRequest(BaseModel):
url: str
username: str
password: str
class NextcloudDownloadRequest(BaseModel):
path: str
class UserUpdateRequest(BaseModel):
role: Optional[str] = None
is_active: Optional[bool] = None
class SettingsRequest(BaseModel):
nextcloud_url: Optional[str] = None
nextcloud_username: Optional[str] = None
nextcloud_password: Optional[str] = None
# ── AUTH ROUTES ─────────────────────────────────────────────────────
@app.post("/api/auth/login")
async def login(req: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)):
ip = request.client.host if request.client else ""
if not verify_turnstile(req.turnstile_token, ip):
raise HTTPException(status_code=400, detail="Turnstile verification failed")
user = db.query(User).filter(User.username == req.username).first()
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account disabled")
token = create_token(user.id, user.username, user.role)
response.set_cookie("auth_token", token, httponly=True, samesite="lax", max_age=172800)
return {"username": user.username, "role": user.role}
@app.post("/api/auth/register")
async def register(req: RegisterRequest, response: Response, request: Request, db: Session = Depends(get_db)):
ip = request.client.host if request.client else ""
if not verify_turnstile(req.turnstile_token, ip):
raise HTTPException(status_code=400, detail="Turnstile verification failed")
if db.query(User).filter(User.username == req.username).first():
raise HTTPException(status_code=400, detail="Username already taken")
if db.query(User).filter(User.email == req.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
username=req.username,
email=req.email,
password_hash=hash_password(req.password),
role="user",
)
db.add(user)
db.commit()
db.refresh(user)
token = create_token(user.id, user.username, user.role)
response.set_cookie("auth_token", token, httponly=True, samesite="lax", max_age=172800)
return {"username": user.username, "role": user.role}
@app.post("/api/auth/logout")
async def logout(response: Response):
response.delete_cookie("auth_token")
return {"ok": True}
@app.get("/api/auth/me")
async def me(user: User = Depends(get_current_user)):
return {"id": user.id, "username": user.username, "email": user.email, "role": user.role}
# ── ADMIN ROUTES ─────────────────────────────────────────────────────
@app.get("/api/admin/users")
async def list_users(admin: User = Depends(require_admin), db: Session = Depends(get_db)):
users = db.query(User).all()
return [{"id": u.id, "username": u.username, "email": u.email, "role": u.role,
"is_active": u.is_active, "created_at": u.created_at.isoformat()} for u in users]
@app.patch("/api/admin/users/{user_id}")
async def update_user(user_id: int, req: UserUpdateRequest,
admin: User = Depends(require_admin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if req.role is not None:
user.role = req.role
if req.is_active is not None:
user.is_active = req.is_active
db.commit()
return {"ok": True}
@app.delete("/api/admin/users/{user_id}")
async def delete_user(user_id: int, admin: User = Depends(require_admin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
db.delete(user)
db.commit()
return {"ok": True}
@app.get("/api/admin/settings")
async def get_settings(admin: User = Depends(require_admin), db: Session = Depends(get_db)):
return {
"nextcloud_url": get_setting(db, "nextcloud_url"),
"nextcloud_username": get_setting(db, "nextcloud_username"),
"nextcloud_password": get_setting(db, "nextcloud_password"),
}
@app.post("/api/admin/settings")
async def save_settings(req: SettingsRequest, admin: User = Depends(require_admin), db: Session = Depends(get_db)):
if req.nextcloud_url is not None: set_setting(db, "nextcloud_url", req.nextcloud_url)
if req.nextcloud_username is not None: set_setting(db, "nextcloud_username", req.nextcloud_username)
if req.nextcloud_password is not None: set_setting(db, "nextcloud_password", req.nextcloud_password)
return {"ok": True}
# ── DOCUMENT ROUTES ──────────────────────────────────────────────────
@app.get("/api/documents")
async def list_documents(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
docs = db.query(Document).filter(Document.user_id == user.id).order_by(Document.created_at.desc()).all()
return [{"id": d.id, "title": d.title, "source_type": d.source_type,
"created_at": d.created_at.isoformat()} for d in docs]
@app.post("/api/documents")
async def save_document(req: SaveDocRequest, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
doc = Document(
id=str(uuid.uuid4()),
user_id=user.id,
title=req.title,
content=req.content,
source_type=req.source_type,
)
db.add(doc)
db.commit()
return {"id": doc.id}
@app.put("/api/documents/{doc_id}")
async def update_document(doc_id: str, req: SaveDocRequest,
user: User = Depends(get_current_user), db: Session = Depends(get_db)):
doc = db.query(Document).filter(Document.id == doc_id, Document.user_id == user.id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
doc.title = req.title
doc.content = req.content
db.commit()
return {"id": doc.id}
@app.get("/api/documents/{doc_id}")
async def get_document(doc_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
doc = db.query(Document).filter(Document.id == doc_id, Document.user_id == user.id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return {"id": doc.id, "title": doc.title, "content": doc.content, "source_type": doc.source_type}
@app.delete("/api/documents/{doc_id}")
async def delete_document(doc_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
doc = db.query(Document).filter(Document.id == doc_id, Document.user_id == user.id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
db.query(Progress).filter(Progress.document_id == doc_id).delete()
db.delete(doc)
db.commit()
return {"ok": True}
# ── PROGRESS ROUTES ──────────────────────────────────────────────────
@app.get("/api/progress/{doc_id}")
async def get_progress(doc_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
p = db.query(Progress).filter(Progress.user_id == user.id, Progress.document_id == doc_id).first()
return {"sentence_index": p.sentence_index if p else 0}
@app.post("/api/progress/{doc_id}")
async def save_progress(doc_id: str, req: ProgressRequest,
user: User = Depends(get_current_user), db: Session = Depends(get_db)):
p = db.query(Progress).filter(Progress.user_id == user.id, Progress.document_id == doc_id).first()
if p:
p.sentence_index = req.sentence_index
p.updated_at = datetime.utcnow()
else:
db.add(Progress(user_id=user.id, document_id=doc_id, sentence_index=req.sentence_index))
db.commit()
return {"ok": True}
# ── NEXTCLOUD ROUTES ─────────────────────────────────────────────────
@app.get("/api/nextcloud/files")
async def list_nextcloud_files(path: str = "/", user: User = Depends(get_current_user), db: Session = Depends(get_db)):
nc_url = get_setting(db, "nextcloud_url")
nc_user = get_setting(db, "nextcloud_username")
nc_pass = get_setting(db, "nextcloud_password")
if not nc_url:
raise HTTPException(status_code=400, detail="Nextcloud not configured")
dav_url = f"{nc_url.rstrip('/')}/remote.php/dav/files/{nc_user}/{path.lstrip('/')}"
try:
r = req_lib.request(
"PROPFIND", dav_url,
auth=(nc_user, nc_pass),
headers={"Depth": "1"},
timeout=15,
)
if r.status_code not in (207, 200):
raise HTTPException(status_code=r.status_code, detail="Nextcloud error")
# Parse WebDAV XML response
from xml.etree import ElementTree as ET
ns = {"d": "DAV:"}
tree = ET.fromstring(r.text)
items = []
for resp in tree.findall("d:response", ns):
href = resp.find("d:href", ns).text
prop = resp.find(".//d:prop", ns)
display_name = prop.find("d:displayname", ns)
content_type = prop.find("d:getcontenttype", ns)
is_collection = prop.find("d:resourcetype/d:collection", ns) is not None
name = display_name.text if display_name is not None else href.split("/")[-1]
mime = content_type.text if content_type is not None else ""
if not name:
continue
# Extract path relative to user's root
dav_root = f"/remote.php/dav/files/{nc_user}/"
rel_path = href.split(dav_root)[-1] if dav_root in href else href
if is_collection or mime == "application/pdf" or rel_path.lower().endswith(".pdf"):
items.append({
"name": name,
"path": rel_path,
"is_dir": is_collection,
"mime": mime,
})
return items
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/nextcloud/download")
async def download_nextcloud_file(req: NextcloudDownloadRequest,
user: User = Depends(get_current_user), db: Session = Depends(get_db)):
nc_url = get_setting(db, "nextcloud_url")
nc_user = get_setting(db, "nextcloud_username")
nc_pass = get_setting(db, "nextcloud_password")
if not nc_url:
raise HTTPException(status_code=400, detail="Nextcloud not configured")
try:
import fitz
dav_url = f"{nc_url.rstrip('/')}/remote.php/dav/files/{nc_user}/{req.path.lstrip('/')}"
r = req_lib.get(dav_url, auth=(nc_user, nc_pass), timeout=30)
if r.status_code != 200:
raise HTTPException(status_code=r.status_code, detail="File download failed")
doc = fitz.open(stream=r.content, filetype="pdf")
pages = [page.get_text() for page in doc]
text = re.sub(r'\n{3,}', '\n\n', "\n\n".join(pages)).strip()
title = req.path.split("/")[-1]
return {"text": text, "title": title}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── VOICES ───────────────────────────────────────────────────────────
@app.get("/api/voices")
async def get_voices():
async def get_voices(_: User = Depends(get_current_user)):
return TTS_MODELS
# ── TTS ──────────────────────────────────────────────────────────────
@app.post("/api/tts")
async def text_to_speech(req: TTSRequest):
payload = {
"model": req.model,
"input": req.text,
"voice": req.voice,
}
async def text_to_speech(req: TTSRequest, _: User = Depends(get_current_user)):
payload = {"model": req.model, "input": req.text, "voice": req.voice}
if req.speed != 1.0:
payload["speed"] = req.speed
@ -167,82 +523,72 @@ async def text_to_speech(req: TTSRequest):
json=payload,
headers={"Authorization": f"Bearer {LITELLM_KEY}"},
)
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail=resp.text)
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail=resp.text)
return Response(
content=resp.content,
media_type="audio/mpeg",
)
from fastapi.responses import Response
return Response(content=resp.content, media_type="audio/mpeg")
# ── URL EXTRACTION ────────────────────────────────────────────────────
@app.post("/api/extract-url")
async def extract_from_url(req: URLRequest):
async def extract_from_url(req: URLRequest, _: User = Depends(get_current_user)):
try:
from bs4 import BeautifulSoup
import html2text
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
resp = await client.get(
req.url,
headers={"User-Agent": "Mozilla/5.0 (compatible; SpeechifyClone/1.0)"},
)
resp = await client.get(req.url, headers={"User-Agent": "Mozilla/5.0"})
soup = BeautifulSoup(resp.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header", "aside", "iframe"]):
tag.decompose()
main = (
soup.find("article")
or soup.find("main")
or soup.find(id="content")
or soup.find(class_="content")
or soup.body
)
main = soup.find("article") or soup.find("main") or soup.find(id="content") or soup.body
h = html2text.HTML2Text()
h.ignore_links = True
h.ignore_images = True
h.body_width = 0
text = h.handle(str(main))
text = re.sub(r'\n{3,}', '\n\n', text).strip()
text = re.sub(r'\n{3,}', '\n\n', h.handle(str(main))).strip()
title = soup.title.string.strip() if soup.title else req.url
return {"text": text, "title": title}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# ── PDF EXTRACTION ────────────────────────────────────────────────────
@app.post("/api/extract-pdf")
async def extract_from_pdf(file: UploadFile = File(...)):
async def extract_from_pdf(file: UploadFile = File(...), _: User = Depends(get_current_user)):
"""
Reads the binary PDF file, extracts all text content from each page,
and returns it as plain text that can be fed to TTS.
"""
try:
import fitz # PyMuPDF
content = await file.read()
doc = fitz.open(stream=content, filetype="pdf")
pages = []
for page in doc:
pages.append(page.get_text())
text = "\n\n".join(pages)
text = re.sub(r'\n{3,}', '\n\n', text).strip()
pages = [page.get_text() for page in doc]
text = re.sub(r'\n{3,}', '\n\n', "\n\n".join(pages)).strip()
return {"text": text, "title": file.filename}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# ── STATIC / PAGES ────────────────────────────────────────────────────
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/login")
async def login_page():
return FileResponse("static/login.html")
@app.get("/")
async def root():
async def root(request: Request):
# Let the frontend handle auth redirect via /api/auth/me
return FileResponse("static/index.html")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080, reload=True)
@app.get("/admin")
async def admin_page():
return FileResponse("static/admin.html")

61
models.py Normal file
View file

@ -0,0 +1,61 @@
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Boolean, ForeignKey, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from datetime import datetime
import uuid
DATABASE_URL = "sqlite:///./speechify.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(100), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
role = Column(String(10), default="user") # "admin" or "user"
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
class Document(Base):
__tablename__ = "documents"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String(500))
content = Column(Text)
source_type = Column(String(20)) # text, pdf, url, nextcloud
created_at = Column(DateTime, default=datetime.utcnow)
class Progress(Base):
__tablename__ = "progress"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
document_id = Column(String(36), ForeignKey("documents.id"), nullable=False)
sentence_index = Column(Integer, default=0)
updated_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (UniqueConstraint("user_id", "document_id", name="uq_user_doc"),)
class Setting(Base):
__tablename__ = "settings"
key = Column(String(100), primary_key=True)
value = Column(Text)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
Base.metadata.create_all(bind=engine)

View file

@ -6,3 +6,6 @@ pymupdf
beautifulsoup4
html2text
requests
sqlalchemy
passlib[bcrypt]
python-jose[cryptography]

197
static/admin.html Normal file
View file

@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Speechify — Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#0d0d0d;--bg2:#161616;--bg3:#212121;--bg4:#2a2a2a;
--accent:#7c3aed;--accent-h:#9d6fff;
--text:#e8e8e8;--text-m:#888;--border:#232323;--danger:#ef4444;
}
body { font-family:'Inter',sans-serif; background:var(--bg); color:var(--text); min-height:100vh; }
header {
height:54px; background:var(--bg2); border-bottom:1px solid var(--border);
display:flex; align-items:center; justify-content:space-between; padding:0 24px;
}
.logo { display:flex; align-items:center; gap:10px; font-size:17px; font-weight:700; }
.header-right { display:flex; align-items:center; gap:12px; }
.back-btn, .logout-btn {
background:var(--bg3); border:1px solid var(--border); color:var(--text-m);
border-radius:7px; padding:6px 14px; font-size:13px; font-family:inherit;
cursor:pointer; text-decoration:none; display:inline-block; transition:all .15s;
}
.back-btn:hover, .logout-btn:hover { border-color:var(--accent); color:var(--text); }
.container { max-width:900px; margin:0 auto; padding:32px 24px; }
h2 { font-size:18px; font-weight:600; margin-bottom:20px; }
h3 { font-size:14px; font-weight:600; color:var(--text-m); text-transform:uppercase;
letter-spacing:.5px; margin-bottom:14px; }
.card { background:var(--bg2); border:1px solid var(--border); border-radius:12px;
padding:24px; margin-bottom:24px; }
table { width:100%; border-collapse:collapse; font-size:14px; }
th { text-align:left; padding:8px 12px; font-size:11px; font-weight:600; color:var(--text-m);
text-transform:uppercase; letter-spacing:.5px; border-bottom:1px solid var(--border); }
td { padding:10px 12px; border-bottom:1px solid var(--border); }
tr:last-child td { border-bottom:none; }
.badge {
display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600;
}
.badge-admin { background:rgba(124,58,237,.2); color:#a78bfa; }
.badge-user { background:rgba(255,255,255,.07); color:var(--text-m); }
.badge-on { background:rgba(34,197,94,.15); color:#4ade80; }
.badge-off { background:rgba(239,68,68,.15); color:#f87171; }
.btn-sm {
padding:4px 10px; border-radius:6px; font-size:12px; font-family:inherit;
cursor:pointer; border:1px solid var(--border); background:var(--bg3); color:var(--text-m);
transition:all .15s;
}
.btn-sm:hover { border-color:var(--accent); color:var(--text); }
.btn-danger { border-color:var(--danger); color:var(--danger); }
.btn-danger:hover { background:rgba(239,68,68,.1); }
.form-row { display:flex; flex-direction:column; gap:6px; margin-bottom:16px; }
label { font-size:12px; font-weight:600; color:var(--text-m); text-transform:uppercase; letter-spacing:.5px; }
input, select {
background:var(--bg3); border:1px solid var(--border); border-radius:8px;
color:var(--text); font-family:inherit; font-size:14px; padding:9px 12px; outline:none; width:100%;
}
input:focus, select:focus { border-color:var(--accent); }
.btn-primary {
background:var(--accent); color:#fff; border:none; border-radius:8px;
padding:10px 20px; font-family:inherit; font-size:14px; font-weight:600; cursor:pointer;
}
.btn-primary:hover { background:var(--accent-h); }
.msg { font-size:13px; color:#4ade80; margin-top:10px; min-height:18px; }
.sections { display:grid; grid-template-columns:1fr 1fr; gap:24px; }
@media(max-width:640px){ .sections { grid-template-columns:1fr; } }
</style>
</head>
<body>
<header>
<div class="logo">
<svg width="28" height="28" viewBox="0 0 30 30" fill="none">
<rect width="30" height="30" rx="9" fill="#7c3aed"/>
<polygon points="11,8 23,15 11,22" fill="white"/>
</svg>
Admin Panel
</div>
<div class="header-right">
<a href="/" class="back-btn">← Back to App</a>
<button class="logout-btn" onclick="logout()">Sign Out</button>
</div>
</header>
<div class="container">
<div class="sections">
<!-- Nextcloud Settings -->
<div class="card">
<h3>Nextcloud Integration</h3>
<div class="form-row"><label>Nextcloud URL</label>
<input id="nc-url" type="url" placeholder="https://cloud.example.com"/></div>
<div class="form-row"><label>Username</label>
<input id="nc-user" type="text"/></div>
<div class="form-row"><label>App Password</label>
<input id="nc-pass" type="password"/></div>
<button class="btn-primary" onclick="saveSettings()">Save</button>
<div class="msg" id="nc-msg"></div>
</div>
<!-- Stats -->
<div class="card">
<h3>System</h3>
<div id="stats" style="font-size:14px;line-height:2;color:var(--text-m);">Loading…</div>
</div>
</div>
<!-- Users -->
<div class="card">
<h3>Users</h3>
<table id="users-table">
<thead><tr><th>ID</th><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>Joined</th><th>Actions</th></tr></thead>
<tbody id="users-body"></tbody>
</table>
</div>
</div>
<script>
async function init() {
const me = await fetch('/api/auth/me').then(r => r.json()).catch(() => null);
if (!me || me.role !== 'admin') { window.location.href = '/'; return; }
loadUsers();
loadSettings();
document.getElementById('stats').innerHTML = `Logged in as <strong>${me.username}</strong> (admin)`;
}
async function loadUsers() {
const users = await fetch('/api/admin/users').then(r => r.json());
const tbody = document.getElementById('users-body');
tbody.innerHTML = '';
for (const u of users) {
tbody.innerHTML += `
<tr>
<td>${u.id}</td>
<td><strong>${u.username}</strong></td>
<td>${u.email}</td>
<td><span class="badge badge-${u.role}">${u.role}</span></td>
<td><span class="badge ${u.is_active ? 'badge-on' : 'badge-off'}">${u.is_active ? 'Active' : 'Disabled'}</span></td>
<td>${u.created_at.split('T')[0]}</td>
<td style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn-sm" onclick="toggleRole(${u.id},'${u.role}')">${u.role === 'admin' ? '→ user' : '→ admin'}</button>
<button class="btn-sm" onclick="toggleActive(${u.id},${u.is_active})">${u.is_active ? 'Disable' : 'Enable'}</button>
<button class="btn-sm btn-danger" onclick="deleteUser(${u.id},'${u.username}')">Delete</button>
</td>
</tr>`;
}
}
async function toggleRole(id, current) {
const newRole = current === 'admin' ? 'user' : 'admin';
await fetch(`/api/admin/users/${id}`, { method:'PATCH', headers:{'Content-Type':'application/json'},
body: JSON.stringify({role: newRole}) });
loadUsers();
}
async function toggleActive(id, current) {
await fetch(`/api/admin/users/${id}`, { method:'PATCH', headers:{'Content-Type':'application/json'},
body: JSON.stringify({is_active: !current}) });
loadUsers();
}
async function deleteUser(id, name) {
if (!confirm(`Delete user "${name}"?`)) return;
await fetch(`/api/admin/users/${id}`, { method:'DELETE' });
loadUsers();
}
async function loadSettings() {
const s = await fetch('/api/admin/settings').then(r => r.json());
document.getElementById('nc-url').value = s.nextcloud_url || '';
document.getElementById('nc-user').value = s.nextcloud_username || '';
}
async function saveSettings() {
const msg = document.getElementById('nc-msg');
await fetch('/api/admin/settings', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
nextcloud_url: document.getElementById('nc-url').value,
nextcloud_username: document.getElementById('nc-user').value,
nextcloud_password: document.getElementById('nc-pass').value || undefined,
}),
});
msg.textContent = '✓ Saved';
setTimeout(() => msg.textContent = '', 3000);
}
async function logout() {
await fetch('/api/auth/logout', { method:'POST' });
window.location.href = '/login';
}
init();
</script>
</body>
</html>

View file

@ -1,39 +1,75 @@
// ── STATE ──────────────────────────────────────────────────────────
const state = {
sentences: [], // [{text, isPara}]
currentIdx: 0,
isPlaying: false,
speed: 1.0,
model: 'openai-tts-1',
voice: 'nova',
fontSize: 18,
sentences: [], // [{text, isPara}]
currentIdx: 0,
isPlaying: false,
speed: 1.0,
model: 'openai-tts-1',
voice: 'nova',
fontSize: 18,
docId: null, // currently loaded saved-doc id
docTitle: '',
};
let currentAudio = null;
const audioCache = new Map(); // idx → blob URL
let allModels = {};
const audioCache = new Map();
let allModels = {};
let currentUser = null;
// ── DOM ────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const textViewer = $('text-viewer');
const modelSelect = $('model-select');
const voiceSelect = $('voice-select');
const speedSlider = $('speed-slider');
const speedLabel = $('speed-label');
const btnPlay = $('btn-play');
const btnPrev = $('btn-prev');
const btnNext = $('btn-next');
const nowPlaying = $('now-playing');
const curSent = $('cur-sent');
const totSent = $('tot-sent');
const progressFill = $('progress-fill');
const loadingOv = $('loading-overlay');
const loadingText = $('loading-text');
const textViewer = $('text-viewer');
const modelSelect = $('model-select');
const voiceSelect = $('voice-select');
const speedSlider = $('speed-slider');
const speedLabel = $('speed-label');
const btnPlay = $('btn-play');
const btnPrev = $('btn-prev');
const btnNext = $('btn-next');
const nowPlaying = $('now-playing');
const curSent = $('cur-sent');
const totSent = $('tot-sent');
const progressFill = $('progress-fill');
const loadingOv = $('loading-overlay');
const loadingText = $('loading-text');
const lockNotice = $('lock-notice');
// ── LOADING ────────────────────────────────────────────────────────
const showLoading = (msg = 'Loading…') => { loadingText.textContent = msg; loadingOv.classList.add('show'); };
const hideLoading = () => loadingOv.classList.remove('show');
// ── AUTH ───────────────────────────────────────────────────────────
async function initAuth() {
try {
const resp = await fetch('/api/auth/me');
if (resp.status === 401) { window.location.href = '/login'; return; }
currentUser = await resp.json();
} catch {
window.location.href = '/login';
return;
}
$('user-label').textContent = currentUser.username;
// Show admin panel link only for admins
const ddAdmin = $('dd-admin');
if (currentUser.role !== 'admin') ddAdmin.classList.add('hidden');
// User menu dropdown toggle
$('user-btn').addEventListener('click', e => {
e.stopPropagation();
$('user-dropdown').classList.toggle('open');
});
document.addEventListener('click', () => $('user-dropdown').classList.remove('open'));
}
function logout() {
fetch('/api/auth/logout', { method: 'POST' }).finally(() => {
window.location.href = '/login';
});
}
window.logout = logout;
// ── VOICES INIT ────────────────────────────────────────────────────
async function initVoices() {
const resp = await fetch('/api/voices');
@ -64,8 +100,22 @@ function refreshVoices() {
audioCache.clear();
}
modelSelect.addEventListener('change', refreshVoices);
voiceSelect.addEventListener('change', () => { state.voice = voiceSelect.value; audioCache.clear(); });
// Voice locking during playback
function setVoiceLock(locked) {
modelSelect.disabled = locked;
voiceSelect.disabled = locked;
lockNotice.classList.toggle('visible', locked);
}
modelSelect.addEventListener('change', () => {
if (state.isPlaying) return; // ignore if locked
refreshVoices();
});
voiceSelect.addEventListener('change', () => {
if (state.isPlaying) return;
state.voice = voiceSelect.value;
audioCache.clear();
});
// ── SENTENCE SPLITTING ─────────────────────────────────────────────
function splitSentences(text) {
@ -77,7 +127,6 @@ function splitSentences(text) {
const para = paras[pi].trim();
if (!para) continue;
// Split on sentence-ending punctuation followed by space + capital
const parts = para.split(/(?<=[.!?…])\s+(?=[A-Z"'"'(])/);
for (const part of parts) {
const t = part.trim();
@ -106,11 +155,13 @@ function renderText(sentences) {
}
// ── LOAD DOCUMENT ──────────────────────────────────────────────────
function loadDoc(text, title = 'Document') {
async function loadDoc(text, title = 'Document', docId = null, startIdx = 0) {
state.sentences = splitSentences(text);
state.currentIdx = 0;
state.currentIdx = startIdx;
state.docId = docId;
state.docTitle = title;
audioCache.clear();
stopAudio();
stopPlayback();
const real = state.sentences.filter(s => !s.isPara).length;
$('document-header').style.display = '';
@ -123,6 +174,44 @@ function loadDoc(text, title = 'Document') {
renderText(state.sentences);
textViewer.style.fontSize = `${state.fontSize}px`;
// Load saved progress if we have a docId and no explicit startIdx
if (docId && startIdx === 0) {
try {
const r = await fetch(`/api/progress/${docId}`);
if (r.ok) {
const p = await r.json();
if (p.sentence_index > 0) {
const realIdx = nextReal(p.sentence_index);
if (realIdx >= 0) {
state.currentIdx = realIdx;
highlight(realIdx);
}
}
}
} catch {}
} else if (startIdx > 0) {
highlight(startIdx);
}
}
// ── PROGRESS SAVING ────────────────────────────────────────────────
let progressSaveTimer = null;
function scheduleSaveProgress() {
if (!state.docId) return;
clearTimeout(progressSaveTimer);
progressSaveTimer = setTimeout(saveProgress, 2000);
}
async function saveProgress() {
if (!state.docId) return;
try {
await fetch(`/api/progress/${state.docId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sentence_index: state.currentIdx }),
});
} catch {}
}
// ── AUDIO ──────────────────────────────────────────────────────────
@ -138,7 +227,7 @@ async function generateAudio(idx) {
if (!resp.ok) throw new Error(await resp.text());
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const url = URL.createObjectURL(blob);
audioCache.set(idx, url);
return url;
}
@ -154,7 +243,7 @@ function prefetch(fromIdx, count = 3) {
}
function stopAudio() {
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
if (currentAudio) { currentAudio.pause(); currentAudio.onended = null; currentAudio = null; }
}
// ── HIGHLIGHT ──────────────────────────────────────────────────────
@ -168,13 +257,14 @@ function highlight(idx) {
state.currentIdx = idx;
const s = state.sentences[idx];
if (s) nowPlaying.textContent = s.text;
if (s && !s.isPara) nowPlaying.textContent = s.text;
// Update counter
const realIdx = state.sentences.slice(0, idx + 1).filter(s => !s.isPara).length;
const realIdx = state.sentences.slice(0, idx + 1).filter(s => !s.isPara).length;
const realTotal = state.sentences.filter(s => !s.isPara).length;
curSent.textContent = realIdx;
progressFill.style.width = realTotal ? `${(realIdx / realTotal) * 100}%` : '0%';
scheduleSaveProgress();
}
// ── NAVIGATION HELPERS ─────────────────────────────────────────────
@ -191,7 +281,7 @@ function prevReal(from) {
// ── PLAYBACK ───────────────────────────────────────────────────────
async function playSentence(idx) {
if (idx < 0 || idx >= state.sentences.length) { stop(); return; }
if (idx < 0 || idx >= state.sentences.length) { stopFinished(); return; }
const s = state.sentences[idx];
if (s.isPara) { playSentence(nextReal(idx + 1)); return; }
@ -221,12 +311,21 @@ async function playSentence(idx) {
}
}
function stop() {
function stopFinished() {
state.isPlaying = false;
stopAudio();
setVoiceLock(false);
updatePlayBtn();
document.querySelectorAll('.s.active').forEach(el => el.classList.remove('active'));
nowPlaying.textContent = 'Finished';
saveProgress();
}
function stopPlayback() {
state.isPlaying = false;
stopAudio();
setVoiceLock(false);
updatePlayBtn();
}
function updatePlayBtn() {
@ -250,13 +349,16 @@ btnPlay.addEventListener('click', () => {
state.isPlaying = !state.isPlaying;
updatePlayBtn();
if (state.isPlaying) {
setVoiceLock(true);
const idx = state.sentences[state.currentIdx]?.isPara
? nextReal(state.currentIdx)
: state.currentIdx;
playSentence(idx >= 0 ? idx : nextReal(0));
} else {
stopAudio();
setVoiceLock(false);
nowPlaying.textContent = 'Paused';
saveProgress();
}
});
@ -270,9 +372,8 @@ btnPrev.addEventListener('click', () => {
if (p >= 0) jumpTo(p);
});
// Progress bar click
$('progress-track').addEventListener('click', e => {
const rect = e.currentTarget.getBoundingClientRect();
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
const reals = state.sentences.map((s, i) => s.isPara ? null : i).filter(i => i !== null);
const target = reals[Math.floor(ratio * reals.length)];
@ -312,12 +413,13 @@ $('btn-sidebar-toggle').addEventListener('click', () => $('sidebar-left').class
$('btn-settings-toggle').addEventListener('click', () => $('sidebar-right').classList.toggle('collapsed'));
// ── TABS ───────────────────────────────────────────────────────────
document.querySelectorAll('.tab').forEach(tab => {
document.querySelectorAll('.tabs .tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
$(`tab-${tab.dataset.tab}`).classList.add('active');
if (tab.dataset.tab === 'saved') loadSavedDocs();
});
});
@ -339,7 +441,7 @@ $('btn-load-url').addEventListener('click', async () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!resp.ok) throw new Error(await resp.text());
if (!resp.ok) throw new Error((await resp.json()).detail || 'Failed');
const data = await resp.json();
loadDoc(data.text, data.title || url);
status.textContent = '✓ Loaded';
@ -355,7 +457,7 @@ const uploadArea = $('upload-area');
const pdfInput = $('pdf-input');
uploadArea.addEventListener('click', () => pdfInput.click());
uploadArea.addEventListener('dragover', e => { e.preventDefault(); uploadArea.classList.add('drag-over'); });
uploadArea.addEventListener('dragover', e => { e.preventDefault(); uploadArea.classList.add('drag-over'); });
uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('drag-over'));
uploadArea.addEventListener('drop', e => {
e.preventDefault(); uploadArea.classList.remove('drag-over');
@ -371,7 +473,7 @@ async function uploadPDF(file) {
form.append('file', file);
try {
const resp = await fetch('/api/extract-pdf', { method: 'POST', body: form });
if (!resp.ok) throw new Error(await resp.text());
if (!resp.ok) throw new Error((await resp.json()).detail || 'Failed');
const data = await resp.json();
loadDoc(data.text, data.title || file.name);
status.textContent = '✓ Loaded';
@ -382,6 +484,182 @@ async function uploadPDF(file) {
}
}
// ── NEXTCLOUD BROWSER ───────────────────────────────────────────────
let ncCurrentPath = '/';
async function ncBrowse() {
ncCurrentPath = '/';
await ncLoadPath('/');
}
window.ncBrowse = ncBrowse;
async function ncUp() {
if (ncCurrentPath === '/') return;
const parts = ncCurrentPath.replace(/\/$/, '').split('/');
parts.pop();
ncCurrentPath = parts.join('/') || '/';
await ncLoadPath(ncCurrentPath);
}
window.ncUp = ncUp;
async function ncNavigate(path) {
ncCurrentPath = path;
await ncLoadPath(path);
}
async function ncLoadPath(path) {
const fileList = $('nc-file-list');
const status = $('nc-status');
fileList.innerHTML = '<div class="status-text">Loading…</div>';
status.textContent = '';
$('nc-path-display').textContent = path;
try {
const resp = await fetch('/api/nextcloud/files?path=' + encodeURIComponent(path));
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Failed to load');
}
const items = await resp.json();
fileList.innerHTML = '';
if (!items.length) {
fileList.innerHTML = '<div class="status-text">Empty folder</div>';
return;
}
for (const item of items) {
const div = document.createElement('div');
div.className = 'nc-item' + (item.is_dir ? ' dir' : '');
const icon = item.is_dir
? `<svg class="nc-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`
: `<svg class="nc-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`;
div.innerHTML = `${icon}<span>${item.name}</span>`;
if (item.is_dir) {
div.addEventListener('click', () => ncNavigate(item.path));
} else {
div.addEventListener('click', () => ncOpenFile(item.path, item.name));
}
fileList.appendChild(div);
}
} catch (e) {
fileList.innerHTML = '<div class="status-text">Error loading</div>';
status.textContent = '✗ ' + e.message;
}
}
async function ncOpenFile(path, name) {
showLoading('Loading from Nextcloud…');
const status = $('nc-status');
try {
const resp = await fetch('/api/nextcloud/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
if (!resp.ok) throw new Error((await resp.json()).detail || 'Failed');
const data = await resp.json();
loadDoc(data.text, name.replace(/\.pdf$/i, ''));
status.textContent = '✓ Loaded';
} catch (e) {
status.textContent = '✗ ' + e.message;
} finally {
hideLoading();
}
}
// ── SAVED DOCUMENTS ────────────────────────────────────────────────
async function loadSavedDocs() {
const list = $('saved-list');
list.innerHTML = '<div class="status-text">Loading…</div>';
try {
const resp = await fetch('/api/documents');
if (!resp.ok) throw new Error('Failed');
const docs = await resp.json();
list.innerHTML = '';
if (!docs.length) {
list.innerHTML = '<div class="status-text">No saved documents</div>';
return;
}
for (const doc of docs) {
const div = document.createElement('div');
div.className = 'saved-item';
const date = new Date(doc.created_at).toLocaleDateString();
div.innerHTML = `
<div class="saved-item-title">${escHtml(doc.title)}</div>
<div class="saved-item-meta">${escHtml(doc.source_type)} · ${date}</div>
`;
div.addEventListener('click', () => openSavedDoc(doc.id, doc.title));
list.appendChild(div);
}
} catch (e) {
list.innerHTML = '<div class="status-text">Error loading</div>';
}
}
window.loadSavedDocs = loadSavedDocs;
async function openSavedDoc(docId, title) {
showLoading('Loading document…');
try {
const resp = await fetch(`/api/documents/${docId}`);
if (!resp.ok) throw new Error('Not found');
const doc = await resp.json();
await loadDoc(doc.content, doc.title, doc.id, 0);
} catch (e) {
console.error('Failed to open doc:', e);
} finally {
hideLoading();
}
}
// Save current document
async function saveDoc() {
if (!state.sentences.length) return;
const btn = $('btn-save-doc');
btn.disabled = true;
btn.textContent = 'Saving…';
const fullText = state.sentences
.map(s => s.isPara ? '\n' : s.text)
.join(' ')
.replace(/ \n /g, '\n\n');
try {
const method = state.docId ? 'PUT' : 'POST';
const url = state.docId ? `/api/documents/${state.docId}` : '/api/documents';
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: state.docTitle, content: fullText }),
});
if (!resp.ok) throw new Error('Failed');
const doc = await resp.json();
state.docId = doc.id;
btn.textContent = '✓ Saved';
setTimeout(() => {
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save`;
btn.disabled = false;
}, 2000);
} catch {
btn.textContent = '✗ Error';
btn.disabled = false;
setTimeout(() => {
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save`;
}, 2000);
}
}
$('btn-save-doc').addEventListener('click', saveDoc);
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── VOICE PREVIEW ─────────────────────────────────────────────────
$('btn-preview').addEventListener('click', async () => {
showLoading('Generating preview…');
@ -409,11 +687,15 @@ $('btn-preview').addEventListener('click', async () => {
// ── KEYBOARD SHORTCUTS ─────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
if (e.code === 'Space') { e.preventDefault(); btnPlay.click(); }
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
if (e.code === 'Space') { e.preventDefault(); btnPlay.click(); }
else if (e.code === 'ArrowRight') btnNext.click();
else if (e.code === 'ArrowLeft') btnPrev.click();
});
// ── BOOT ───────────────────────────────────────────────────────────
initVoices();
async function boot() {
await initAuth();
await initVoices();
}
boot();

View file

@ -1,181 +1,201 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Speechify</title>
<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@300;400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/style.css"/>
</head>
<body>
<div class="app">
<div class="app">
<!-- Top Bar -->
<header class="topbar">
<div class="topbar-left">
<div class="logo">
<svg width="30" height="30" viewBox="0 0 30 30" fill="none">
<rect width="30" height="30" rx="9" fill="#7c3aed"/>
<polygon points="11,8 23,15 11,22" fill="white"/>
</svg>
<span>Speechify</span>
</div>
<!-- Top Bar -->
<header class="topbar">
<div class="topbar-left">
<div class="logo">
<svg width="30" height="30" viewBox="0 0 30 30" fill="none">
<rect width="30" height="30" rx="9" fill="#7c3aed"/>
<polygon points="11,8 23,15 11,22" fill="white"/>
</svg>
<span>Speechify</span>
</div>
<div class="topbar-right">
<button class="btn-icon" id="btn-sidebar-toggle" title="Toggle Input Panel">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>
</div>
<div class="topbar-right">
<button class="btn-icon" id="btn-sidebar-toggle" title="Toggle Input">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
</button>
<button class="btn-icon" id="btn-settings-toggle" title="Voice Settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
</button>
<div class="user-menu" id="user-menu">
<button class="user-btn" id="user-btn">
<span id="user-label"></span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<button class="btn-icon" id="btn-settings-toggle" title="Voice Settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</div>
</header>
<div class="main-layout">
<!-- Left Sidebar: Input -->
<aside class="sidebar-left" id="sidebar-left">
<div class="sidebar-header"><h3>Add Content</h3></div>
<div class="tabs">
<button class="tab active" data-tab="text">Text</button>
<button class="tab" data-tab="pdf">PDF</button>
<button class="tab" data-tab="url">URL</button>
</div>
<!-- Text Tab -->
<div class="tab-content active" id="tab-text">
<textarea id="text-input" placeholder="Paste your text here..."></textarea>
<button class="btn-primary" id="btn-load-text">Load Text</button>
</div>
<!-- PDF Tab -->
<div class="tab-content" id="tab-pdf">
<div class="upload-area" id="upload-area">
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
<p>Drop PDF here or<br><strong>click to upload</strong></p>
<input type="file" id="pdf-input" accept=".pdf" hidden />
</div>
<div id="pdf-status" class="status-text"></div>
</div>
<!-- URL Tab -->
<div class="tab-content" id="tab-url">
<input type="url" id="url-input" class="text-field" placeholder="https://example.com/article" />
<button class="btn-primary" id="btn-load-url">Fetch Article</button>
<div id="url-status" class="status-text"></div>
</div>
</aside>
<!-- Main Content -->
<main class="content-area" id="content-area">
<div id="document-header" class="document-header" style="display:none">
<h1 class="document-title" id="document-title"></h1>
<p class="document-meta" id="document-meta"></p>
</div>
<div class="text-viewer" id="text-viewer">
<div class="empty-state">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<p>Load a document to get started</p>
<span>Paste text, upload a PDF, or enter a URL</span>
</div>
</div>
</main>
<!-- Right Sidebar: Voice Settings -->
<aside class="sidebar-right" id="sidebar-right">
<div class="sidebar-header"><h3>Voice Settings</h3></div>
<div class="setting-group">
<label class="setting-label">TTS Model</label>
<select id="model-select" class="select"></select>
</div>
<div class="setting-group">
<label class="setting-label">Voice</label>
<select id="voice-select" class="select"></select>
</div>
<div class="setting-group">
<label class="setting-label">Speed — <span id="speed-label">1x</span></label>
<input type="range" id="speed-slider" min="0.5" max="3" step="0.25" value="1" class="slider" />
<div class="speed-presets">
<button class="speed-btn" data-speed="0.75">0.75×</button>
<button class="speed-btn active" data-speed="1">1×</button>
<button class="speed-btn" data-speed="1.25">1.25×</button>
<button class="speed-btn" data-speed="1.5">1.5×</button>
<button class="speed-btn" data-speed="2">2×</button>
<button class="speed-btn" data-speed="3">3×</button>
</div>
</div>
<div class="setting-group">
<label class="setting-label">Font Size</label>
<div class="font-size-row">
<button class="btn-sz" id="font-decrease">A</button>
<span id="font-size-label">18px</span>
<button class="btn-sz" id="font-increase">A+</button>
</div>
</div>
<div class="setting-group">
<button class="btn-secondary" id="btn-preview">▶ Preview Voice</button>
</div>
</aside>
</div><!-- /main-layout -->
<!-- Player Bar -->
<div class="player-bar">
<div class="player-left">
<p class="now-playing" id="now-playing">No document loaded</p>
</div>
<div class="player-center">
<button class="ctrl-btn" id="btn-prev" title="Previous sentence (←)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button>
<button class="play-btn" id="btn-play" title="Play / Pause (Space)">
<svg class="icon-play" width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="icon-pause" width="26" height="26" viewBox="0 0 24 24" fill="currentColor" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<button class="ctrl-btn" id="btn-next" title="Next sentence (→)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 5.5 4V8l-5.5 4zM16 6h2v12h-2z"/></svg>
</button>
</div>
<div class="player-right">
<span class="progress-text"><span id="cur-sent">0</span> / <span id="tot-sent">0</span></span>
<div class="progress-track" id="progress-track">
<div class="progress-fill" id="progress-fill"></div>
<div class="user-dropdown" id="user-dropdown">
<a href="/admin" class="dd-item" id="dd-admin">Admin Panel</a>
<button class="dd-item" onclick="logout()">Sign Out</button>
</div>
</div>
</div>
</header>
</div><!-- /app -->
<div class="main-layout">
<!-- Left Sidebar -->
<aside class="sidebar-left" id="sidebar-left">
<div class="sidebar-header"><h3>Add Content</h3></div>
<div class="tabs">
<button class="tab active" data-tab="text">Text</button>
<button class="tab" data-tab="pdf">PDF</button>
<button class="tab" data-tab="url">URL</button>
<button class="tab" data-tab="cloud">Cloud</button>
<button class="tab" data-tab="saved">Saved</button>
</div>
<!-- Text -->
<div class="tab-content active" id="tab-text">
<textarea id="text-input" placeholder="Paste your text here…"></textarea>
<button class="btn-primary" id="btn-load-text">Load Text</button>
</div>
<!-- PDF -->
<div class="tab-content" id="tab-pdf">
<div class="upload-area" id="upload-area">
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
<p>Drop PDF here or<br/><strong>click to upload</strong></p>
<small>Text is extracted from all pages so the AI can read it aloud</small>
<input type="file" id="pdf-input" accept=".pdf" hidden/>
</div>
<div id="pdf-status" class="status-text"></div>
</div>
<!-- URL -->
<div class="tab-content" id="tab-url">
<input type="url" id="url-input" class="text-field" placeholder="https://example.com/article"/>
<button class="btn-primary" id="btn-load-url">Fetch Article</button>
<div id="url-status" class="status-text"></div>
</div>
<!-- Nextcloud -->
<div class="tab-content" id="tab-cloud">
<div id="nc-path-bar" class="nc-path-bar">
<span id="nc-path-display">/</span>
<button class="btn-sm" id="nc-up-btn" onclick="ncUp()">↑ Up</button>
</div>
<div id="nc-file-list" class="nc-file-list">
<div class="status-text">Click Browse to load files</div>
</div>
<button class="btn-primary" id="btn-nc-browse" onclick="ncBrowse()">Browse Nextcloud</button>
<div id="nc-status" class="status-text"></div>
</div>
<!-- Saved Docs -->
<div class="tab-content" id="tab-saved">
<div id="saved-list" class="saved-list">
<div class="status-text">No saved documents</div>
</div>
<button class="btn-secondary" id="btn-refresh-saved" onclick="loadSavedDocs()">Refresh</button>
</div>
</aside>
<!-- Main Content -->
<main class="content-area">
<div id="document-header" class="document-header" style="display:none">
<div class="doc-title-row">
<h1 class="document-title" id="document-title"></h1>
<button class="btn-save" id="btn-save-doc" title="Save document">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Save
</button>
</div>
<p class="document-meta" id="document-meta"></p>
<div class="doc-hint">Click any sentence to jump there · Space to play/pause · ← → to skip</div>
</div>
<div class="text-viewer" id="text-viewer">
<div class="empty-state">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<p>Load a document to get started</p>
<span>Paste text, upload a PDF, enter a URL, or pick from Nextcloud</span>
</div>
</div>
</main>
<!-- Right Sidebar: Settings -->
<aside class="sidebar-right" id="sidebar-right">
<div class="sidebar-header"><h3>Voice Settings</h3></div>
<div class="setting-group">
<label class="setting-label">TTS Model</label>
<select id="model-select" class="select"></select>
<div class="lock-notice" id="lock-notice">Stop playback to change voice</div>
</div>
<div class="setting-group">
<label class="setting-label">Voice</label>
<select id="voice-select" class="select"></select>
</div>
<div class="setting-group">
<label class="setting-label">Speed — <span id="speed-label">1×</span></label>
<input type="range" id="speed-slider" min="0.5" max="3" step="0.25" value="1" class="slider"/>
<div class="speed-presets">
<button class="speed-btn" data-speed="0.75">0.75×</button>
<button class="speed-btn active" data-speed="1">1×</button>
<button class="speed-btn" data-speed="1.25">1.25×</button>
<button class="speed-btn" data-speed="1.5">1.5×</button>
<button class="speed-btn" data-speed="2">2×</button>
<button class="speed-btn" data-speed="3">3×</button>
</div>
</div>
<div class="setting-group">
<label class="setting-label">Font Size</label>
<div class="font-size-row">
<button class="btn-sz" id="font-decrease">A</button>
<span id="font-size-label">18px</span>
<button class="btn-sz" id="font-increase">A+</button>
</div>
</div>
<div class="setting-group">
<button class="btn-secondary" id="btn-preview">▶ Preview Voice</button>
</div>
</aside>
<!-- Loading overlay -->
<div class="loading-overlay" id="loading-overlay">
<div class="spinner"></div>
<p id="loading-text">Loading…</p>
</div>
<script src="/static/app.js"></script>
<!-- Player Bar -->
<div class="player-bar">
<div class="player-left">
<p class="now-playing" id="now-playing">No document loaded</p>
</div>
<div class="player-center">
<button class="ctrl-btn" id="btn-prev" title="Previous (←)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button>
<button class="play-btn" id="btn-play" title="Play/Pause (Space)">
<svg class="icon-play" width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="icon-pause" width="26" height="26" viewBox="0 0 24 24" fill="currentColor" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<button class="ctrl-btn" id="btn-next" title="Next (→)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 5.5 4V8l-5.5 4zM16 6h2v12h-2z"/></svg>
</button>
</div>
<div class="player-right">
<span class="progress-text"><span id="cur-sent">0</span> / <span id="tot-sent">0</span></span>
<div class="progress-track" id="progress-track">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
</div>
</div>
<!-- Loading overlay -->
<div class="loading-overlay" id="loading-overlay">
<div class="spinner"></div>
<p id="loading-text">Loading…</p>
</div>
<script src="/static/app.js"></script>
</body>
</html>

147
static/login.html Normal file
View file

@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Speechify — Sign In</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d0d; --bg2: #161616; --bg3: #212121;
--accent: #7c3aed; --accent-h: #9d6fff;
--text: #e8e8e8; --text-m: #888; --border: #232323;
}
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text);
min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.card {
background: var(--bg2); border: 1px solid var(--border); border-radius: 16px;
padding: 40px 36px; width: 100%; max-width: 400px;
}
.logo { display: flex; align-items: center; gap: 10px; font-size: 20px; font-weight: 700; margin-bottom: 32px; }
.logo svg { flex-shrink: 0; }
.tabs { display: flex; gap: 4px; margin-bottom: 24px; background: var(--bg3); border-radius: 10px; padding: 4px; }
.tab {
flex: 1; padding: 8px; background: none; border: none; border-radius: 7px;
color: var(--text-m); font-family: inherit; font-size: 14px; font-weight: 500; cursor: pointer;
}
.tab.active { background: var(--accent); color: #fff; }
.form { display: flex; flex-direction: column; gap: 14px; }
.form.hidden { display: none; }
label { font-size: 12px; font-weight: 600; color: var(--text-m); text-transform: uppercase; letter-spacing: .5px; }
input {
background: var(--bg3); border: 1px solid var(--border); border-radius: 8px;
color: var(--text); font-family: inherit; font-size: 14px; padding: 10px 12px;
outline: none; width: 100%; transition: border-color .15s; margin-top: 6px;
}
input:focus { border-color: var(--accent); }
.field { display: flex; flex-direction: column; }
.turnstile-wrap { display: flex; justify-content: center; }
.btn {
background: var(--accent); color: #fff; border: none; border-radius: 8px;
padding: 11px; font-family: inherit; font-size: 15px; font-weight: 600;
cursor: pointer; transition: background .15s; width: 100%;
}
.btn:hover { background: var(--accent-h); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.error { color: #f87171; font-size: 13px; text-align: center; min-height: 18px; }
</style>
</head>
<body>
<div class="card">
<div class="logo">
<svg width="30" height="30" viewBox="0 0 30 30" fill="none">
<rect width="30" height="30" rx="9" fill="#7c3aed"/>
<polygon points="11,8 23,15 11,22" fill="white"/>
</svg>
Speechify
</div>
<div class="tabs">
<button class="tab active" id="tab-login" onclick="switchTab('login')">Sign In</button>
<button class="tab" id="tab-register" onclick="switchTab('register')">Register</button>
</div>
<!-- Login Form -->
<form class="form" id="form-login" onsubmit="doLogin(event)">
<div class="field"><label>Username</label><input id="l-user" type="text" required autocomplete="username"/></div>
<div class="field"><label>Password</label><input id="l-pass" type="password" required autocomplete="current-password"/></div>
<div class="turnstile-wrap">
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="dark" id="ts-login"></div>
</div>
<div class="error" id="err-login"></div>
<button class="btn" type="submit" id="btn-login">Sign In</button>
</form>
<!-- Register Form -->
<form class="form hidden" id="form-register" onsubmit="doRegister(event)">
<div class="field"><label>Username</label><input id="r-user" type="text" required autocomplete="username"/></div>
<div class="field"><label>Email</label><input id="r-email" type="email" required autocomplete="email"/></div>
<div class="field"><label>Password</label><input id="r-pass" type="password" required autocomplete="new-password"/></div>
<div class="turnstile-wrap">
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="dark" id="ts-register"></div>
</div>
<div class="error" id="err-register"></div>
<button class="btn" type="submit" id="btn-register">Create Account</button>
</form>
</div>
<script>
function switchTab(tab) {
document.getElementById('tab-login').classList.toggle('active', tab === 'login');
document.getElementById('tab-register').classList.toggle('active', tab === 'register');
document.getElementById('form-login').classList.toggle('hidden', tab !== 'login');
document.getElementById('form-register').classList.toggle('hidden', tab !== 'register');
}
function getTurnstileToken(widgetId) {
return document.querySelector(`#${widgetId} [name="cf-turnstile-response"]`)?.value || '';
}
async function doLogin(e) {
e.preventDefault();
const btn = document.getElementById('btn-login');
const err = document.getElementById('err-login');
btn.disabled = true; err.textContent = '';
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('l-user').value,
password: document.getElementById('l-pass').value,
turnstile_token: getTurnstileToken('ts-login'),
}),
});
if (!resp.ok) { err.textContent = (await resp.json()).detail; }
else { window.location.href = '/'; }
} catch { err.textContent = 'Network error'; }
finally { btn.disabled = false; }
}
async function doRegister(e) {
e.preventDefault();
const btn = document.getElementById('btn-register');
const err = document.getElementById('err-register');
btn.disabled = true; err.textContent = '';
try {
const resp = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('r-user').value,
email: document.getElementById('r-email').value,
password: document.getElementById('r-pass').value,
turnstile_token: getTurnstileToken('ts-register'),
}),
});
if (!resp.ok) { err.textContent = (await resp.json()).detail; }
else { window.location.href = '/'; }
} catch { err.textContent = 'Network error'; }
finally { btn.disabled = false; }
}
</script>
</body>
</html>

View file

@ -11,7 +11,7 @@
--text-m: #888;
--text-d: #444;
--hl: rgba(124,58,237,.18);
--hl-active: rgba(124,58,237,.42);
--hl-active: rgba(124,58,237,.5);
--border: #232323;
--topbar-h: 54px;
--player-h: 76px;
@ -38,7 +38,33 @@ body {
flex-shrink: 0;
}
.logo { display: flex; align-items: center; gap: 10px; font-size: 17px; font-weight: 700; }
.topbar-right { display: flex; gap: 6px; }
.topbar-right { display: flex; align-items: center; gap: 6px; }
/* ── USER MENU ────────────────────────────── */
.user-menu { position: relative; }
.user-btn {
display: flex; align-items: center; gap: 6px;
background: var(--bg3); border: 1px solid var(--border);
border-radius: 8px; color: var(--text-m); cursor: pointer;
font-family: inherit; font-size: 13px; padding: 6px 12px;
transition: all .15s;
}
.user-btn:hover { border-color: var(--accent); color: var(--text); }
.user-dropdown {
display: none; position: absolute; top: calc(100% + 6px); right: 0;
background: var(--bg2); border: 1px solid var(--border); border-radius: 10px;
min-width: 160px; z-index: 100; overflow: hidden;
box-shadow: 0 8px 24px rgba(0,0,0,.5);
}
.user-dropdown.open { display: block; }
.dd-item {
display: block; width: 100%; text-align: left;
background: none; border: none; color: var(--text-m);
cursor: pointer; font-family: inherit; font-size: 13px;
padding: 10px 16px; transition: background .12s; text-decoration: none;
}
.dd-item:hover { background: var(--bg3); color: var(--text); }
.dd-item.hidden { display: none; }
/* ── MAIN LAYOUT ──────────────────────────── */
.app { display: flex; flex-direction: column; height: 100vh; }
@ -77,7 +103,7 @@ body {
.tabs { display: flex; padding: 10px 10px 0; gap: 4px; flex-shrink: 0; }
.tab {
flex: 1; padding: 7px 4px; background: none; border: none;
border-radius: 7px; color: var(--text-m); font-size: 13px;
border-radius: 7px; color: var(--text-m); font-size: 12px;
font-weight: 500; cursor: pointer; transition: all .15s; font-family: inherit;
}
.tab:hover { background: var(--bg3); color: var(--text); }
@ -108,6 +134,7 @@ textarea:focus, .text-field:focus { border-color: var(--accent); }
border-color: var(--accent); background: rgba(124,58,237,.06); color: var(--text);
}
.upload-area p { font-size: 13px; line-height: 1.5; }
.upload-area small { font-size: 11px; color: var(--text-d); line-height: 1.4; }
/* ── BUTTONS ──────────────────────────────── */
.btn-primary {
@ -137,6 +164,22 @@ textarea:focus, .text-field:focus { border-color: var(--accent); }
}
.btn-sz:hover { border-color: var(--accent); color: var(--accent-h); }
.btn-sm {
background: var(--bg3); border: 1px solid var(--border); color: var(--text-m);
border-radius: 6px; padding: 4px 10px; font-size: 12px; font-family: inherit;
cursor: pointer; transition: all .15s;
}
.btn-sm:hover { border-color: var(--accent); color: var(--text); }
/* ── SAVE BUTTON ──────────────────────────── */
.btn-save {
display: flex; align-items: center; gap: 6px;
background: var(--bg3); border: 1px solid var(--border); color: var(--text-m);
border-radius: 8px; padding: 6px 14px; font-family: inherit; font-size: 13px;
cursor: pointer; transition: all .15s; flex-shrink: 0;
}
.btn-save:hover { border-color: var(--accent); color: var(--accent-h); }
/* ── STATUS TEXT ─────────────────────────── */
.status-text { font-size: 12px; color: var(--text-m); min-height: 18px; text-align: center; }
@ -145,8 +188,10 @@ textarea:focus, .text-field:focus { border-color: var(--accent); }
flex: 1; overflow-y: auto; padding: 44px 56px;
}
.document-header { margin-bottom: 28px; }
.document-title { font-size: 22px; font-weight: 700; margin-bottom: 6px; }
.document-meta { font-size: 13px; color: var(--text-m); }
.doc-title-row { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 6px; }
.document-title { font-size: 22px; font-weight: 700; flex: 1; }
.document-meta { font-size: 13px; color: var(--text-m); margin-bottom: 8px; }
.doc-hint { font-size: 12px; color: var(--text-d); }
.text-viewer { font-size: 18px; line-height: 1.85; color: var(--text); }
@ -159,13 +204,51 @@ textarea:focus, .text-field:focus { border-color: var(--accent); }
/* Sentence spans */
.s {
cursor: pointer; border-radius: 4px; padding: 2px 1px;
cursor: pointer; border-radius: 4px; padding: 2px 2px;
transition: background .12s; display: inline;
}
.s:hover { background: var(--hl); }
.s.active { background: var(--hl-active); color: #fff; border-radius: 4px; }
.s.active {
background: var(--hl-active);
color: #fff;
border-radius: 4px;
box-shadow: 0 0 0 2px rgba(124,58,237,.3);
}
.para-gap { display: block; height: .9em; }
/* ── NEXTCLOUD FILE BROWSER ──────────────── */
.nc-path-bar {
display: flex; align-items: center; justify-content: space-between;
background: var(--bg3); border: 1px solid var(--border); border-radius: 8px;
padding: 6px 10px; font-size: 12px; color: var(--text-m); flex-shrink: 0;
}
#nc-path-display { font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nc-file-list {
flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px;
min-height: 0;
}
.nc-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: 7px; cursor: pointer;
font-size: 13px; color: var(--text-m); transition: all .12s;
border: 1px solid transparent;
}
.nc-item:hover { background: var(--bg3); color: var(--text); border-color: var(--border); }
.nc-item.dir { color: var(--accent-h); }
.nc-item .nc-icon { flex-shrink: 0; opacity: .7; }
/* ── SAVED DOCS LIST ─────────────────────── */
.saved-list {
flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; min-height: 0;
}
.saved-item {
padding: 10px 12px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--border); background: var(--bg3); transition: all .12s;
}
.saved-item:hover { border-color: var(--accent); background: rgba(124,58,237,.06); }
.saved-item-title { font-size: 13px; font-weight: 500; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.saved-item-meta { font-size: 11px; color: var(--text-m); margin-top: 3px; }
/* ── SETTINGS SIDEBAR ────────────────────── */
.setting-group { padding: 16px; border-bottom: 1px solid var(--border); }
.setting-label {
@ -178,6 +261,15 @@ textarea:focus, .text-field:focus { border-color: var(--accent); }
padding: 8px 10px; outline: none; cursor: pointer; transition: border-color .15s;
}
.select:focus { border-color: var(--accent); }
.select:disabled { opacity: .45; cursor: not-allowed; }
/* Voice lock notice */
.lock-notice {
display: none; margin-top: 8px; font-size: 11px; color: #f59e0b;
background: rgba(245,158,11,.1); border: 1px solid rgba(245,158,11,.2);
border-radius: 6px; padding: 5px 10px;
}
.lock-notice.visible { display: block; }
.slider { width: 100%; accent-color: var(--accent); cursor: pointer; margin-bottom: 10px; display: block; }