508 lines
26 KiB
Python
508 lines
26 KiB
Python
"""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=>`<option value="${x.slug}">${esc(x.title)} (${x.slug})</option>`).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='<p class="muted">'+(ADMIN?'Документов пока нет.':'У вас пока нет сертификатов. Если ожидаете документ — проверьте, что администратор указал этот email как получателя.')+'</p>'; return; }
|
|
box.innerHTML = items.map(c=>`<div class="doc">
|
|
<img src="/c/${c.seq}.png" loading="lazy" alt="">
|
|
<div class="meta">
|
|
<div class="num">№ ${esc(c.number)}</div>
|
|
<div class="fio">${esc(c.fio||'')}</div>
|
|
<div class="muted" style="font-size:12px">${esc(c.title||'')}${c.recipient_email?(' · '+esc(c.recipient_email)):''}</div>
|
|
${c.public_url?`<div class="muted" style="font-size:12px;word-break:break-all">🔗 <a href="${c.public_url}" target="_blank">${c.public_url}</a> <a href="#" data-copy="${c.public_url}">копировать</a></div>`:''}
|
|
<div class="links"><a href="/c/${c.seq}" target="_blank">Открыть</a>
|
|
<a href="/c/${c.seq}.png" download>PNG</a>
|
|
<a href="/c/${c.seq}.pdf" download>PDF</a>
|
|
${ADMIN?`<a href="#" data-share="${c.seq}" data-on="${c.public_url?1:0}">${c.public_url?'Скрыть ссылку':'Сделать публичной'}</a>`:''}
|
|
${ADMIN?`<a href="#" data-del="${c.seq}" style="color:#ffb4b4">Удалить</a>`:''}</div>
|
|
</div></div>`).join('');
|
|
box.querySelectorAll('[data-del]').forEach(a=>a.addEventListener('click',async ev=>{
|
|
ev.preventDefault();
|
|
if(!confirm('Удалить документ №'+a.dataset.del+'?')) return;
|
|
try{ await api('/api/certificates/'+a.dataset.del,{method:'DELETE'}); loadDocs(); }catch(e){ alert(e.message); }
|
|
}));
|
|
box.querySelectorAll('[data-share]').forEach(a=>a.addEventListener('click',async ev=>{
|
|
ev.preventDefault();
|
|
const seq=a.dataset.share, isOn=a.dataset.on==='1';
|
|
try{
|
|
if(isOn){ await api('/api/certificates/'+seq+'/share',{method:'DELETE'}); }
|
|
else{
|
|
const r = await api('/api/certificates/'+seq+'/share',{method:'POST'});
|
|
try{ await navigator.clipboard.writeText(r.public_url); }catch(e){}
|
|
alert('Публичная ссылка создана и скопирована:\n'+r.public_url);
|
|
}
|
|
loadDocs();
|
|
}catch(e){ alert(e.message); }
|
|
}));
|
|
box.querySelectorAll('[data-copy]').forEach(el=>el.addEventListener('click',ev=>{
|
|
ev.preventDefault();
|
|
navigator.clipboard.writeText(el.dataset.copy).catch(()=>{});
|
|
el.textContent='скопировано'; setTimeout(()=>el.textContent='копировать',1200);
|
|
}));
|
|
}
|
|
|
|
async function changePassword(){
|
|
$('pwmsg').textContent='';
|
|
const cur=$('pw_cur').value, nw=$('pw_new').value;
|
|
try{
|
|
await api('/api/auth/password',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({current:cur, password:nw})});
|
|
$('pw_cur').value=''; $('pw_new').value=''; $('pwmsg').textContent='Пароль изменён';
|
|
}catch(e){ $('pwmsg').textContent=e.message; }
|
|
}
|
|
async function logout(){ try{ await fetch('/api/auth/logout',{method:'POST'}); }catch(e){} location.href='/login'; }
|
|
|
|
window.addEventListener('DOMContentLoaded',()=>{
|
|
loadDocs();
|
|
if(ADMIN && $('template')) loadTemplates();
|
|
});
|
|
"""
|
|
|
|
USERS_JS = r"""
|
|
const $ = id => document.getElementById(id);
|
|
const ME = window.CURRENT_USER;
|
|
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); }
|
|
return r.headers.get('content-type','').includes('json') ? r.json() : r.blob();
|
|
}
|
|
function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
|
|
|
async function load(){
|
|
const users = await api('/api/users');
|
|
$('tbody').innerHTML = users.map(u=>`<tr>
|
|
<td>${esc(u.email)}</td>
|
|
<td>${esc(u.name||'')}</td>
|
|
<td><span class="pill ${u.role==='admin'?'admin':''}">${u.role}</span></td>
|
|
<td>${u.is_active?'да':'нет'}</td>
|
|
<td class="muted">${esc((u.created_at||'').slice(0,10))}</td>
|
|
<td>
|
|
<button class="btn-ghost btn-sm" data-role="${u.id}" data-cur="${u.role}">${u.role==='admin'?'Снять админа':'Сделать админом'}</button>
|
|
<button class="btn-ghost btn-sm" data-active="${u.id}" data-cur="${u.is_active?1:0}">${u.is_active?'Отключить':'Включить'}</button>
|
|
<button class="btn-ghost btn-sm" data-pw="${u.id}">Сбросить пароль</button>
|
|
${u.id===ME.id?'':`<button class="btn-danger btn-sm" data-del="${u.id}">Удалить</button>`}
|
|
</td></tr>`).join('');
|
|
bind();
|
|
}
|
|
function bind(){
|
|
document.querySelectorAll('[data-del]').forEach(b=>b.onclick=async()=>{
|
|
if(!confirm('Удалить пользователя?')) return;
|
|
try{ await api('/api/users/'+b.dataset.del,{method:'DELETE'}); load(); }catch(e){ alert(e.message); }
|
|
});
|
|
document.querySelectorAll('[data-role]').forEach(b=>b.onclick=async()=>{
|
|
const role = b.dataset.cur==='admin'?'user':'admin';
|
|
try{ await api('/api/users/'+b.dataset.role,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({role})}); load(); }catch(e){ alert(e.message); }
|
|
});
|
|
document.querySelectorAll('[data-active]').forEach(b=>b.onclick=async()=>{
|
|
const is_active = b.dataset.cur!=='1';
|
|
try{ await api('/api/users/'+b.dataset.active,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active})}); load(); }catch(e){ alert(e.message); }
|
|
});
|
|
document.querySelectorAll('[data-pw]').forEach(b=>b.onclick=async()=>{
|
|
const pw = prompt('Новый пароль (мин. 8 символов):'); if(!pw) return;
|
|
try{ await api('/api/users/'+b.dataset.pw,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pw})}); alert('Пароль обновлён'); }catch(e){ alert(e.message); }
|
|
});
|
|
}
|
|
async function addUser(){
|
|
$('msg').textContent='';
|
|
const body={email:$('u_email').value.trim(), name:$('u_name').value.trim(),
|
|
password:$('u_pw').value, role:$('u_role').value};
|
|
try{ await api('/api/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
$('u_email').value=''; $('u_name').value=''; $('u_pw').value=''; load();
|
|
}catch(e){ $('msg').textContent=e.message; }
|
|
}
|
|
async function logout(){ try{ await fetch('/api/auth/logout',{method:'POST'}); }catch(e){} location.href='/login'; }
|
|
window.addEventListener('DOMContentLoaded',load);
|
|
"""
|
|
|
|
|
|
def _head(title: str, extra_css: str = "") -> str:
|
|
return (f'<!doctype html><html lang="ru"><head><meta charset="utf-8">'
|
|
f'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
f'<title>{html.escape(title)}</title><style>{STYLE}{extra_css}</style></head>')
|
|
|
|
|
|
def auth_page_html(mode: str, notice: str = "") -> str:
|
|
is_reg = mode == "register"
|
|
title = "Регистрация" if is_reg else "Вход"
|
|
extra = ('<label>Имя</label><input id="name" placeholder="Иван Иванов" autocomplete="name">'
|
|
if is_reg else "")
|
|
link = ('<div class="switch">Уже есть аккаунт? <a href="/login">Войти</a></div>'
|
|
if is_reg else
|
|
'<div class="switch">Нет аккаунта? <a href="/register">Зарегистрироваться</a></div>')
|
|
notice_html = f'<div id="notice" class="notice">{html.escape(notice)}</div>' if notice else ""
|
|
return (
|
|
_head(f"certgen — {title}") +
|
|
f'<body><div class="authwrap"><div class="card">'
|
|
f'<h1>{title}</h1><p class="sub">Сертификаты и благодарственные письма</p>'
|
|
f'{notice_html}'
|
|
f'<form id="form" autocomplete="on">'
|
|
f'<label>Email</label><input id="email" type="email" placeholder="you@example.com" autocomplete="email" required>'
|
|
f'{extra}'
|
|
f'<label>Пароль</label><input id="password" type="password" placeholder="мин. 8 символов" autocomplete="current-password" required>'
|
|
f'<button class="btn-primary" type="submit">{title}</button>'
|
|
f'</form><div id="err" class="err"></div>{link}'
|
|
f'</div></div>'
|
|
f'<script>window.AUTH_MODE={json.dumps(mode)};{AUTH_JS}</script></body></html>'
|
|
)
|
|
|
|
|
|
def dashboard_html(user: dict, base: str) -> str:
|
|
is_admin = user["role"] == "admin"
|
|
user_js = json.dumps({"id": user["id"], "email": user["email"],
|
|
"name": user.get("name"), "role": user["role"]},
|
|
ensure_ascii=False)
|
|
role_pill = ('<span class="pill admin">администратор</span>' if is_admin
|
|
else '<span class="pill">пользователь</span>')
|
|
users_link = '<a href="/users">Пользователи</a>' if is_admin else ""
|
|
header = (
|
|
'<header><span class="dot"></span><h1>certgen</h1>'
|
|
f'<span class="who">{html.escape(user["email"])} {role_pill}</span>'
|
|
f'<span class="spacer"></span>{users_link}'
|
|
'<a href="#" onclick="logout();return false">Выйти</a></header>'
|
|
)
|
|
|
|
if is_admin:
|
|
create_card = f"""
|
|
<section class="card">
|
|
<h2>Новый документ</h2>
|
|
<label>Шаблон</label><select id="template"></select>
|
|
<div class="row">
|
|
<div><label>ФИО получателя</label><input id="fio" placeholder="Иванов Иван Иванович"></div>
|
|
<div><label>Email получателя</label><input id="email" type="email" placeholder="user@example.com"></div>
|
|
</div>
|
|
<p class="muted" style="font-size:12px;margin:6px 0 0">Пользователь увидит документ после входа под этим email.</p>
|
|
<label>Заголовок (необязательно)</label><input id="title" placeholder="напр. СЕРТИФИКАТ">
|
|
<label>Дополнительный текст</label><textarea id="body" placeholder="за успешное прохождение курса «...»"></textarea>
|
|
<div class="row">
|
|
<div><label>Дата</label><input id="date" placeholder="пусто — сегодня"></div>
|
|
<div><label>Префикс номера</label><input id="prefix" placeholder="напр. СЕРТ"></div>
|
|
</div>
|
|
<label>Особый номер 1–1000 (необязательно)</label>
|
|
<input id="number" placeholder="пусто — обычная нумерация с 1001">
|
|
<button class="btn-primary" onclick="create()">Создать документ</button>
|
|
<div class="actions"><button class="btn-ghost" onclick="preview()">Предпросмотр</button></div>
|
|
<div id="err" class="err"></div><div id="notice" class="notice"></div>
|
|
<div id="result" style="margin-top:16px;display:none">
|
|
<label>Ссылка <span class="pill" id="numbadge"></span></label>
|
|
<div class="linkbox"><input id="link" readonly>
|
|
<button class="btn-ghost" id="copy" onclick="copyLink()">Копировать</button></div>
|
|
<div class="actions">
|
|
<a id="dlpng" class="btn-ghost btn-sm" download>PNG</a>
|
|
<a id="dlpdf" class="btn-ghost btn-sm" download>PDF</a></div>
|
|
<div class="preview"><img id="img" alt=""></div>
|
|
</div>
|
|
</section>"""
|
|
docs_title = "Все документы"
|
|
else:
|
|
create_card = ""
|
|
docs_title = "Мои документы"
|
|
|
|
docs_card = f"""
|
|
<section class="card">
|
|
<h2>{docs_title}</h2>
|
|
<div class="cards" id="docs"></div>
|
|
</section>"""
|
|
|
|
account_card = """
|
|
<section class="card" style="grid-column:1/-1">
|
|
<h2>Аккаунт</h2>
|
|
<div class="row">
|
|
<div><label>Текущий пароль</label><input id="pw_cur" type="password"></div>
|
|
<div><label>Новый пароль</label><input id="pw_new" type="password" placeholder="мин. 8 символов"></div>
|
|
</div>
|
|
<div class="actions"><button class="btn-ghost" onclick="changePassword()">Сменить пароль</button></div>
|
|
<div id="pwmsg" class="notice"></div>
|
|
</section>"""
|
|
|
|
main_cls = "" if is_admin else ' class="single"'
|
|
return (
|
|
_head("certgen — кабинет") + '<body>' + header +
|
|
f'<main{main_cls}>{create_card}{docs_card}'
|
|
+ (account_card if is_admin else "") +
|
|
'</main>'
|
|
f'<script>window.CURRENT_USER={user_js};window.IS_ADMIN={str(is_admin).lower()};{DASH_JS}</script>'
|
|
'</body></html>'
|
|
)
|
|
|
|
|
|
def users_page_html(user: dict, base: str) -> str:
|
|
user_js = json.dumps({"id": user["id"], "email": user["email"], "role": user["role"]},
|
|
ensure_ascii=False)
|
|
header = (
|
|
'<header><span class="dot"></span><h1>certgen</h1>'
|
|
'<span class="who">Пользователи</span><span class="spacer"></span>'
|
|
'<a href="/">← Кабинет</a> '
|
|
'<a href="#" onclick="logout();return false">Выйти</a></header>'
|
|
)
|
|
return (
|
|
_head("certgen — пользователи") + '<body>' + header +
|
|
'<main><section class="card" style="grid-column:1/-1">'
|
|
'<h2>Новый пользователь</h2>'
|
|
'<div class="row">'
|
|
'<div><label>Email</label><input id="u_email" type="email"></div>'
|
|
'<div><label>Имя</label><input id="u_name"></div></div>'
|
|
'<div class="row">'
|
|
'<div><label>Пароль</label><input id="u_pw" type="password" placeholder="мин. 8 символов"></div>'
|
|
'<div><label>Роль</label><select id="u_role"><option value="user">user</option>'
|
|
'<option value="admin">admin</option></select></div></div>'
|
|
'<button class="btn-primary" onclick="addUser()">Добавить</button>'
|
|
'<div id="msg" class="err"></div></section>'
|
|
'<section class="card" style="grid-column:1/-1"><h2>Список</h2>'
|
|
'<table><thead><tr><th>Email</th><th>Имя</th><th>Роль</th><th>Активен</th>'
|
|
'<th>Создан</th><th></th></tr></thead><tbody id="tbody"></tbody></table></section></main>'
|
|
f'<script>window.CURRENT_USER={user_js};{USERS_JS}</script></body></html>'
|
|
)
|
|
|
|
|
|
def cert_page_html(cert: dict, base: str) -> str:
|
|
safe = html.escape
|
|
return (
|
|
_head(f"Документ №{cert['number']}",
|
|
".wrap{max-width:1000px;margin:0 auto;padding:28px}"
|
|
".head{display:flex;align-items:center;gap:12px;margin-bottom:18px;flex-wrap:wrap}"
|
|
".head h1{font-size:18px;margin:0}"
|
|
".big{background:#fff;border-radius:14px;overflow:hidden;border:1px solid var(--line)}"
|
|
".big img{width:100%;display:block}") +
|
|
'<body><div class="wrap">'
|
|
'<div class="head"><span class="dot"></span>'
|
|
f'<h1>Документ №{safe(cert["number"])}</h1>'
|
|
f'<span class="muted">{safe(cert.get("fio") or "")} · {safe(cert.get("date") or "")}</span>'
|
|
'<span style="margin-left:auto"></span>'
|
|
f'<a class="btn-ghost btn-sm" href="{base}/c/{cert["seq"]}.png" download>Скачать PNG</a> '
|
|
f'<a class="btn-ghost btn-sm" href="{base}/c/{cert["seq"]}.pdf" download>Скачать PDF</a>'
|
|
'</div>'
|
|
f'<div class="big"><img src="{base}/c/{cert["seq"]}.png" alt="document"></div>'
|
|
'</div></body></html>'
|
|
)
|
|
|
|
|
|
def public_cert_page_html(cert: dict, base: str, image_size: tuple[int, int] | None = None) -> str:
|
|
safe = html.escape
|
|
esc = lambda s: html.escape(str(s or ""), quote=True) # noqa: E731
|
|
token = cert.get("public_token") or ""
|
|
url = f"{base}/s/{token}"
|
|
img = f"{base}/s/{token}.png"
|
|
pdf = f"{base}/s/{token}.pdf"
|
|
|
|
number = cert.get("number") or ""
|
|
doc_title = cert.get("title") or "Документ"
|
|
fio = cert.get("fio") or ""
|
|
date = cert.get("date") or ""
|
|
|
|
page_title = f"{doc_title} № {number}" + (f" — {fio}" if fio else "")
|
|
desc_parts = [f"{doc_title} № {number}"]
|
|
if fio:
|
|
desc_parts.append(f"получатель: {fio}")
|
|
if date:
|
|
desc_parts.append(f"дата: {date}")
|
|
description = ". ".join(desc_parts) + ". Электронный документ certgen."
|
|
image_alt = f"{doc_title} № {number}" + (f", {fio}" if fio else "")
|
|
|
|
dims = ""
|
|
if image_size:
|
|
dims = (f'\n<meta property="og:image:width" content="{int(image_size[0])}">'
|
|
f'\n<meta property="og:image:height" content="{int(image_size[1])}">')
|
|
secure = ('\n<meta property="og:image:secure_url" content="%s">' % esc(img)) if url.startswith("https://") else ""
|
|
|
|
favicon = ("data:image/svg+xml,"
|
|
"%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E"
|
|
"%3Crect width='32' height='32' rx='7' fill='%230f1116'/%3E"
|
|
"%3Ccircle cx='16' cy='16' r='7' fill='%23c69e2d'/%3E%3C/svg%3E")
|
|
|
|
jsonld = {
|
|
"@context": "https://schema.org",
|
|
"@type": "EducationalOccupationalCredential",
|
|
"name": page_title,
|
|
"description": description,
|
|
"image": img,
|
|
"url": url,
|
|
"dateCreated": cert.get("date"),
|
|
"credentialCategory": "certificate",
|
|
"creator": {"@type": "Organization", "name": "certgen"},
|
|
}
|
|
|
|
head = f"""<!doctype html><html lang="ru"><head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>{safe(page_title)}</title>
|
|
<meta name="description" content="{esc(description)}">
|
|
<meta name="robots" content="noindex, nofollow">
|
|
<meta name="author" content="certgen">
|
|
<meta name="theme-color" content="#c69e2d">
|
|
<link rel="canonical" href="{esc(url)}">
|
|
<link rel="icon" href="{favicon}">
|
|
<!-- Open Graph -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:site_name" content="certgen">
|
|
<meta property="og:locale" content="ru_RU">
|
|
<meta property="og:title" content="{esc(page_title)}">
|
|
<meta property="og:description" content="{esc(description)}">
|
|
<meta property="og:url" content="{esc(url)}">
|
|
<meta property="og:image" content="{esc(img)}">
|
|
<meta property="og:image:type" content="image/png">
|
|
<meta property="og:image:alt" content="{esc(image_alt)}">{dims}{secure}
|
|
<!-- Twitter -->
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:title" content="{esc(page_title)}">
|
|
<meta name="twitter:description" content="{esc(description)}">
|
|
<meta name="twitter:image" content="{esc(img)}">
|
|
<meta name="twitter:image:alt" content="{esc(image_alt)}">
|
|
<script type="application/ld+json">{json.dumps(jsonld, ensure_ascii=False)}</script>
|
|
<style>{STYLE}
|
|
.wrap{{max-width:1000px;margin:0 auto;padding:28px}}
|
|
.head{{display:flex;align-items:center;gap:12px;margin-bottom:18px;flex-wrap:wrap}}
|
|
.head h1{{font-size:18px;margin:0}}
|
|
.big{{background:#fff;border-radius:14px;overflow:hidden;border:1px solid var(--line)}}
|
|
.big img{{width:100%;display:block}}
|
|
</style></head>"""
|
|
|
|
return (
|
|
head +
|
|
'<body><div class="wrap">'
|
|
'<div class="head"><span class="dot"></span>'
|
|
f'<h1>Документ №{safe(number)}</h1>'
|
|
f'<span class="muted">{safe(fio)} · {safe(date)}</span>'
|
|
'<span style="margin-left:auto"></span>'
|
|
f'<a class="btn-ghost btn-sm" href="{pdf}" download>Скачать PDF</a> '
|
|
f'<a class="btn-ghost btn-sm" href="{img}" download>Скачать PNG</a>'
|
|
'</div>'
|
|
f'<div class="big"><img src="{img}" alt="{esc(image_alt)}"></div>'
|
|
'</div></body></html>'
|
|
)
|