"""HTML-интерфейс сервиса: вход, регистрация, кабинет, админка.""" from __future__ import annotations import html import json STYLE = """ :root{--bg:#0f1116;--card:#171a22;--line:#262b36;--fg:#e8ebf2;--mut:#9aa3b2; --acc:#c69e2d;--acc2:#e0bd57;--ok:#3fb950;--err:#f85149} *{box-sizing:border-box} body{margin:0;font:15px/1.5 -apple-system,Segoe UI,Roboto,Arial,sans-serif; background:var(--bg);color:var(--fg)} header{padding:18px 28px;border-bottom:1px solid var(--line); display:flex;align-items:center;gap:14px;background:linear-gradient(180deg,#141822,#0f1116)} header h1{font-size:19px;margin:0;font-weight:700} header .dot{width:10px;height:10px;border-radius:50%;background:var(--acc)} header .spacer{margin-left:auto} header .who{color:var(--mut);font-size:13px} header a{color:var(--acc2);text-decoration:none;font-size:13px} main{max-width:1180px;margin:0 auto;padding:26px;display:grid; grid-template-columns:minmax(340px,440px) 1fr;gap:24px;align-items:start} main.single{grid-template-columns:1fr;max-width:920px} @media(max-width:920px){main{grid-template-columns:1fr}} .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px} .card h2{font-size:14px;text-transform:uppercase;letter-spacing:.08em; color:var(--mut);margin:0 0 16px;font-weight:600} label{display:block;font-size:13px;color:var(--mut);margin:12px 0 5px} input,select,textarea{width:100%;background:#0d1017;border:1px solid var(--line); color:var(--fg);border-radius:9px;padding:10px 12px;font:inherit;outline:none} input:focus,select:focus,textarea:focus{border-color:var(--acc)} textarea{resize:vertical;min-height:70px} .row{display:grid;grid-template-columns:1fr 1fr;gap:12px} button{cursor:pointer;border:none;border-radius:10px;padding:11px 16px;font:inherit; font-weight:600;transition:.15s} .btn-primary{background:var(--acc);color:#1a1500;width:100%;margin-top:16px} .btn-primary:hover{background:var(--acc2)} .btn-ghost{background:#20252f;color:var(--fg);border:1px solid var(--line); text-decoration:none;display:inline-block} .btn-ghost:hover{background:#262c38} .btn-sm{padding:6px 10px;font-size:13px;border-radius:8px} .btn-danger{background:#3a1d20;color:#ffb4b4;border:1px solid #5a2a2e} .actions{display:flex;gap:10px;margin-top:10px;flex-wrap:wrap} .linkbox{display:flex;gap:8px;margin:10px 0} .linkbox input{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:13px} .preview{border-radius:12px;overflow:hidden;border:1px solid var(--line);background:#fff;margin-top:14px} .preview img{width:100%;display:block} table{width:100%;border-collapse:collapse;font-size:14px} th,td{text-align:left;padding:9px 8px;border-bottom:1px solid var(--line);white-space:nowrap} th{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.06em} td a{color:var(--acc2);text-decoration:none} .mono{font-family:ui-monospace,Menlo,Consolas,monospace} .muted{color:var(--mut)} .err{color:var(--err);font-size:13px;margin-top:10px} .notice{color:var(--ok);font-size:13px;margin-top:10px} .pill{display:inline-block;background:#20252f;border:1px solid var(--line); border-radius:20px;padding:2px 10px;font-size:12px;color:var(--mut)} .pill.admin{color:#ffd76a;border-color:#5a4a1e;background:#2a2412} .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px} .doc{border:1px solid var(--line);border-radius:12px;overflow:hidden;background:#0d1017} .doc img{width:100%;display:block;background:#fff} .doc .meta{padding:10px 12px} .doc .num{font-family:ui-monospace,Menlo,Consolas,monospace;font-weight:700} .doc .fio{font-size:14px;margin:2px 0} .doc .links{display:flex;gap:10px;margin-top:8px;font-size:13px} .authwrap{max-width:420px;margin:8vh auto;padding:0 20px} .authwrap h1{font-size:22px;margin:0 0 6px} .authwrap .sub{color:var(--mut);margin:0 0 18px;font-size:14px} .switch{color:var(--mut);font-size:14px;margin-top:14px} .switch a{color:var(--acc2);text-decoration:none} """ AUTH_JS = r""" const $ = id => document.getElementById(id); const mode = window.AUTH_MODE; function setMsg(id, text){ const el = $(id); if (el) el.textContent = text; } async function submit(e){ e.preventDefault(); setMsg('err',''); setMsg('notice',''); const body = {email:$('email').value.trim(), password:$('password').value}; if(mode==='register'){ body.name = ($('name')?$('name').value.trim():''); } let r; try { r = await fetch('/api/auth/'+mode,{method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); } catch(err){ setMsg('err','Сервер недоступен. Попробуйте позже.'); return; } if(r.ok){ location.href='/'; return; } let msg = 'Ошибка'; try { msg = (await r.json()).error || msg; } catch(e){} setMsg('err', msg); } window.addEventListener('DOMContentLoaded',()=>{ $('form').addEventListener('submit',submit); }); """ DASH_JS = r""" const $ = id => document.getElementById(id); const U = window.CURRENT_USER, ADMIN = window.IS_ADMIN; async function api(path, opts){ const r = await fetch(path, opts); if(!r.ok){ let m='Ошибка'; try{ m=(await r.json()).error||m; }catch(e){} throw new Error(m); } const ct = r.headers.get('content-type')||''; return ct.includes('json') ? r.json() : r.blob(); } function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } async function loadTemplates(){ const sel=$('template'); if(!sel) return; const t = await api('/api/templates'); sel.innerHTML = t.map(x=>``).join(''); } function payload(){ const p={template:$('template').value, fio:$('fio').value.trim(), title:$('title').value.trim(), body:$('body').value.trim(), date:$('date').value.trim(), prefix:$('prefix').value.trim(), email:$('email').value.trim()}; const n=$('number').value.trim(); if(n) p.number=n; return p; } async function preview(){ $('err').textContent=''; try{ const blob = await api('/api/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload())}); $('img').src = URL.createObjectURL(blob); $('result').classList.add('show'); }catch(e){ $('err').textContent=e.message; } } async function create(){ $('err').textContent=''; $('notice').textContent=''; try{ const c = await api('/api/certificates',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload())}); $('link').value=c.url; $('img').src=c.png+'?t='+Date.now(); $('dlpng').href=c.png; $('dlpdf').href=c.pdf; $('numbadge').textContent='№ '+c.number; $('result').classList.add('show'); loadDocs(); }catch(e){ $('err').textContent=e.message; } } function copyLink(){ const el=$('link'); el.select(); navigator.clipboard.writeText(el.value); $('copy').textContent='Скопировано'; setTimeout(()=>$('copy').textContent='Копировать',1200); } async function loadDocs(){ let items; try{ items = await api('/api/certificates?limit=200'); }catch(e){ return; } const box=$('docs'); if(!items.length){ box.innerHTML='
'+(ADMIN?'Документов пока нет.':'У вас пока нет сертификатов. Если ожидаете документ — проверьте, что администратор указал этот email как получателя.')+'
'; return; } box.innerHTML = items.map(c=>`
Сертификаты и благодарственные письма
' f'{notice_html}' f'{link}' f'Пользователь увидит документ после входа под этим email.