feat(android): folder and 2FA secret can be changed, and the buttons wear icons
The folder had no row of its own - it shared "Folder / tags" with the tags, so neither could be touched. It is now CHOSEN from the known folders rather than typed: a typo would file the entry under a folder nothing displays, here or on the desktop, and it would look lost. Tags stay read-only because they are an array in the snapshot, and writing one as a string would break its shape for the desktop. The 2FA secret gets its own masked row. The code has had a panel for a long time, but it is computed and renewed every thirty seconds; the secret is what one sets, changes and removes, and nothing allowed that from the phone. User, Pass, Copy, Show and Edit become drawings. Four words on a list row left little room for the entry's name and had to be re-read every time. Every icon carries an aria-label: replacing a word with a drawing must not take the information away from someone listening to a screen reader.
This commit is contained in:
+87
-12
@@ -157,6 +157,14 @@
|
||||
/* Rouge sourd : assez pour qu'on ne le confonde pas avec Copy, pas assez
|
||||
pour attirer l'oeil sur la seule action irreversible de l'ecran. */
|
||||
.btn.danger { background: rgba(220, 80, 80, .30); }
|
||||
/* Un bouton d'icone est CARRE et etroit : c'est ce qui le distingue d'un
|
||||
bouton de texte au premier coup d'oeil, et ce qui laisse la place au nom
|
||||
de l'entree sur une ligne de liste. La cible reste a 44 px, ce qu'un
|
||||
doigt attend. */
|
||||
.btn.ico-btn { padding: 0; width: 44px; min-width: 44px; height: 44px;
|
||||
display: inline-flex; align-items: center;
|
||||
justify-content: center; }
|
||||
.btn.ico-btn svg { width: 20px; height: 20px; }
|
||||
|
||||
.empty { text-align: center; color: var(--muted); padding: 48px 16px; }
|
||||
|
||||
@@ -539,8 +547,10 @@ function renderRows() {
|
||||
+ '</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')
|
||||
+ (e.hasUser ? '<button class="btn ico-btn" data-copy="user">'
|
||||
+ ico('user', 'Copy username') + '</button>' : '')
|
||||
+ '<button class="btn ico-btn" data-copy="pwd">'
|
||||
+ (e.isNote ? ico('note', 'Copy text') : ico('key', 'Copy password'))
|
||||
+ '</button></div>';
|
||||
}
|
||||
document.getElementById('rows').innerHTML = shown ? html
|
||||
@@ -582,6 +592,37 @@ document.getElementById('qx').addEventListener('click', function () {
|
||||
renderRows();
|
||||
});
|
||||
|
||||
// LES ICONES, dessinees une fois et nommees.
|
||||
//
|
||||
// Elles remplacent "User", "Pass", "Copy", "Edit" : quatre mots sur une ligne
|
||||
// de liste laissaient peu de place au nom de l'entree, et se relisaient a
|
||||
// chaque fois. Un trait suffit a les distinguer.
|
||||
//
|
||||
// aria-label EST OBLIGATOIRE : une icone sans nom n'existe pas pour qui
|
||||
// n'ecoute que le lecteur d'ecran, et remplacer un mot par un dessin ne doit
|
||||
// pas retirer l'information a quelqu'un.
|
||||
var ICONS = {
|
||||
user: '<path d="M12 12a4 4 0 100-8 4 4 0 000 8z"/>'
|
||||
+ '<path d="M4.5 20a7.5 7.5 0 0115 0"/>',
|
||||
key: '<circle cx="8" cy="12" r="3.5"/><path d="M11.5 12H21l-2 2.5"/>'
|
||||
+ '<path d="M17 12v3"/>',
|
||||
note: '<path d="M6 3h9l4 4v14H6z"/><path d="M9 12h7M9 16h5"/>',
|
||||
copy: '<rect x="9" y="9" width="11" height="11" rx="2"/>'
|
||||
+ '<path d="M15 5H6a2 2 0 00-2 2v9"/>',
|
||||
edit: '<path d="M4 20h4L20 8l-4-4L4 16z"/>',
|
||||
eye: '<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/>'
|
||||
+ '<circle cx="12" cy="12" r="3"/>',
|
||||
eyeOff: '<path d="M4 4l16 16"/>'
|
||||
+ '<path d="M9.5 5.4A9.8 9.8 0 0112 5c6.5 0 10 6 10 6a17 17 0 01-3.3 3.7"/>'
|
||||
+ '<path d="M6.3 7.7A17 17 0 002 11s3.5 6 10 6a9.7 9.7 0 003.4-.6"/>'
|
||||
};
|
||||
|
||||
function ico(name, label) {
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"'
|
||||
+ ' stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"'
|
||||
+ ' aria-label="' + label + '" role="img">' + ICONS[name] + '</svg>';
|
||||
}
|
||||
|
||||
// --- detail ----------------------------------------------------------------
|
||||
var detailRows = [];
|
||||
// Le dernier detail recu, pour redessiner la ligne apres une annulation sans
|
||||
@@ -603,7 +644,8 @@ function renderDetail(d) {
|
||||
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>'
|
||||
+ '<button class="btn ico-btn" data-copy="totp">'
|
||||
+ ico('copy', 'Copy code') + '</button></div>'
|
||||
+ '<div class="bar"><i id="totpb"></i></div></div>';
|
||||
for (i = 0; i < detailRows.length; i++) {
|
||||
var r = detailRows[i];
|
||||
@@ -612,9 +654,12 @@ function renderDetail(d) {
|
||||
+ '" 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>' : '')
|
||||
+ (r.s ? '<button class="btn ico-btn" data-show="' + i + '">'
|
||||
+ ico('eye', 'Show') + '</button>' : '')
|
||||
+ '<button class="btn ico-btn" data-copy="row" data-k="' + i + '">'
|
||||
+ ico('copy', 'Copy') + '</button>'
|
||||
+ (r.e ? '<button class="btn ico-btn" data-edit="' + i + '">'
|
||||
+ ico('edit', 'Edit') + '</button>' : '')
|
||||
+ '</div></div>';
|
||||
}
|
||||
// SUPPRIMER, tout en bas et en deux temps. Sans retour possible depuis le
|
||||
@@ -657,7 +702,7 @@ document.getElementById('fields').addEventListener('click', function (ev) {
|
||||
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';
|
||||
t.innerHTML = ico(hidden ? 'eyeOff' : 'eye', hidden ? 'Hide' : 'Show');
|
||||
send('touch');
|
||||
return;
|
||||
}
|
||||
@@ -702,6 +747,40 @@ document.getElementById('fields').addEventListener('click', function (ev) {
|
||||
}
|
||||
});
|
||||
|
||||
/// LE DOSSIER SE CHOISIT, il ne se tape pas.
|
||||
///
|
||||
/// C'est une chaine simple dans l'instantane, mais elle doit correspondre a un
|
||||
/// dossier existant : une faute de frappe rangerait l'entree dans un dossier
|
||||
/// que rien n'affiche, ni ici ni sur le poste - l'entree semblerait disparue.
|
||||
/// La liste des dossiers arrive deja avec le coffre, autant s'en servir.
|
||||
///
|
||||
/// 'All' est ajoute d'office : c'est le dossier par defaut du poste, et il ne
|
||||
/// figure pas toujours dans la liste.
|
||||
function editorControl(k) {
|
||||
var val = detailRows[k].v;
|
||||
var style = ' style="margin:0;flex:1;min-width:0;width:auto"';
|
||||
if (editField !== 'folder')
|
||||
return '<input class="field-in" id="editval" autocomplete="off"'
|
||||
+ ' type="' + (detailRows[k].s ? 'password' : 'text') + '"'
|
||||
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"'
|
||||
+ style + '>';
|
||||
|
||||
var names = ['All'], i;
|
||||
for (i = 0; i < folders.length; i++)
|
||||
if (folders[i] && names.indexOf(folders[i]) < 0) names.push(folders[i]);
|
||||
// Le dossier courant peut ne plus figurer dans la liste - il a ete renomme
|
||||
// ailleurs, ou l'entree vient d'un import. Le proposer quand meme evite de
|
||||
// le remplacer silencieusement en ouvrant simplement l'editeur.
|
||||
if (val && names.indexOf(val) < 0) names.push(val);
|
||||
|
||||
var html = '<select class="field-in" id="editval"' + style + '>';
|
||||
for (i = 0; i < names.length; i++)
|
||||
html += '<option value="' + esc(names[i]) + '"'
|
||||
+ (names[i] === val ? ' selected' : '') + '>' + esc(names[i])
|
||||
+ '</option>';
|
||||
return html + '</select>';
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -719,11 +798,7 @@ function openEditor(k) {
|
||||
editField = detailRows[k].e || '';
|
||||
if (!editField) return;
|
||||
var line = row.parentNode;
|
||||
line.innerHTML =
|
||||
'<input class="field-in" id="editval" autocomplete="off"'
|
||||
+ ' type="' + (detailRows[k].s ? 'password' : 'text') + '"'
|
||||
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"'
|
||||
+ ' style="margin:0;flex:1;min-width:0;width:auto">'
|
||||
line.innerHTML = editorControl(k)
|
||||
// Show n'a de sens que sur un champ masque. Sur un site en clair il
|
||||
// n'aurait rien a reveler - et l'inverse : il le CACHERAIT.
|
||||
+ (detailRows[k].s ? '<button class="btn" data-editshow>Show</button>' : '')
|
||||
|
||||
Reference in New Issue
Block a user