amber-backend/pb_public/index.html
Claude 0b65fd7f49 Family onboarding site: invite-gated signup, template config, /get/tv
Distribution site served by PocketBase itself (pb_public/, same origin):
- Landing (/): create-account CTA, TV sign-in steps, login-gated PC
  downloads via the existing update manifest + file tokens
- Wizard (/onboarding.html): invite code -> account -> named default
  profile -> family template fetched and encrypted IN THE BROWSER under
  the new user's password (PBKDF2-210k + AES-GCM, byte-compatible with
  the app's AddonConfigCrypto - a wizard blob decrypts in Dart, verified)
  -> pushed as their addon_config ciphertext. Adult fields never included.
- /get/tv: public direct download of the latest clean Android APK
  (Downloader-friendly; adult builds stay account-gated)

Backend:
- onboarding_template collection (admin-only, maintained via admin UI)
- AMBER_INVITE_CODE env gates BOTH users creation (X-Amber-Invite header
  or ?invite=) and the template route (per-IP rate limit 10/5min);
  fail-closed when unset. Note: this closes the previously-open in-app
  registration too.
- Dockerfile ships pb_public + --publicDir; compose passes the env var

Contract + Coolify steps (second domain amber.petruzalekr.cz) in
docs/onboarding-contract.md. Verified locally end-to-end against
pocketbase 0.39.6 (real-browser wizard run + Dart decrypt interop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:23:05 +02:00

161 lines
7.2 KiB
HTML

<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex">
<title>Amber — filmy a seriály pro rodinu</title>
<style>
:root { color-scheme: dark; --bg:#0e0f13; --card:#191b21; --fg:#f2e9d8;
--muted:#9aa0aa; --amber:#f0a63c; --amber2:#c9791b; --err:#ff6b6b; --ok:#5fd08a;
--line:#2a2d36; }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
min-height:100dvh; display:flex; flex-direction:column; align-items:center; }
.wrap { width:100%; max-width:640px; padding:32px 20px 48px; }
.brand { display:flex; align-items:center; gap:12px; margin:18px 0 6px; }
.gem { width:40px; height:40px; }
h1 { font-size:34px; margin:0; color:var(--amber); letter-spacing:.5px; }
p.tag { margin:6px 0 30px; color:var(--muted); font-size:16px; line-height:1.5; }
.card { background:var(--card); border:1px solid var(--line); border-radius:16px;
padding:22px 20px; margin-bottom:18px; box-shadow:0 10px 40px rgba(0,0,0,.25); }
.card h2 { margin:0 0 6px; font-size:18px; }
.card p { margin:0 0 14px; color:var(--muted); font-size:14px; line-height:1.55; }
a.btn, button.btn { display:block; width:100%; text-align:center; padding:14px;
border:none; border-radius:10px; font-size:15px; font-weight:700;
text-decoration:none; cursor:pointer; }
.primary { background:var(--amber); color:#1b1206; }
.secondary { background:transparent; color:var(--amber); border:1px solid var(--amber2) !important; border:1px solid var(--amber2); }
.row { display:flex; gap:10px; }
.row > * { flex:1; }
code.url { display:inline-block; background:var(--bg); border:1px solid var(--line);
border-radius:8px; padding:6px 10px; font-family:ui-monospace,Menlo,Consolas,monospace;
font-size:14px; color:var(--amber); }
label { display:block; font-size:13px; color:var(--muted); margin:10px 0 5px; }
input { width:100%; padding:12px; border-radius:10px; border:1px solid var(--line);
background:var(--bg); color:var(--fg); font-size:15px; }
input:focus { outline:2px solid var(--amber); border-color:transparent; }
.msg { margin-top:12px; font-size:14px; min-height:18px; }
.msg.err { color:var(--err); }
.msg.ok { color:var(--ok); }
.hidden { display:none; }
.dl { display:flex; gap:10px; margin-top:12px; }
.dl a { flex:1; }
footer { color:var(--muted); font-size:12px; margin-top:8px; text-align:center;
padding-bottom:24px; }
ol { margin:0; padding-left:20px; color:var(--muted); font-size:14px; line-height:1.7; }
ol b { color:var(--fg); }
</style>
</head>
<body>
<div class="wrap">
<div class="brand">
<svg class="gem" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<polygon points="50,4 90,32 76,92 24,92 10,32" fill="#c9791b"/>
<polygon points="50,4 90,32 50,50" fill="#f0a63c"/>
<polygon points="50,4 10,32 50,50" fill="#e9932a"/>
<polygon points="10,32 24,92 50,50" fill="#b06515"/>
<polygon points="90,32 76,92 50,50" fill="#8f4f0e"/>
<polygon points="24,92 76,92 50,50" fill="#a05a12"/>
</svg>
<h1>Amber</h1>
</div>
<p class="tag">Filmy, seriály a anime pro celou rodinu — česky, na televizi i na počítači.</p>
<div class="card">
<h2>Začínáš?</h2>
<p>Průvodce ti vytvoří účet a rovnou nastaví vše potřebné. Budeš potřebovat
<b>kód pozvánky</b> od Richarda.</p>
<a class="btn primary" href="/onboarding.html">Vytvořit účet</a>
</div>
<div class="card">
<h2>Přihlásit televizi</h2>
<ol>
<li>Na televizi nainstaluj Amber — v prohlížeči nebo v aplikaci
<b>Downloader</b> otevři <code class="url" id="tvUrl">/get/tv</code></li>
<li>Spusť Amber a zvol <b>„Přihlásit z jiného zařízení“</b></li>
<li>Naskenuj QR kód telefonem, nebo pokračuj tlačítkem níže a opiš kód</li>
</ol>
<div style="height:14px"></div>
<a class="btn secondary" href="/device">Zadat kód z televize</a>
</div>
<div class="card">
<h2>Stáhnout do počítače</h2>
<p>Přihlas se svým účtem — stažení je vázané na účet.</p>
<div id="dlform">
<label for="dlEmail">E-mail</label>
<input id="dlEmail" type="email" autocomplete="username">
<label for="dlPass">Heslo</label>
<input id="dlPass" type="password" autocomplete="current-password">
<div style="height:12px"></div>
<button class="btn primary" id="dlGo">Zobrazit stažení</button>
</div>
<div id="dlLinks" class="dl hidden"></div>
<div id="dlMsg" class="msg"></div>
</div>
<footer>Amber je soukromá rodinná aplikace.</footer>
</div>
<script>
document.getElementById("tvUrl").textContent = location.host + "/get/tv";
// Detect the visitor's platform to pre-order the download buttons.
function platforms(){
var ua = navigator.userAgent;
if (/Windows/i.test(ua)) return ["windows", "linux"];
if (/Linux|X11/i.test(ua) && !/Android/i.test(ua)) return ["linux", "windows"];
return ["windows", "linux"];
}
var NAMES = { windows: "Windows", linux: "Linux" };
async function api(method, path, body, token){
var opt = { method:method, headers:{} };
if (body){ opt.headers["Content-Type"]="application/json"; opt.body=JSON.stringify(body); }
if (token){ opt.headers["Authorization"]=token; }
var r = await fetch(path, opt);
var data = null; try { data = await r.json(); } catch(_){}
return { ok:r.ok, status:r.status, data:data };
}
document.getElementById("dlGo").addEventListener("click", async function(){
var msg = document.getElementById("dlMsg");
msg.className = "msg"; msg.textContent = "";
var email = document.getElementById("dlEmail").value.trim();
var pass = document.getElementById("dlPass").value;
if (!email || !pass){ msg.className="msg err"; msg.textContent="Zadej e-mail a heslo."; return; }
var auth = await api("POST", "/api/collections/users/auth-with-password",
{ identity: email, password: pass });
if (!auth.ok || !auth.data || !auth.data.token){
msg.className="msg err"; msg.textContent="Nesprávný e-mail nebo heslo."; return;
}
var token = auth.data.token;
var box = document.getElementById("dlLinks");
box.innerHTML = ""; box.classList.remove("hidden");
var found = 0;
for (const p of platforms()){
var m = await api("GET", "/api/update/manifest?platform=" + p, null, token);
if (!m.ok || !m.data || !m.data.available) continue;
var t = await api("POST", "/api/files/token", null, token);
if (!t.ok || !t.data || !t.data.token) continue;
var a = document.createElement("a");
a.className = "btn secondary";
a.href = m.data.downloadPath + "?token=" + encodeURIComponent(t.data.token);
a.textContent = NAMES[p] + " · " + m.data.version;
box.appendChild(a);
found++;
}
if (!found){
box.classList.add("hidden");
msg.className="msg err"; msg.textContent="Zatím není zveřejněná žádná verze.";
} else {
document.getElementById("dlform").classList.add("hidden");
msg.className="msg ok"; msg.textContent="Hotovo — vyber svůj systém.";
}
});
</script>
</body>
</html>