From fb8e1340f111b86cd6b52a79d9e8830c5d11067c Mon Sep 17 00:00:00 2001 From: ifedan-ed Date: Sat, 11 Apr 2026 03:23:18 +0200 Subject: [PATCH] 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 --- auth.py | 58 +++++ main.py | 612 ++++++++++++++++++++++++++++++++++++---------- models.py | 61 +++++ requirements.txt | 3 + static/admin.html | 197 +++++++++++++++ static/app.js | 374 ++++++++++++++++++++++++---- static/index.html | 346 ++++++++++++++------------ static/login.html | 147 +++++++++++ static/style.css | 106 +++++++- 9 files changed, 1555 insertions(+), 349 deletions(-) create mode 100644 auth.py create mode 100644 models.py create mode 100644 static/admin.html create mode 100644 static/login.html diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..67d3cf4 --- /dev/null +++ b/auth.py @@ -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 diff --git a/main.py b/main.py index 22d9421..6461818 100644 --- a/main.py +++ b/main.py @@ -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") diff --git a/models.py b/models.py new file mode 100644 index 0000000..390c616 --- /dev/null +++ b/models.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 0c325b7..363fa9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,6 @@ pymupdf beautifulsoup4 html2text requests +sqlalchemy +passlib[bcrypt] +python-jose[cryptography] diff --git a/static/admin.html b/static/admin.html new file mode 100644 index 0000000..b5e5297 --- /dev/null +++ b/static/admin.html @@ -0,0 +1,197 @@ + + + + + + Speechify — Admin + + + + + +
+ +
+ ← Back to App + +
+
+ +
+ +
+ +
+

Nextcloud Integration

+
+
+
+
+
+
+ +
+
+ + +
+

System

+
Loading…
+
+
+ + +
+

Users

+ + + +
IDUsernameEmailRoleStatusJoinedActions
+
+
+ + + + diff --git a/static/app.js b/static/app.js index d0e848a..1d31c0e 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = '
Loading…
'; + 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 = '
Empty folder
'; + return; + } + + for (const item of items) { + const div = document.createElement('div'); + div.className = 'nc-item' + (item.is_dir ? ' dir' : ''); + + const icon = item.is_dir + ? `` + : ``; + + div.innerHTML = `${icon}${item.name}`; + + 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 = '
Error loading
'; + 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 = '
Loading…
'; + 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 = '
No saved documents
'; + 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 = ` +
${escHtml(doc.title)}
+
${escHtml(doc.source_type)} · ${date}
+ `; + div.addEventListener('click', () => openSavedDoc(doc.id, doc.title)); + list.appendChild(div); + } + } catch (e) { + list.innerHTML = '
Error loading
'; + } +} +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 = ` Save`; + btn.disabled = false; + }, 2000); + } catch { + btn.textContent = '✗ Error'; + btn.disabled = false; + setTimeout(() => { + btn.innerHTML = ` Save`; + }, 2000); + } +} +$('btn-save-doc').addEventListener('click', saveDoc); + +function escHtml(str) { + return str.replace(/&/g,'&').replace(//g,'>'); +} + // ── 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(); diff --git a/static/index.html b/static/index.html index 3278c12..828834b 100644 --- a/static/index.html +++ b/static/index.html @@ -1,181 +1,201 @@ - - + + Speechify - - - - + + + -
+
- -
-
- + +
+
+ -
-
+
+ + +
+ - -
-
- -
- - - - - -
- -
-
- - - - - - -

Load a document to get started

- Paste text, upload a PDF, or enter a URL -
-
-
- - - - -
- - -
-
-

No document loaded

-
-
- - - -
-
- 0 / 0 -
-
+
+ Admin Panel +
+
-
+
+ + + + + +
+ +
+
+ +

Load a document to get started

+ Paste text, upload a PDF, enter a URL, or pick from Nextcloud +
+
+
+ + + - -
-
-

Loading…

- + +
+
+

No document loaded

+
+
+ + + +
+
+ 0 / 0 +
+
+
+
+
+ +
+ + +
+
+

Loading…

+
+ + diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..8e11e0a --- /dev/null +++ b/static/login.html @@ -0,0 +1,147 @@ + + + + + + Speechify — Sign In + + + + + + +
+ + +
+ + +
+ + +
+
+
+
+
+
+
+ +
+ + + +
+ + + + diff --git a/static/style.css b/static/style.css index 86564af..0ef632b 100644 --- a/static/style.css +++ b/static/style.css @@ -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; }