speechify-clone/static/login.html
ifedan-ed ee3ffcfe90 Add email verification, user Nextcloud settings, Groq TTS, favicon, and settings page
- Email verification: send link on register via SMTP2GO (noreply@pedshub.com), verify endpoint, beautiful HTML email
- Unverified users see yellow banner in app with resend button; also shown on settings page
- User settings page (/settings): personal Nextcloud credentials (override admin-wide config)
- Admin and per-user Nextcloud config merged (user settings take priority)
- Groq PlayAI TTS: 20 English voices + 2 Arabic voices via groq-playai-tts / groq-playai-tts-arabic
- Added groq-playai-tts and groq-playai-tts-arabic to litellm config
- Favicon SVG (purple play button) across all pages
- Settings link in user dropdown menu
- verify-ok.html and verify-fail.html result pages
- DB migration: added email_verified column, verification_tokens and user_settings tables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 04:12:25 +02:00

148 lines
6.7 KiB
HTML

<!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"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<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>