79a6625613
Two fixes placed by reasoning changed nothing observable, which means the reasoning was wrong somewhere I cannot see from here. This writes the numbers into the log rather than a fleeting message, so they can be read after the fact: the window height, the visible height, and - the one that settles it - what the scroll box shows against what it contains. Equal, and there is nothing to scroll, which would be the whole problem. Temporary. It comes out once the cause is known.
960 lines
41 KiB
HTML
960 lines
41 KiB
HTML
<!doctype html>
|
|
<!--
|
|
Interface du client Android. Liste, recherche, dossiers, detail.
|
|
|
|
ASCII UNIQUEMENT dans ce fichier. FMX charge la page par
|
|
loadDataWithBaseURL en laissant l'encodage a nil : le moteur lit alors de
|
|
l'UTF-8 comme du latin-1. Tout caractere hors ASCII doit s'ecrire en
|
|
reference numerique (م). Les DONNEES, elles, n'ont pas ce probleme :
|
|
elles arrivent par evaluateJavascript, qui passe par une chaine JNI.
|
|
|
|
CE QUI VIT ICI ET CE QUI N'Y VIT PAS. La liste recoit les noms, sites et
|
|
identifiants - ce qui est deja a l'ecran. Elle ne recoit AUCUN mot de passe :
|
|
copier depuis une ligne envoie un indice, et Delphi copie depuis le coffre.
|
|
Seule l'entree ouverte a ses valeurs ici, une a la fois, effacees a la
|
|
fermeture et au passage en arriere-plan.
|
|
-->
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
<title>Vault</title>
|
|
<style>
|
|
:root {
|
|
--bg-top: #3E6394;
|
|
--bg-bot: #1B3563;
|
|
--surface: rgba(255,255,255,.12);
|
|
--raised: rgba(255,255,255,.18);
|
|
--accent: rgba(255,255,255,.30);
|
|
--text: #fff;
|
|
--muted: rgba(232,240,250,.70);
|
|
--radius: 20px;
|
|
--radius-s: 14px;
|
|
}
|
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
|
html, body {
|
|
/* CLOUE AU CADRE. overflow:hidden ne suffit pas dans un WebView Android :
|
|
un glissement qui ne trouve rien a faire defiler fait quand meme
|
|
glisser le document entier, et on voit alors le degrade du corps passer
|
|
sous celui de l'ecran - deux degrades empiles, sans le moindre contenu.
|
|
position:fixed lui retire cette liberte. */
|
|
position: fixed; inset: 0;
|
|
margin: 0; overflow: hidden;
|
|
overscroll-behavior: none;
|
|
background: var(--bg-bot);
|
|
color: var(--text);
|
|
font: 15px/1.4 system-ui, "Roboto", sans-serif;
|
|
/* Le texte de l'interface ne se selectionne pas - un appui maintenu sur
|
|
une ligne doit ouvrir l'entree, pas armer une poignee de selection.
|
|
Les valeurs du detail font exception, plus bas. */
|
|
-webkit-user-select: none; user-select: none;
|
|
}
|
|
|
|
/* --- ecran : les deux vues glissent l'une sur l'autre ------------------ */
|
|
.screen {
|
|
position: absolute; inset: 0;
|
|
display: flex; flex-direction: column;
|
|
transition: transform .22s ease;
|
|
/* CHAQUE ECRAN PORTE SON FOND. Sans cela ils sont transparents, celui du
|
|
corps se voit a travers les deux, et la liste reste lisible sous le
|
|
detail - dont les blocs sont eux-memes du blanc translucide. */
|
|
background: linear-gradient(var(--bg-top), var(--bg-bot));
|
|
}
|
|
/* Trois vues sur un rail. Le deverrouillage est a gauche, la liste au
|
|
milieu, le detail a droite ; deux classes sur le corps disent ou on est. */
|
|
#list, #detail { transform: translateX(100%); }
|
|
body.on-list #unlock { transform: translateX(-100%); }
|
|
body.on-list #list { transform: translateX(0); }
|
|
body.on-detail #unlock { transform: translateX(-100%); }
|
|
body.on-detail #list { transform: translateX(-22%); }
|
|
body.on-detail #detail { transform: translateX(0); }
|
|
|
|
/* LE CLAVIER RECOUVRE LA PAGE, IL NE LA RETRECIT PAS. Le cadre est cloue a
|
|
l'ecran entier, donc quand le clavier monte le contenu ne deborde de rien
|
|
et le tiers du bas devient inatteignable : le champ mot de passe du
|
|
formulaire ne se voyait qu'en repliant le clavier. Reserver dessous la
|
|
hauteur qu'il occupe rend au panneau de quoi defiler. */
|
|
.scroll { flex: 1; overflow-y: auto; overflow-x: hidden;
|
|
-webkit-overflow-scrolling: touch;
|
|
overscroll-behavior: contain;
|
|
padding: 0 12px calc(16px + var(--kb, 0px)); }
|
|
|
|
/* --- barre de recherche ------------------------------------------------ */
|
|
.top { padding: 10px 12px 6px; display: flex; gap: 8px; }
|
|
.search { flex: 1; position: relative; }
|
|
.search input {
|
|
width: 100%; height: 46px; padding: 0 38px 0 40px;
|
|
border: 0; border-radius: var(--radius-s);
|
|
background: rgba(255,255,255,.10); color: var(--text);
|
|
font-size: 15px; outline: none;
|
|
}
|
|
.search input::placeholder { color: var(--muted); }
|
|
.glass, .clear {
|
|
position: absolute; top: 0; height: 46px; width: 38px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--muted); font-size: 17px;
|
|
}
|
|
.glass { left: 2px; }
|
|
.glass svg { width: 19px; height: 19px; }
|
|
.clear { right: 2px; display: none; font-size: 20px; }
|
|
body.searching .glass { display: none; }
|
|
body.searching .clear { display: flex; }
|
|
|
|
/* --- dossiers : une rangee de pastilles, pas un panneau ---------------- */
|
|
.folders {
|
|
display: flex; gap: 8px; overflow-x: auto; padding: 4px 12px 8px;
|
|
scrollbar-width: none;
|
|
}
|
|
.folders::-webkit-scrollbar { display: none; }
|
|
.chip {
|
|
flex: 0 0 auto; padding: 8px 14px; border-radius: 999px;
|
|
background: rgba(255,255,255,.10); color: var(--muted);
|
|
font-size: 13px; white-space: nowrap;
|
|
}
|
|
.chip.on { background: var(--accent); color: var(--text); font-weight: 500; }
|
|
.chip .n { opacity: .6; margin-left: 6px; }
|
|
|
|
/* --- lignes ------------------------------------------------------------ */
|
|
.row {
|
|
display: flex; align-items: center; gap: 10px;
|
|
background: var(--surface); border-radius: var(--radius);
|
|
padding: 10px 10px 10px 12px; margin-bottom: 8px;
|
|
}
|
|
.row:active { background: var(--raised); }
|
|
.av {
|
|
flex: 0 0 40px; height: 40px; border-radius: 20px;
|
|
background: rgba(255,255,255,.18);
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-weight: 600; font-size: 16px; overflow: hidden;
|
|
}
|
|
/* PAS DE PASTILLE SOUS UNE ICONE. Le rond translucide sert de fond aux
|
|
initiales ; une favicon a son propre fond transparent, et le rond se
|
|
voyait donc au travers et autour d'elle - une soucoupe sous le dessin.
|
|
`contain` plutot que `cover` : une icone n'est pas une photo, on ne la
|
|
recadre pas. */
|
|
.av.has-img { background: none; }
|
|
.av img { width: 100%; height: 100%; object-fit: contain; }
|
|
.txt { flex: 1; min-width: 0; }
|
|
.txt b { display: block; font-weight: 500; overflow: hidden;
|
|
text-overflow: ellipsis; white-space: nowrap; }
|
|
.txt span { display: block; color: var(--muted); font-size: 13px;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.btn {
|
|
flex: 0 0 auto; border: 0; border-radius: var(--radius-s);
|
|
background: var(--raised); color: var(--text);
|
|
padding: 11px 13px; font-size: 13px; font-family: inherit;
|
|
}
|
|
.btn:active { opacity: .62; }
|
|
.btn.wide { padding: 13px 18px; font-size: 15px; }
|
|
.btn.main { background: var(--accent); }
|
|
|
|
.empty { text-align: center; color: var(--muted); padding: 48px 16px; }
|
|
|
|
/* --- boutons a icone ---------------------------------------------------- */
|
|
/* Une icone seule ne dit ce qu'elle fait que si elle est archi-connue. Le
|
|
cadenas l'est ; c'est pourquoi il est le seul a se passer d'intitule. */
|
|
.ico {
|
|
flex: 0 0 46px; width: 46px; height: 46px; border: 0; padding: 0;
|
|
border-radius: var(--radius-s); background: rgba(255,255,255,.10);
|
|
color: var(--text); display: flex; align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.ico:active { opacity: .62; }
|
|
.ico svg { width: 21px; height: 21px; }
|
|
|
|
/* --- detail ------------------------------------------------------------ */
|
|
.head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; }
|
|
.head h1 { flex: 1; margin: 0; font-size: 19px; font-weight: 600;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.field {
|
|
background: var(--surface); border-radius: var(--radius);
|
|
padding: 10px 10px 12px 14px; margin-bottom: 8px;
|
|
}
|
|
.field .cap { color: var(--muted); font-size: 12px; margin-bottom: 4px; }
|
|
.field .line { display: flex; align-items: center; gap: 8px; }
|
|
.field .val {
|
|
flex: 1; min-width: 0; word-break: break-all;
|
|
-webkit-user-select: text; user-select: text;
|
|
}
|
|
.field .val.mask { letter-spacing: 2px; }
|
|
.field.totp .val { font-size: 30px; font-weight: 300; letter-spacing: 2px; }
|
|
.bar { height: 3px; border-radius: 2px; background: rgba(255,255,255,.18);
|
|
margin-top: 10px; overflow: hidden; }
|
|
.bar i { display: block; height: 100%; background: var(--text);
|
|
transition: width 1s linear; }
|
|
|
|
/* --- deverrouillage ----------------------------------------------------- */
|
|
.status { color: var(--muted); margin: 22px 4px 14px; min-height: 20px; }
|
|
.stack { display: flex; flex-direction: column; gap: 8px; }
|
|
.pair { display: flex; gap: 8px; }
|
|
.pair .btn { flex: 1; }
|
|
.field-in {
|
|
width: 100%; height: 46px; padding: 0 14px; margin-top: 8px;
|
|
border: 0; border-radius: var(--radius-s);
|
|
background: rgba(255,255,255,.10); color: var(--text);
|
|
font: inherit; outline: none;
|
|
}
|
|
.field-in::placeholder { color: var(--muted); }
|
|
/* La fleche dit si le panneau est deplie, et de quel bouton il descend. */
|
|
.chev { display: inline-block; margin-left: 6px; font-size: 11px;
|
|
opacity: .7; transition: transform .18s ease; }
|
|
.btn.open .chev { transform: rotate(180deg); }
|
|
/* Un panneau est visiblement ATTACHE au bouton qui l'ouvre : sans ce trait
|
|
et ce retrait, les reglages et le journal se ressemblaient trop pour
|
|
qu'on sache lequel etait deplie. */
|
|
.panel { margin-top: 8px; padding-left: 10px;
|
|
border-left: 2px solid rgba(255,255,255,.18); }
|
|
|
|
.in-wrap { position: relative; }
|
|
/* Le champ laisse la place a la croix, sinon le texte passe dessous. */
|
|
.in-wrap .field-in { padding-right: 44px; }
|
|
.in-x {
|
|
position: absolute; right: 0; top: 8px; width: 44px; height: 46px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--muted); font-size: 22px;
|
|
}
|
|
.in-x:active { opacity: .5; }
|
|
#log {
|
|
margin: 10px 0 0; padding: 10px 12px; max-height: 34vh; overflow: auto;
|
|
background: rgba(0,0,0,.20); border-radius: var(--radius-s);
|
|
font: 11px/1.45 monospace; white-space: pre-wrap; word-break: break-all;
|
|
-webkit-user-select: text; user-select: text;
|
|
}
|
|
[hidden] { display: none !important; }
|
|
|
|
.toast {
|
|
position: fixed; left: 12px; right: 12px; bottom: 14px;
|
|
background: rgba(10,20,40,.92); color: var(--text);
|
|
border-radius: var(--radius-s); padding: 12px 16px; font-size: 14px;
|
|
text-align: center; opacity: 0; transition: opacity .2s;
|
|
pointer-events: none;
|
|
}
|
|
.toast.on { opacity: 1; }
|
|
</style>
|
|
|
|
<div class="screen" id="unlock">
|
|
<div class="scroll">
|
|
<div class="status" id="status"></div>
|
|
<!-- SE CONNECTER, comme sur le poste. Le mot de passe maitre ouvre le
|
|
conteneur local - c'est le dechiffrement lui-meme qui verifie, aucun
|
|
secret n'est compare - et le nom dit a qui appartient ce coffre.
|
|
Cache des qu'un cache existe : redemander six secondes de derivation
|
|
a chaque lancement alors que le cache est la n'apporterait rien, et
|
|
c'est le code de deverrouillage qui garde cette porte-la. -->
|
|
<div id="authbox" class="stack" hidden>
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="user" placeholder="username"
|
|
autocomplete="off" autocorrect="off" autocapitalize="none"
|
|
spellcheck="false">
|
|
<div class="in-x" data-clear="user">×</div>
|
|
</div>
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="pass" placeholder="master password"
|
|
type="password" autocomplete="off">
|
|
<div class="in-x" data-clear="pass">×</div>
|
|
</div>
|
|
<button class="btn wide main" id="btnAuth">Sign in</button>
|
|
</div>
|
|
|
|
<div class="stack">
|
|
<button class="btn wide main" id="btnOpen">➜ Open vault</button>
|
|
<button class="btn wide" id="btnSync">↻ Sync</button>
|
|
<div class="pair">
|
|
<button class="btn" id="btnCfg" data-panel="cfg">⚙ Server
|
|
settings<span class="chev">▾</span></button>
|
|
<button class="btn" id="btnLog" data-panel="log">≡
|
|
Log<span class="chev">▾</span></button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- LE CODE EST ICI, PAS DANS LES REGLAGES DU SERVEUR. Il ne concerne que
|
|
cet appareil - il scelle le cache local - alors que les trois autres
|
|
champs decrivent le depot distant, partage entre les appareils. Range
|
|
parmi eux, rien n'indiquait qu'il fallait le retaper, et un coffre
|
|
scelle proposait de se retelecharger plutot que de se laisser ouvrir.
|
|
JAMAIS PRE-REMPLI : le retenir reviendrait a ne rien proteger. -->
|
|
<div id="pinbox">
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="pin" placeholder="unlock code"
|
|
type="password" inputmode="numeric" autocomplete="off">
|
|
<div class="in-x" data-clear="pin">×</div>
|
|
</div>
|
|
</div>
|
|
<!-- UNE CROIX PAR CHAMP. Vider par la touche retour demande une quarantaine
|
|
d'appuis sur un jeton, et laisse facilement un caractere - ce qui donne
|
|
un 401 dont la cause est invisible. La croix est UN FRERE du champ,
|
|
posee par-dessus, jamais un enfant : un enfant d'input ne recoit pas
|
|
l'appui.
|
|
L'indication d'aide sert de libelle, et le jeton comme le mot de passe
|
|
sont masques - le placeholder reste donc le seul moyen de les
|
|
distinguer quand ils sont vides. -->
|
|
<div id="cfg" class="panel" hidden>
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="url" placeholder="endpoint URL"
|
|
autocomplete="off" autocorrect="off" autocapitalize="none"
|
|
spellcheck="false" inputmode="url">
|
|
<div class="in-x" data-clear="url">×</div>
|
|
</div>
|
|
<!-- MASQUE, comme le mot de passe juste en dessous. Il s'affichait en
|
|
clair alors qu'il ouvre le depot : le moins grave des trois secrets
|
|
de cet ecran, mais le seul qu'un regard par-dessus l'epaule
|
|
attrapait. On le saisit une fois ; le relire n'a aucun usage. -->
|
|
<div class="in-wrap">
|
|
<!-- NEUTRE : c'est l'URL qui decide de la nature du jeton, pas ce
|
|
champ. Une adresse d'API Gitea en fait un jeton d'acces personnel
|
|
envoye en "token" ; l'endpoint PHP en fait un "Bearer". Le
|
|
nommer d'apres un seul des deux le rend faux pour l'autre. -->
|
|
<input class="field-in" id="tok" placeholder="access token"
|
|
type="password" autocomplete="off">
|
|
<div class="in-x" data-clear="tok">×</div>
|
|
</div>
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="pwd" placeholder="sync password"
|
|
type="password" autocomplete="off">
|
|
<div class="in-x" data-clear="pwd">×</div>
|
|
</div>
|
|
</div>
|
|
<pre id="log" class="panel" hidden></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="screen" id="list">
|
|
<div class="top">
|
|
<div class="search">
|
|
<div class="glass">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round">
|
|
<circle cx="11" cy="11" r="6.5" /><path d="M16 16l4.5 4.5" />
|
|
</svg>
|
|
</div>
|
|
<input id="q" placeholder="Search" autocomplete="off" autocorrect="off"
|
|
autocapitalize="none" spellcheck="false">
|
|
<div class="clear" id="qx">×</div>
|
|
</div>
|
|
<button class="ico" id="new" aria-label="New entry">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round">
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
</button>
|
|
<button class="ico" id="lock" aria-label="Lock vault">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round">
|
|
<path d="M7.5 10.5V7a4.5 4.5 0 0 1 9 0v3.5" />
|
|
<rect x="4" y="10.5" width="16" height="10.5" rx="3" fill="currentColor"
|
|
stroke="none" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="folders" id="folders"></div>
|
|
<div class="scroll"><div id="rows"></div></div>
|
|
</div>
|
|
|
|
<div class="screen" id="detail">
|
|
<div class="head">
|
|
<button class="btn" id="back">←</button>
|
|
<h1 id="dtitle"></h1>
|
|
</div>
|
|
<div class="scroll"><div id="fields"></div></div>
|
|
</div>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
"use strict";
|
|
|
|
// --- pont vers Delphi ------------------------------------------------------
|
|
// PAS location.href, UNE IFRAME JETABLE. FMX ecoute bien la navigation, mais
|
|
// il rend toujours "je ne la prends pas en charge" au moteur, qui essaie alors
|
|
// de charger cmd:// lui-meme, echoue sur un schema inconnu et remplace la page
|
|
// par son ecran d'erreur. Dans une iframe, l'echec reste confine et la page
|
|
// vit. C'est le prix a payer pour un pont sans interface Java.
|
|
//
|
|
// Et les commandes partent UNE PAR UNE : deux navigations dans le meme tour de
|
|
// boucle se fondent en une seule, et la premiere disparait - sur une copie,
|
|
// cela donnerait un presse-papiers vide sans rien signaler.
|
|
var queue = [], sending = false;
|
|
function send(cmd) {
|
|
queue.push(cmd);
|
|
if (!sending) drain();
|
|
}
|
|
function drain() {
|
|
if (!queue.length) { sending = false; return; }
|
|
sending = true;
|
|
var f = document.createElement('iframe');
|
|
f.style.display = 'none';
|
|
f.src = 'cmd://' + queue.shift();
|
|
document.body.appendChild(f);
|
|
setTimeout(function () {
|
|
// LE try/finally EST L'INTERET DE CE BLOC. Sans lui, une exception au
|
|
// retrait de l'iframe laisse "sending" a vrai pour toujours : plus une
|
|
// seule commande ne repart, et l'interface devient muette sans rien
|
|
// signaler. Le meme piege existait cote Delphi sur l'indicateur d'occupation.
|
|
try {
|
|
if (f.parentNode) f.parentNode.removeChild(f);
|
|
} finally {
|
|
drain();
|
|
}
|
|
}, 60);
|
|
}
|
|
|
|
var entries = [], folders = [], folder = '', query = '', view = 'unlock';
|
|
|
|
// --- deverrouillage --------------------------------------------------------
|
|
var elUrl = document.getElementById('url'),
|
|
elTok = document.getElementById('tok'),
|
|
elPwd = document.getElementById('pwd'),
|
|
elPin = document.getElementById('pin');
|
|
|
|
// Les trois valeurs remontent A LA PERTE DU FOCUS, pas a chaque touche : c'est
|
|
// une navigation par champ modifie au lieu d'une par caractere. Delphi les
|
|
// garde, la page n'est qu'un editeur.
|
|
function pushConfig() {
|
|
send('cfg?u=' + encodeURIComponent(elUrl.value.trim())
|
|
+ '&t=' + encodeURIComponent(elTok.value.trim())
|
|
+ '&p=' + encodeURIComponent(elPwd.value)
|
|
+ '&c=' + encodeURIComponent(elPin.value));
|
|
}
|
|
[elUrl, elTok, elPwd, elPin].forEach(function (el) {
|
|
el.addEventListener('change', pushConfig);
|
|
el.addEventListener('blur', pushConfig);
|
|
});
|
|
|
|
// Le meme bouton dans les deux sens : creer un coffre et en ouvrir un ne
|
|
// different que par ce qui existe deja sur l'appareil, et Delphi le sait.
|
|
var authMode = 'open';
|
|
document.getElementById('btnAuth').addEventListener('click', function () {
|
|
var u = document.getElementById('user').value.trim();
|
|
var p = document.getElementById('pass').value;
|
|
// Le mot de passe part, puis le champ se vide : il n'a plus rien a faire
|
|
// dans le document une fois la commande envoyee.
|
|
send((authMode === 'register' ? 'register' : 'login')
|
|
+ '?u=' + encodeURIComponent(u) + '&p=' + encodeURIComponent(p));
|
|
document.getElementById('pass').value = '';
|
|
});
|
|
|
|
document.getElementById('btnOpen').addEventListener('click', function () {
|
|
pushConfig(); // le code vient peut-etre d'etre tape
|
|
send('openvault');
|
|
});
|
|
document.getElementById('btnSync').addEventListener('click', function () {
|
|
pushConfig(); // ce qui est tape mais pas encore quitte compte aussi
|
|
send('sync');
|
|
});
|
|
// Un seul ecouteur pour les quatre croix : elles ne different que par leur
|
|
// cible. Pose sur l'ECRAN, pas sur le groupe des reglages - le code a son
|
|
// propre bloc depuis qu'il n'appartient plus au serveur, et sa croix serait
|
|
// restee sans effet.
|
|
document.getElementById('unlock').addEventListener('click', function (ev) {
|
|
var id = ev.target.getAttribute('data-clear');
|
|
if (!id) return;
|
|
var el = document.getElementById(id);
|
|
el.value = '';
|
|
el.focus();
|
|
// Delphi doit l'apprendre tout de suite : le champ ne perdra pas le focus,
|
|
// donc l'evenement qui remonte d'habitude la configuration n'aura pas lieu.
|
|
pushConfig();
|
|
});
|
|
|
|
// UN SEUL PANNEAU DEPLIE A LA FOIS. Les deux s'ouvraient l'un sous l'autre,
|
|
// dans la meme colonne et avec le meme fond : le journal se lisait comme la
|
|
// suite des reglages.
|
|
function togglePanel(name) {
|
|
['cfg', 'log'].forEach(function (n) {
|
|
var open = (n === name) && document.getElementById(n).hidden;
|
|
document.getElementById(n).hidden = !open;
|
|
document.querySelector('[data-panel="' + n + '"]').classList.toggle('open', open);
|
|
});
|
|
}
|
|
document.getElementById('btnCfg').addEventListener('click', function () {
|
|
togglePanel('cfg');
|
|
});
|
|
document.getElementById('btnLog').addEventListener('click', function () {
|
|
togglePanel('log');
|
|
});
|
|
|
|
function esc(s) {
|
|
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];
|
|
});
|
|
}
|
|
|
|
// --- liste -----------------------------------------------------------------
|
|
function matches(e) {
|
|
if (folder && e.folder !== folder) return false;
|
|
if (!query) return true;
|
|
return (e.name + ' ' + e.sub + ' ' + (e.tags || '')).toLowerCase()
|
|
.indexOf(query) >= 0;
|
|
}
|
|
|
|
function renderFolders() {
|
|
var counts = {}, total = 0, i;
|
|
for (i = 0; i < entries.length; i++) {
|
|
counts[entries[i].folder] = (counts[entries[i].folder] || 0) + 1;
|
|
total++;
|
|
}
|
|
var html = '<div class="chip' + (folder ? '' : ' on') + '" data-f="">All'
|
|
+ '<span class="n">' + total + '</span></div>';
|
|
for (i = 0; i < folders.length; i++) {
|
|
var f = folders[i], n = counts[f] || 0;
|
|
if (!n) continue; // un dossier vide n'a rien a offrir
|
|
html += '<div class="chip' + (folder === f ? ' on' : '') + '" data-f="'
|
|
+ esc(f) + '">' + esc(f) + '<span class="n">' + n + '</span></div>';
|
|
}
|
|
document.getElementById('folders').innerHTML = html;
|
|
}
|
|
|
|
function renderRows() {
|
|
var html = '', shown = 0;
|
|
for (var i = 0; i < entries.length; i++) {
|
|
var e = entries[i];
|
|
if (!matches(e)) continue;
|
|
shown++;
|
|
html += '<div class="row" data-i="' + e.i + '">'
|
|
+ '<div class="av' + (e.icon ? ' has-img' : '') + '">'
|
|
+ (e.icon ? '<img src="' + esc(e.icon) + '">' : esc(e.initial))
|
|
+ '</div>'
|
|
+ '<div class="txt"><b>' + esc(e.name) + '</b>'
|
|
+ (e.sub ? '<span>' + esc(e.sub) + '</span>' : '') + '</div>'
|
|
+ (e.hasUser ? '<button class="btn" data-copy="user">User</button>' : '')
|
|
+ '<button class="btn" data-copy="pwd">' + (e.isNote ? 'Text' : 'Pass')
|
|
+ '</button></div>';
|
|
}
|
|
document.getElementById('rows').innerHTML = shown ? html
|
|
: '<div class="empty">Nothing here</div>';
|
|
}
|
|
|
|
// Un seul ecouteur pour toute la liste : les lignes sont refaites a chaque
|
|
// frappe, en attacher un par bouton les recreerait autant de fois.
|
|
document.getElementById('rows').addEventListener('click', function (ev) {
|
|
var row = ev.target.closest('.row');
|
|
if (!row) return;
|
|
var copy = ev.target.getAttribute('data-copy');
|
|
if (copy) send('copy?i=' + row.getAttribute('data-i') + '&f=' + copy);
|
|
else send('open?i=' + row.getAttribute('data-i'));
|
|
});
|
|
|
|
document.getElementById('folders').addEventListener('click', function (ev) {
|
|
var chip = ev.target.closest('.chip');
|
|
if (!chip) return;
|
|
folder = chip.getAttribute('data-f');
|
|
renderFolders();
|
|
renderRows();
|
|
send('touch');
|
|
});
|
|
|
|
var q = document.getElementById('q');
|
|
q.addEventListener('input', function () {
|
|
query = q.value.trim().toLowerCase();
|
|
document.body.classList.toggle('searching', q.value !== '');
|
|
renderRows();
|
|
send('touch');
|
|
});
|
|
document.getElementById('lock').addEventListener('click', function () {
|
|
send('lock');
|
|
});
|
|
document.getElementById('qx').addEventListener('click', function () {
|
|
q.value = ''; query = '';
|
|
document.body.classList.remove('searching');
|
|
renderRows();
|
|
});
|
|
|
|
// --- detail ----------------------------------------------------------------
|
|
var detailRows = [];
|
|
// Le dernier detail recu, pour redessiner la ligne apres une annulation sans
|
|
// avoir a le redemander a Delphi.
|
|
var lastDetail = null;
|
|
|
|
function renderDetail(d) {
|
|
detailRows = d.rows || [];
|
|
document.getElementById('dtitle').textContent = d.title || '';
|
|
var html = '', i;
|
|
if (d.totp)
|
|
html += '<div class="field totp" id="totp"><div class="cap">2FA code</div>'
|
|
+ '<div class="line"><div class="val" id="totpv">------</div>'
|
|
+ '<button class="btn" data-copy="totp">Copy</button></div>'
|
|
+ '<div class="bar"><i id="totpb"></i></div></div>';
|
|
for (i = 0; i < detailRows.length; i++) {
|
|
var r = detailRows[i];
|
|
html += '<div class="field"><div class="cap">' + esc(r.c) + '</div>'
|
|
+ '<div class="line"><div class="val' + (r.s ? ' mask' : '')
|
|
+ '" data-v="' + i + '">'
|
|
+ (r.s ? '•'.repeat(Math.min(r.v.length, 14)) : esc(r.v))
|
|
+ '</div>'
|
|
+ (r.s ? '<button class="btn" data-show="' + i + '">Show</button>' : '')
|
|
+ '<button class="btn" data-copy="row" data-k="' + i + '">Copy</button>'
|
|
+ (r.e ? '<button class="btn" data-edit="' + i + '">Edit</button>' : '')
|
|
+ '</div></div>';
|
|
}
|
|
document.getElementById('fields').innerHTML = html;
|
|
document.body.classList.add('on-detail');
|
|
view = 'detail';
|
|
send('state?view=detail');
|
|
}
|
|
|
|
document.getElementById('fields').addEventListener('click', function (ev) {
|
|
var t = ev.target, k = t.getAttribute('data-show');
|
|
if (k !== null) {
|
|
var cell = document.querySelector('[data-v="' + k + '"]');
|
|
var hidden = cell.classList.contains('mask');
|
|
cell.classList.toggle('mask', !hidden);
|
|
cell.textContent = hidden ? detailRows[k].v
|
|
: '\u2022'.repeat(Math.min(detailRows[k].v.length, 14));
|
|
t.textContent = hidden ? 'Hide' : 'Show';
|
|
send('touch');
|
|
return;
|
|
}
|
|
var copy = t.getAttribute('data-copy');
|
|
if (copy === 'totp') { send('copy?f=totp'); return; }
|
|
if (copy === 'row') { send('copy?f=row&k=' + t.getAttribute('data-k')); return; }
|
|
|
|
var edit = t.getAttribute('data-edit');
|
|
if (edit !== null) { openEditor(edit); return; }
|
|
if (t.getAttribute('data-editshow') !== null) {
|
|
// Masque par defaut, revelable a la demande : on tape un mot de passe
|
|
// qu'on ne peut relire nulle part ailleurs, mais il n'a pas a rester
|
|
// expose a l'ecran pendant qu'on cherche le bouton.
|
|
var iv = document.getElementById('editval');
|
|
var hid = iv.type === 'password';
|
|
iv.type = hid ? 'text' : 'password';
|
|
t.textContent = hid ? 'Hide' : 'Show';
|
|
return;
|
|
}
|
|
if (t.getAttribute('data-editcancel') !== null) { renderDetail(lastDetail); return; }
|
|
if (t.getAttribute('data-editsave') !== null) {
|
|
var input = document.getElementById('editval');
|
|
if (!input || !input.value) return; // vide : on n'ecrase pas par megarde
|
|
// DIRE QUE CA TRAVAILLE. L'enregistrement tire, dechiffre, rechiffre et
|
|
// publie : plusieurs secondes pendant lesquelles rien ne bougeait, et le
|
|
// bouton semblait n'avoir pas pris l'appui.
|
|
t.textContent = 'Saving...';
|
|
t.disabled = true;
|
|
var cancel = document.querySelector('[data-editcancel]');
|
|
if (cancel) cancel.disabled = true;
|
|
input.disabled = true;
|
|
send('setpwd?v=' + encodeURIComponent(input.value));
|
|
return;
|
|
}
|
|
});
|
|
|
|
// EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au
|
|
// moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors
|
|
// null sans un mot - le bouton semblerait ne rien faire.
|
|
//
|
|
// La valeur s'affiche EN CLAIR pendant la saisie. On tape un mot de passe
|
|
// qu'on ne peut pas relire ailleurs ; le masquer ferait valider a l'aveugle
|
|
// une frappe qu'aucune confirmation ne rattrape.
|
|
function openEditor(k) {
|
|
var row = document.querySelector('[data-v="' + k + '"]');
|
|
if (!row) return;
|
|
var line = row.parentNode;
|
|
line.innerHTML =
|
|
'<input class="field-in" id="editval" type="password" autocomplete="off"'
|
|
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"'
|
|
+ ' style="margin:0;flex:1;min-width:0;width:auto">'
|
|
+ '<button class="btn" data-editshow>Show</button>'
|
|
+ '<button class="btn" data-editcancel>Cancel</button>'
|
|
+ '<button class="btn main" data-editsave>Save</button>';
|
|
var input = document.getElementById('editval');
|
|
input.value = detailRows[k].v;
|
|
input.focus();
|
|
send('touch');
|
|
}
|
|
|
|
// RIEN NE GLISSE DE COTE, sauf la bande de dossiers.
|
|
//
|
|
// overflow:hidden n'interdit que le GESTE. Le moteur, lui, garde le droit de
|
|
// deplacer une boite pour amener a l'ecran le champ qui vient de prendre le
|
|
// focus, et les ecrans hors-champ du rail - poses a translateX(100%) - lui en
|
|
// laissent la place a droite. La touche de validation du clavier suffisait
|
|
// alors a pousser tout le panneau de cote, sans aucun moyen de le ramener
|
|
// puisque le geste, lui, reste interdit.
|
|
//
|
|
// On l'attrape en phase de capture, donc quelle que soit la boite qui bouge et
|
|
// quelle qu'en soit la cause : ici, aucune vue n'a de contenu a droite.
|
|
addEventListener('scroll', function (ev) {
|
|
var box = ev.target === document ? document.scrollingElement : ev.target;
|
|
if (box && box.scrollLeft && box.id !== 'folders') box.scrollLeft = 0;
|
|
}, true);
|
|
|
|
// LE CLAVIER RECOUVRE LA PAGE, IL NE LA RETRECIT PAS.
|
|
//
|
|
// Le cadre est cloue a l'ecran entier (position:fixed, inset:0) et le moteur ne
|
|
// le redimensionne pas quand le clavier monte : la page garde sa hauteur, le
|
|
// clavier passe par-dessus, et le tiers du bas devient inatteignable. Rien ne
|
|
// depassait, donc rien ne defilait - le champ mot de passe du formulaire ne se
|
|
// voyait qu'en repliant le clavier.
|
|
//
|
|
// visualViewport donne la hauteur REELLEMENT visible. La reporter sur le corps
|
|
// retrecit les vues, le panneau defilant retrouve du contenu en trop, et le
|
|
// moteur peut de nouveau amener a l'ecran le champ qui prend le focus.
|
|
if (window.visualViewport) {
|
|
var vv = window.visualViewport;
|
|
// Ce que le clavier RECOUVRE, en pixels. Zero quand il est replie - et zero
|
|
// aussi si le moteur retrecit reellement la fenetre, auquel cas le panneau
|
|
// deborde tout seul et n'a besoin de rien. La meme mesure repond donc juste
|
|
// dans les deux cas, sans qu'on ait a savoir lequel s'applique.
|
|
var measureKeyboard = function () {
|
|
var hidden = Math.max(0, Math.round(window.innerHeight - vv.height));
|
|
document.documentElement.style.setProperty('--kb', hidden + 'px');
|
|
reportBox('keyboard');
|
|
};
|
|
vv.addEventListener('resize', measureKeyboard);
|
|
measureKeyboard();
|
|
} else {
|
|
window._noVisualViewport = true;
|
|
}
|
|
|
|
// DIAGNOSTIC, temporaire. Deux corrections posees a l'aveugle n'ont rien
|
|
// change ; celle-ci dit ce que la page mesure vraiment, dans le journal plutot
|
|
// qu'en message fugace, pour qu'on puisse la lire apres coup.
|
|
function reportBox(why) {
|
|
// PM n'existe pas encore au chargement : cette fonction tourne aussi avant
|
|
// lui, et on ne va pas casser la page pour une trace.
|
|
// Les trois vues sont TOUTES dans le document - c'est un rail, rien n'est
|
|
// masque - donc la vue active se designe par son nom, pas par sa visibilite.
|
|
if (!window.PM || !PM.log) return;
|
|
var box = document.getElementById(view);
|
|
box = box && box.querySelector('.scroll');
|
|
var vh = window.visualViewport ? Math.round(window.visualViewport.height) : -1;
|
|
var sc = document.body.className || '(none)';
|
|
if (!box) { PM.log(why + ': no scroll box, body=' + sc); return; }
|
|
// clientHeight = ce que la boite montre, scrollHeight = ce qu'elle contient.
|
|
// Egaux, il n'y a rien a faire defiler, et c'est TOUT le probleme.
|
|
PM.log(why + ': inner ' + window.innerHeight + ' visual ' + vh
|
|
+ ' | box client ' + box.clientHeight + ' scroll ' + box.scrollHeight
|
|
+ ' | body=' + sc);
|
|
}
|
|
document.addEventListener('focusin', function () {
|
|
setTimeout(function () { reportBox('focus'); }, 400);
|
|
});
|
|
|
|
// AUCUN CHAMP D'UNE VUE AU REPOS NE GARDE LE FOCUS.
|
|
//
|
|
// Meme origine que le glissement ci-dessus. La touche de validation du clavier
|
|
// demande "champ suivant", et le suivant dans le document est sur une vue
|
|
// rangee hors du cadre. On y atterrissait sans rien voir : le clavier changeait
|
|
// de forme - numerique pour le code, alphabetique pour la recherche - et ne se
|
|
// refermait plus, puisque le champ qui le tenait ouvert etait hors de l'ecran.
|
|
addEventListener('focusin', function (ev) {
|
|
var scr = ev.target.closest && ev.target.closest('.screen');
|
|
if (scr && scr.id !== view) ev.target.blur();
|
|
});
|
|
|
|
// La validation ferme le clavier au lieu de sauter au champ suivant. Dans
|
|
// l'editeur, elle enregistre : c'est ce qu'on attend de cette touche apres
|
|
// avoir tape un mot de passe.
|
|
addEventListener('keydown', function (ev) {
|
|
var el = ev.target;
|
|
if (ev.key !== 'Enter' || !el.classList || !el.classList.contains('field-in'))
|
|
return;
|
|
ev.preventDefault();
|
|
el.blur();
|
|
if (el.id !== 'editval') return;
|
|
var save = document.querySelector('[data-editsave]');
|
|
if (save && !save.disabled) save.click();
|
|
});
|
|
|
|
// NOUVELLE ENTREE - dans l'ecran de DETAIL, pas dans une quatrieme vue.
|
|
//
|
|
// Le rail en porte trois, et chacune coute ses regles de transformation. Le
|
|
// detail a deja tout ce qu'il faut : un titre, un retour, une colonne qui
|
|
// defile. Une entree qu'on cree et une entree qu'on consulte occupent la meme
|
|
// place a l'ecran, et c'est exactement ce que cette vue veut dire.
|
|
var newKind = 'login';
|
|
|
|
function newField(id, label, type) {
|
|
return '<div class="field"><div class="cap">' + label + '</div>'
|
|
+ '<div class="line"><input class="field-in" id="' + id + '" type="'
|
|
+ type + '" autocomplete="off" autocorrect="off" autocapitalize="none"'
|
|
+ ' spellcheck="false" style="margin:0;flex:1;min-width:0;width:auto">'
|
|
+ '</div></div>';
|
|
}
|
|
|
|
function openNewEntry() {
|
|
newKind = 'login';
|
|
detailRows = [];
|
|
document.getElementById('dtitle').textContent = 'New entry';
|
|
document.getElementById('fields').innerHTML =
|
|
'<div class="field"><div class="cap">Kind</div><div class="line">'
|
|
+ '<button class="btn main" data-kind="login">Login</button>'
|
|
+ '<button class="btn" data-kind="note">Note</button></div></div>'
|
|
+ newField('nTitle', 'Name', 'text')
|
|
+ newField('nSite', 'Site', 'text')
|
|
+ newField('nUser', 'Username', 'text')
|
|
// EN CLAIR pendant la saisie, comme l'editeur : on tape un mot de passe
|
|
// qu'aucune confirmation ne rattrape et qu'on ne peut relire nulle part.
|
|
+ newField('nPwd', 'Password', 'text')
|
|
+ '<div class="field"><div class="line">'
|
|
// flex:1 et non .wide : .wide ne regle que le rembourrage, et la largeur
|
|
// pleine vient d'etre etire dans son conteneur.
|
|
+ '<button class="btn main wide" id="nSave" style="flex:1">Save</button>'
|
|
+ '</div></div>';
|
|
document.body.classList.remove('on-list');
|
|
document.body.classList.add('on-detail');
|
|
view = 'detail';
|
|
send('state?view=detail');
|
|
}
|
|
|
|
// Un seul ecouteur pour le formulaire : ses boutons naissent et meurent avec
|
|
// lui, et en attacher a chaque ouverture les empilerait.
|
|
document.getElementById('fields').addEventListener('click', function (ev) {
|
|
var kind = ev.target.getAttribute('data-kind');
|
|
if (kind) {
|
|
newKind = kind;
|
|
var all = document.querySelectorAll('[data-kind]');
|
|
for (var i = 0; i < all.length; i++)
|
|
all[i].classList.toggle('main', all[i].getAttribute('data-kind') === kind);
|
|
// Le corps d'une note EST son mot de passe cote stockage : meme champ,
|
|
// autre intitule, et rien a masquer.
|
|
var caps = document.querySelectorAll('#fields .cap');
|
|
for (i = 0; i < caps.length; i++) {
|
|
if (caps[i].textContent === 'Password' || caps[i].textContent === 'Content')
|
|
caps[i].textContent = (kind === 'note') ? 'Content' : 'Password';
|
|
// Une note n'a ni site ni identifiant. Les laisser proposes revient a
|
|
// demander de remplir ce qui ne sera jamais affiche.
|
|
if (caps[i].textContent === 'Site' || caps[i].textContent === 'Username')
|
|
caps[i].parentNode.hidden = (kind === 'note');
|
|
}
|
|
return;
|
|
}
|
|
if (ev.target.id !== 'nSave') return;
|
|
var v = function (id) { return document.getElementById(id).value.trim(); };
|
|
ev.target.disabled = true;
|
|
send('newentry?t=' + encodeURIComponent(v('nTitle'))
|
|
+ '&s=' + encodeURIComponent(v('nSite'))
|
|
+ '&u=' + encodeURIComponent(v('nUser'))
|
|
+ '&p=' + encodeURIComponent(document.getElementById('nPwd').value)
|
|
+ '&k=' + newKind);
|
|
});
|
|
document.getElementById('new').addEventListener('click', openNewEntry);
|
|
|
|
function goList() {
|
|
document.body.classList.remove('on-detail');
|
|
document.body.classList.add('on-list');
|
|
view = 'list';
|
|
// Les valeurs en clair partent avec la vue, pas seulement de l'ecran.
|
|
detailRows = [];
|
|
document.getElementById('fields').innerHTML = '';
|
|
send('state?view=list');
|
|
}
|
|
document.getElementById('back').addEventListener('click', goList);
|
|
|
|
// --- appele par Delphi -----------------------------------------------------
|
|
var PM = {
|
|
setVault: function (json) {
|
|
var d = JSON.parse(json);
|
|
entries = d.entries || [];
|
|
folders = d.folders || [];
|
|
folder = '';
|
|
q.value = ''; query = '';
|
|
document.body.classList.remove('searching', 'on-detail');
|
|
document.body.classList.add('on-list');
|
|
// LE FORMULAIRE PART AVEC LA VUE, pas seulement de l'ecran. Il garde le
|
|
// mot de passe qu'on vient de taper ; le laisser dans le document apres
|
|
// l'enregistrement le laisserait la jusqu'au verrouillage.
|
|
detailRows = [];
|
|
document.getElementById('fields').innerHTML = '';
|
|
view = 'list';
|
|
renderFolders();
|
|
renderRows();
|
|
},
|
|
|
|
/// Sort du mode edition sans rien demander a Delphi. Sert aux refus
|
|
/// immediats : l'editeur y restait ouvert, boutons desactives, et Cancel
|
|
/// lui-meme ne repondait plus.
|
|
editDone: function () {
|
|
// Le formulaire de creation passe par ici aussi : son bouton se desactive
|
|
// a l'envoi, et sans cela un refus le laissait mort avec le texte saisi
|
|
// derriere - a retaper sans savoir pourquoi.
|
|
var save = document.getElementById('nSave');
|
|
if (save) { save.disabled = false; return; }
|
|
if (lastDetail) renderDetail(lastDetail);
|
|
},
|
|
|
|
showUnlock: function () {
|
|
document.body.classList.remove('on-list', 'on-detail', 'searching');
|
|
view = 'unlock';
|
|
},
|
|
setStatus: function (text) {
|
|
document.getElementById('status').textContent = text;
|
|
},
|
|
/// Etat des deux actions : ouvrir n'a de sens qu'avec un coffre local
|
|
/// lisible, et synchroniser s'appelle autrement quand il n'y en a pas.
|
|
setActions: function (canOpen, syncLabel, busy) {
|
|
var o = document.getElementById('btnOpen'), s = document.getElementById('btnSync');
|
|
o.hidden = !canOpen;
|
|
// textContent et non innerHTML : l'intitule vient de Delphi, et une valeur
|
|
// interpretee comme du balisage n'a pas de raison de l'etre.
|
|
s.textContent = '\u21BB ' + syncLabel;
|
|
o.disabled = busy; s.disabled = busy;
|
|
o.style.opacity = busy ? .4 : 1;
|
|
s.style.opacity = busy ? .4 : 1;
|
|
},
|
|
setConfig: function (url, tok, pwd, show) {
|
|
// elPin volontairement absent : rien ne le restaure, jamais.
|
|
elUrl.value = url; elTok.value = tok; elPwd.value = pwd;
|
|
// Replier des champs vides revient a cacher la seule chose a faire.
|
|
if (show !== !document.getElementById('cfg').hidden) togglePanel('cfg');
|
|
},
|
|
|
|
/// Le coffre local est scelle sous un code : il faut le retaper pour
|
|
/// l'ouvrir. Delphi le sait en lisant le premier octet du cache.
|
|
/// TOUJOURS VISIBLE, seule l'indication change. Le cacher quand aucun code
|
|
/// n'est pose supprimerait le seul endroit ou en poser un ; le montrer sans
|
|
/// rien dire laisserait croire qu'il en faut un pour ouvrir. C'est donc le
|
|
/// texte d'aide qui porte la difference.
|
|
/// Dans lequel des trois cas se trouve cet appareil : rien du tout,
|
|
/// un conteneur sans cache, ou un cache pret a s'ouvrir.
|
|
setAuthMode: function (mode) {
|
|
authMode = mode;
|
|
var box = document.getElementById('authbox');
|
|
box.hidden = (mode === 'open');
|
|
document.getElementById('pinbox').hidden = (mode !== 'open');
|
|
document.getElementById('btnAuth').textContent =
|
|
(mode === 'register') ? 'Create vault' : 'Sign in';
|
|
},
|
|
|
|
setPinNeeded: function (on) {
|
|
document.getElementById('pin').placeholder =
|
|
on ? 'unlock code' : 'set an unlock code (optional)';
|
|
},
|
|
focusPin: function () { document.getElementById('pin').focus(); },
|
|
log: function (line) {
|
|
var l = document.getElementById('log');
|
|
l.textContent += line + '\n';
|
|
l.scrollTop = l.scrollHeight;
|
|
},
|
|
clearLog: function () { document.getElementById('log').textContent = ''; },
|
|
setDetail: function (json) { renderDetail(JSON.parse(json)); },
|
|
setTotp: function (code, left, period) {
|
|
var v = document.getElementById('totpv');
|
|
if (!v) return;
|
|
v.textContent = code.length === 6 ? code.slice(0, 3) + ' ' + code.slice(3) : code;
|
|
var b = document.getElementById('totpb');
|
|
// Le retour a plein ne s'anime pas : la transition jouerait le saut a
|
|
// l'envers et donnerait l'impression que la barre se fige.
|
|
b.style.transition = left >= period - 1 ? 'none' : 'width 1s linear';
|
|
b.style.width = (left / period * 100) + '%';
|
|
},
|
|
toast: function (text) {
|
|
var t = document.getElementById('toast');
|
|
t.textContent = text;
|
|
t.classList.add('on');
|
|
clearTimeout(t._h);
|
|
t._h = setTimeout(function () { t.classList.remove('on'); }, 2600);
|
|
},
|
|
back: function () { if (view === 'detail') goList(); },
|
|
// Efface tout ce qui est en clair. Appele au passage en arriere-plan et au
|
|
// verrouillage : le tas JS n'est pas efface par le masquage de la vue.
|
|
wipe: function () {
|
|
entries = []; folders = []; detailRows = [];
|
|
document.getElementById('rows').innerHTML = '';
|
|
document.getElementById('fields').innerHTML = '';
|
|
document.getElementById('folders').innerHTML = '';
|
|
q.value = '';
|
|
document.body.classList.remove('on-list', 'on-detail', 'searching');
|
|
view = 'unlock';
|
|
}
|
|
};
|
|
window.PM = PM;
|
|
</script>
|