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:
parent
87d38183c0
commit
fb8e1340f1
9 changed files with 1555 additions and 349 deletions
58
auth.py
Normal file
58
auth.py
Normal 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
612
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")
|
||||
|
|
|
|||
61
models.py
Normal file
61
models.py
Normal 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)
|
||||
|
|
@ -6,3 +6,6 @@ pymupdf
|
|||
beautifulsoup4
|
||||
html2text
|
||||
requests
|
||||
sqlalchemy
|
||||
passlib[bcrypt]
|
||||
python-jose[cryptography]
|
||||
|
|
|
|||
197
static/admin.html
Normal file
197
static/admin.html
Normal 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>
|
||||
374
static/app.js
374
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 = '<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,'&').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();
|
||||
|
|
|
|||
|
|
@ -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
147
static/login.html
Normal 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>
|
||||
106
static/style.css
106
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; }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue