clavarium-binder 0.1.1-next.2 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/binder.mjs +2 -1
- package/docs/protocol.md +8 -0
- package/host/binder-host.mjs +3 -3
- package/package.json +1 -1
- package/server/binder.mjs +3 -3
- package/server/store.mjs +21 -2
- package/ui/binder-ui.css +23 -2
- package/ui/binder-ui.js +139 -37
- package/ui/texts/en.json +17 -4
- package/ui/texts/fr.json +17 -4
package/bin/binder.mjs
CHANGED
|
@@ -17,6 +17,7 @@ export const HELP = `binder: the lists you were given, and their records.
|
|
|
17
17
|
binder search "text" in every list you can see
|
|
18
18
|
binder add LIST "title" [--description "text"] [--state S] [--due DATE] [--owner me] [--note "text"] [--field name=value]
|
|
19
19
|
binder edit ID [--title "t"] [--description "text"] [--due DATE|none] [--owner me|ID|none] [--field name=value]
|
|
20
|
+
[--cover PICTURE|none] the picture the card is shown by: one attached (its ref), or a photo field
|
|
20
21
|
binder move ID STATE past a gate, only what a person approved
|
|
21
22
|
binder note ID "text" a short note: a decision, a fact, a result
|
|
22
23
|
binder attach ID KIND REF ["title"] KIND: message, file, asset, branch, link, record
|
|
@@ -56,7 +57,7 @@ export function read(argv) {
|
|
|
56
57
|
case 'get': return rest[0] ? ['get', { id: rest[0] }] : { refused: 'say which record: binder get ID' };
|
|
57
58
|
case 'search': return rest.length ? ['search', { text: rest.join(' ') }] : { refused: 'say what to look for' };
|
|
58
59
|
case 'add': return rest.length >= 2 ? ['add', { list: rest[0], title: rest.slice(1).join(' '), state: flags.state, due, owners, note: flags.note, description: flags.description, ...has }] : { refused: 'binder add LIST "title"' };
|
|
59
|
-
case 'edit': return rest[0] ? ['edit', { id: rest[0], title: flags.title, description: flags.description, due, owners, ...has }] : { refused: 'say which record: binder edit ID' };
|
|
60
|
+
case 'edit': return rest[0] ? ['edit', { id: rest[0], title: flags.title, description: flags.description, due, owners, ...(flags.cover === undefined ? {} : { cover: flags.cover === 'none' ? '' : flags.cover }), ...has }] : { refused: 'say which record: binder edit ID' };
|
|
60
61
|
case 'move': return rest.length === 2 ? ['move', { id: rest[0], state: rest[1] }] : { refused: 'binder move ID STATE' };
|
|
61
62
|
case 'note': return rest.length >= 2 ? ['note', { id: rest[0], text: rest.slice(1).join(' ') }] : { refused: 'binder note ID "text"' };
|
|
62
63
|
case 'attach': return rest.length >= 3 ? ['attach', { id: rest[0], kind: rest[1], ref: rest[2], title: rest.slice(3).join(' ') }] : { refused: 'binder attach ID KIND REF ["title"]' };
|
package/docs/protocol.md
CHANGED
|
@@ -64,3 +64,11 @@ whoever wrote it (`edit-note`).
|
|
|
64
64
|
|
|
65
65
|
And the screen: `mount(element, { call, words, lang, people, picture, openAttached })`
|
|
66
66
|
from `clavarium-binder/ui`, with `ui/binder-ui.css` and `ui/texts/*.json`.
|
|
67
|
+
|
|
68
|
+
What a card shows: what is set, nothing about what is empty. The fields set
|
|
69
|
+
make one line under its title; its state is said unless the card is in the
|
|
70
|
+
first state and that is the list's only state not done (with no gate). Its
|
|
71
|
+
picture -- the `cover` chosen (`binder edit ID --cover REF`, an attached
|
|
72
|
+
picture or a photo field), else its photo field set, else its only picture
|
|
73
|
+
attached, else none -- is put forward
|
|
74
|
+
on its page and in Tiles, through the page's `picture(value)`.
|
package/host/binder-host.mjs
CHANGED
|
@@ -57,9 +57,9 @@ export function say(op, data, list) {
|
|
|
57
57
|
const out = [`#${r.id} ${r.title}`, ` list: ${l.title}; state: ${st?.name ?? r.state}${r.approved ? ' (approved)' : ''}${r.archived ? '; archived' : ''}${owners(r) ? `; owners:${owners(r)}` : ''}${due(r)}`];
|
|
58
58
|
if (r.description) out.push('Description:', ...r.description.split('\n').map((x) => (x ? ` ${x}` : '')));
|
|
59
59
|
else out.push('No description yet: give one (what it is and why, 1-3 lines for a person) with binder edit ID --description "...".');
|
|
60
|
-
for (const f of l.fields) if (r.fields[f.id] !== undefined) out.push(` ${f.name}: ${r.fields[f.id]}`);
|
|
60
|
+
for (const f of l.fields) if (r.fields[f.id] !== undefined) out.push(` ${f.name}: ${r.fields[f.id]}${r.cover === `field:${f.id}` ? ' (cover)' : ''}`);
|
|
61
61
|
if (r.notes.length) { out.push('Notes:'); for (const n of r.notes) out.push(` ${day(n.at)} ${n.by.name}: ${n.text.replace(/\n/g, '\n ')}${n.edited ? ' (edited)' : ''}`); }
|
|
62
|
-
if (r.attached.length) { out.push('Attached:'); for (const a of r.attached) out.push(` ${a.kind} ${a.kind === 'record' ? `#${a.ref}` : a.ref}${a.title ? ` "${a.title}"` : ''}`); }
|
|
62
|
+
if (r.attached.length) { out.push('Attached:'); for (const a of r.attached) out.push(` ${a.kind} ${a.kind === 'record' ? `#${a.ref}` : a.ref}${a.title ? ` "${a.title}"` : ''}${r.cover === a.id ? ' (cover)' : ''}`); }
|
|
63
63
|
out.push(`History: ${history.length} change${history.length === 1 ? '' : 's'}, the last ${history.length ? `${day(history.at(-1).at)} by ${history.at(-1).by.name}` : 'none'}.`);
|
|
64
64
|
return out.join('\n');
|
|
65
65
|
}
|
|
@@ -89,7 +89,7 @@ export function keeper(dir, { sides = () => [], now = () => Date.now() } = {}) {
|
|
|
89
89
|
case 'get': data = store.record(a.id, who); break;
|
|
90
90
|
case 'search': data = store.search(a.text, who); break;
|
|
91
91
|
case 'add': data = store.add(a.list, { title: a.title, description: a.description, state: a.state, fields: a.fields, owners: me(a.owners), due: a.due, note: a.note }, who); break;
|
|
92
|
-
case 'edit': data = store.edit(a.id, { title: a.title, description: a.description, fields: a.fields, owners: me(a.owners), due: a.due }, who); break;
|
|
92
|
+
case 'edit': data = store.edit(a.id, { title: a.title, description: a.description, fields: a.fields, owners: me(a.owners), due: a.due, cover: a.cover }, who); break;
|
|
93
93
|
case 'move': { const r = store.move(a.id, a.state, who); data = { ...r, stateName: store.record(r.id, who).list.states.find((x) => x.id === r.state)?.name }; break; }
|
|
94
94
|
case 'note': data = store.note(a.id, a.text, who); break;
|
|
95
95
|
case 'attach': data = store.attach(a.id, { kind: a.kind, ref: a.ref, title: a.title }, who); break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium-binder",
|
|
3
|
-
"version": "0.1.1
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Clavarium Binder: lists of records kept together by people and AI agents, each with its id (a command and an MCP server for agents, a keeper and a screen for the application). No dependency.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/clavarium/clavarium-binder#readme",
|
package/server/binder.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
23
23
|
import { randomUUID } from 'node:crypto';
|
|
24
24
|
import { createInterface } from 'node:readline';
|
|
25
25
|
|
|
26
|
-
export const VERSION = '0.1.1
|
|
26
|
+
export const VERSION = '0.1.1';
|
|
27
27
|
export const FOLDER = '.binder';
|
|
28
28
|
/* How long an ask waits for its answer. The keeper answers within a second or two. */
|
|
29
29
|
export const WAIT_MS = 20_000;
|
|
@@ -68,7 +68,7 @@ export const TOOLS = [
|
|
|
68
68
|
{ name: 'get', description: 'One record whole: its fields, notes, what is attached, its history.', inputSchema: { type: 'object', properties: { id: S('the record number, as #12 or 12') }, required: ['id'] } },
|
|
69
69
|
{ name: 'search', description: 'Records whose title, notes or fields hold a text, in every list you can see.', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] } },
|
|
70
70
|
{ name: 'add', description: 'Add a record to a list (in its first state unless you say), with a short title and a description a person understands at a glance. You cannot add it approved: a person ticks it.', inputSchema: { type: 'object', properties: { list: S('its name or id'), title: { type: 'string' }, description: DESCRIBE, state: { type: 'string' }, due: S('2026-11-04 or 2026-11-04 09:00'), owner: S('"me", or an id'), note: { type: 'string' }, fields: { type: 'object', description: '{ field name: value }' } }, required: ['list', 'title'] } },
|
|
71
|
-
{ name: 'edit', description: 'Change a record: title, description, fields, owners, due date (empty to remove it).', inputSchema: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: DESCRIBE, due: { type: 'string' }, owner: S('"me", or an id; empty for nobody'), fields: { type: 'object' } }, required: ['id'] } },
|
|
71
|
+
{ name: 'edit', description: 'Change a record: title, description, fields, owners, due date, cover picture (empty to remove it).', inputSchema: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: DESCRIBE, due: { type: 'string' }, owner: S('"me", or an id; empty for nobody'), fields: { type: 'object' }, cover: S('the picture the card is shown by, when it has several: a picture attached (its ref) or a photo field; empty to remove it') }, required: ['id'] } },
|
|
72
72
|
{ name: 'move', description: 'Put a record in another state of its list. Past a gate (an approval), only once a person approved it.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, state: { type: 'string' } }, required: ['id', 'state'] } },
|
|
73
73
|
{ name: 'note', description: 'Add a short note to a record: a decision, a fact, a result. Talking with the person stays in the chat.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, text: { type: 'string' } }, required: ['id', 'text'] } },
|
|
74
74
|
{ name: 'attach', description: 'Attach something to a record: a message of the chat, a file, an asset, a branch, a link, another record.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, kind: { type: 'string', enum: ['message', 'file', 'asset', 'branch', 'link', 'record'] }, ref: S('what it is: a path, a URL, repository:branch, a message id, a record number'), title: { type: 'string' } }, required: ['id', 'kind', 'ref'] } },
|
|
@@ -84,7 +84,7 @@ export function asked(name, a = {}) {
|
|
|
84
84
|
case 'get': return ['get', { id: a.id }];
|
|
85
85
|
case 'search': return ['search', { text: a.text }];
|
|
86
86
|
case 'add': return ['add', { list: a.list, title: a.title, description: a.description, state: a.state, due: a.due, owners, note: a.note, fields: a.fields }];
|
|
87
|
-
case 'edit': return ['edit', { id: a.id, title: a.title, description: a.description, due: a.due, owners, fields: a.fields }];
|
|
87
|
+
case 'edit': return ['edit', { id: a.id, title: a.title, description: a.description, due: a.due, owners, fields: a.fields, cover: a.cover }];
|
|
88
88
|
case 'move': return ['move', { id: a.id, state: a.state }];
|
|
89
89
|
case 'note': return ['note', { id: a.id, text: a.text }];
|
|
90
90
|
case 'attach': return ['attach', { id: a.id, kind: a.kind, ref: a.ref, title: a.title }];
|
package/server/store.mjs
CHANGED
|
@@ -36,6 +36,8 @@ export const STATES_MAX = 12;
|
|
|
36
36
|
export const FIELDS_MAX = 20;
|
|
37
37
|
export const OWNERS_MAX = 10;
|
|
38
38
|
export const ATTACH_KINDS = ['message', 'file', 'asset', 'branch', 'link', 'record'];
|
|
39
|
+
/* A file attached that is a picture, by its name (the screen has the same rule). */
|
|
40
|
+
export const isPicture = (ref) => /\.(jpe?g|png|webp|gif)$/i.test(String(ref ?? '').split(/[?#]/)[0]);
|
|
39
41
|
|
|
40
42
|
const NOT_SHOWN = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F----]/gu;
|
|
41
43
|
export const oneLine = (text, max) => String(text ?? '').replace(/\r/g, '').replace(NOT_SHOWN, '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
@@ -259,7 +261,12 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
|
|
|
259
261
|
save(); log(w, 'add', { record: id, list: list.id, change: { title: t, ...(d ? { description: d } : {}), state: st.id } });
|
|
260
262
|
return r;
|
|
261
263
|
},
|
|
262
|
-
|
|
264
|
+
/* COVER: the one picture a card is shown by, when it has several. It is a
|
|
265
|
+
* picture attached (its id; given by id, the start of it, or its ref) or a
|
|
266
|
+
* photo field set ("field:ID"; given by the field's id or name). Empty
|
|
267
|
+
* clears it. Without one, a card is shown by its photo field set, else by
|
|
268
|
+
* its only picture attached. */
|
|
269
|
+
edit(given, { title, description, fields, owners, due, cover } = {}, who) {
|
|
263
270
|
const w = whoOf(who);
|
|
264
271
|
const { r, list } = recordOf(given, w, 'write');
|
|
265
272
|
const change = {};
|
|
@@ -268,6 +275,7 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
|
|
|
268
275
|
if (fields !== undefined) change.fields = setFields(list, r, fields);
|
|
269
276
|
if (owners !== undefined) r.owners = change.owners = ownersOf(owners);
|
|
270
277
|
if (due !== undefined) { if (due === null || due === '') { delete r.due; change.due = null; } else r.due = change.due = dueOf(due); }
|
|
278
|
+
if (cover !== undefined) { const c = coverOf(list, r, cover); if (c) r.cover = change.cover = c; else { delete r.cover; change.cover = null; } }
|
|
271
279
|
r.changed = stamp();
|
|
272
280
|
save(); log(w, 'edit', { record: r.id, list: list.id, change });
|
|
273
281
|
return r;
|
|
@@ -335,6 +343,7 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
|
|
|
335
343
|
const a = r.attached.find((x) => x.id === attachedId || x.id.startsWith(String(attachedId ?? '-')) || x.ref === attachedId);
|
|
336
344
|
if (!a) no('nothing like that is attached');
|
|
337
345
|
r.attached = r.attached.filter((x) => x !== a); r.changed = stamp();
|
|
346
|
+
if (r.cover === a.id) delete r.cover;
|
|
338
347
|
save(); log(w, 'detach', { record: r.id, list: list.id, change: { kind: a.kind, ref: a.ref } });
|
|
339
348
|
return r;
|
|
340
349
|
},
|
|
@@ -372,7 +381,7 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
|
|
|
372
381
|
for (const [k, v] of Object.entries(given)) {
|
|
373
382
|
const f = list.fields.find((x) => x.id === k) ?? list.fields.find((x) => x.name.toLowerCase() === String(k).toLowerCase());
|
|
374
383
|
if (!f) no(`the list ${list.title} has no field "${k}"${list.fields.length ? ` (it has: ${list.fields.map((x) => x.name).join(', ')})` : ''}`);
|
|
375
|
-
if (v === null || v === '') { delete r.fields[f.id]; change[f.id] = null; continue; }
|
|
384
|
+
if (v === null || v === '') { delete r.fields[f.id]; change[f.id] = null; if (r.cover === `field:${f.id}`) delete r.cover; continue; }
|
|
376
385
|
const value = oneLine(v, FIELD_MAX);
|
|
377
386
|
if (f.type === 'number' && !Number.isFinite(Number(value))) no(`${f.name} is a number`);
|
|
378
387
|
if (f.type === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(value)) no(`${f.name} is a date: 2026-11-04`);
|
|
@@ -381,6 +390,16 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
|
|
|
381
390
|
}
|
|
382
391
|
return change;
|
|
383
392
|
}
|
|
393
|
+
function coverOf(list, r, given) {
|
|
394
|
+
if (given === null || given === '') return '';
|
|
395
|
+
const g = oneLine(given, REF_MAX);
|
|
396
|
+
const a = r.attached.find((x) => x.id === g) ?? r.attached.find((x) => x.ref === g) ?? (g.length >= 4 ? r.attached.find((x) => x.id.startsWith(g)) : undefined);
|
|
397
|
+
if (a) { if (a.kind !== 'file' || !isPicture(a.ref)) no(`the cover is a picture: ${a.ref} is not one`); return a.id; }
|
|
398
|
+
const name = g.replace(/^field:/, '');
|
|
399
|
+
const f = list.fields.find((x) => x.id === name) ?? list.fields.find((x) => x.name.toLowerCase() === name.toLowerCase());
|
|
400
|
+
if (f) { if (f.type !== 'photo') no(`the cover is a picture: ${f.name} is not a photo field`); if (!r.fields[f.id]) no(`${f.name} is empty`); return `field:${f.id}`; }
|
|
401
|
+
return no('the cover is a picture attached (its id, or what it is) or a photo field');
|
|
402
|
+
}
|
|
384
403
|
function ownersOf(given) {
|
|
385
404
|
const list = (Array.isArray(given) ? given : [given]).map((x) => oneLine(x, 100)).filter(Boolean);
|
|
386
405
|
if (list.length > OWNERS_MAX) no(`${OWNERS_MAX} owners at most`);
|
package/ui/binder-ui.css
CHANGED
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
.binder-chip { border: 1px solid var(--binder-line); background: transparent; border-radius: 999px; padding: .3em .8em; color: var(--binder-dim); }
|
|
36
36
|
.binder-chip[aria-pressed="true"] { border-color: var(--binder-action); color: var(--binder-text); background: color-mix(in srgb, var(--binder-action) 14%, transparent); }
|
|
37
37
|
.binder-owners { display: flex; gap: .35rem; flex-wrap: wrap; }
|
|
38
|
+
.binder-pop { position: relative; }
|
|
39
|
+
.binder-dots { font-weight: 700; letter-spacing: .05em; padding: .4em .75em; line-height: 1.15; }
|
|
40
|
+
.binder-menu-end { left: auto; right: 0; }
|
|
38
41
|
.binder-primary { background: var(--binder-action); color: var(--binder-on-action) !important; border: 1px solid var(--binder-action); border-radius: 8px; padding: .45em 1em; font-weight: 600; }
|
|
39
42
|
.binder-ghost { background: var(--binder-panel); border: 1px solid var(--binder-line); border-radius: 8px; padding: .45em .9em; }
|
|
40
43
|
.binder-danger { background: var(--binder-danger); color: var(--binder-bg) !important; border: 0; border-radius: 8px; padding: .45em .9em; font-weight: 600; }
|
|
@@ -51,6 +54,11 @@
|
|
|
51
54
|
.binder-what { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
|
|
52
55
|
.binder-what b { overflow-wrap: anywhere; }
|
|
53
56
|
.binder-sub { color: var(--binder-dim); font-size: .9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
57
|
+
/* The fields set, on one line under a title. */
|
|
58
|
+
.binder-facts { color: var(--binder-dim); overflow-wrap: anywhere; }
|
|
59
|
+
.binder-head .binder-facts { font-size: 1.02rem; color: var(--binder-text); opacity: .82; }
|
|
60
|
+
.binder-what .binder-facts { font-size: .9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
61
|
+
.binder-facts-small { font-size: .82em; }
|
|
54
62
|
.binder-meta { display: flex; gap: .6em; flex-wrap: wrap; font-size: .85em; color: var(--binder-dim); }
|
|
55
63
|
.binder-tick { width: 1.4rem; height: 1.4rem; border: 2px solid var(--binder-dim); border-radius: 5px; background: transparent; display: grid; place-items: center; padding: 0; font-weight: 800; font-size: .85rem; line-height: 1; }
|
|
56
64
|
.binder-tick[aria-checked="true"] { background: var(--binder-ok); border-color: var(--binder-ok); color: var(--binder-bg); }
|
|
@@ -67,7 +75,9 @@
|
|
|
67
75
|
/* As cards, with a face. */
|
|
68
76
|
.binder-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: .8rem; }
|
|
69
77
|
.binder-face { align-items: center; text-align: center; }
|
|
70
|
-
.binder-face
|
|
78
|
+
.binder-face { padding: .6rem .6rem .8rem; }
|
|
79
|
+
.binder-face img { width: 100%; aspect-ratio: 4 / 3; border-radius: 6px; object-fit: cover; background: var(--binder-panel); }
|
|
80
|
+
.binder-initial { width: 5.5rem; height: 5.5rem; border-radius: 50%; margin: .4rem 0; }
|
|
71
81
|
.binder-initial { display: grid; place-items: center; background: var(--binder-panel); font-size: 2rem; font-weight: 700; color: var(--binder-dim); }
|
|
72
82
|
|
|
73
83
|
/* A record, whole: one calm column, read on a phone first. */
|
|
@@ -78,7 +88,13 @@
|
|
|
78
88
|
.binder .binder-text-btn { border: 0; background: transparent; padding: .35em 0; color: var(--binder-action); font-weight: 500; text-align: left; }
|
|
79
89
|
.binder-text-btn:hover { text-decoration: underline; }
|
|
80
90
|
.binder-small { padding: .35em .85em; font-size: .92em; }
|
|
81
|
-
.binder-head { display: flex; flex-direction: column; gap: .
|
|
91
|
+
.binder-head { display: flex; flex-direction: column; gap: .45rem; }
|
|
92
|
+
.binder-hero { display: block; width: 100%; max-height: 26rem; object-fit: cover; border-radius: 14px; background: var(--binder-panel); margin-bottom: -.4rem; }
|
|
93
|
+
.binder-offer { display: flex; flex-wrap: wrap; gap: .1rem .45rem; align-items: center; font-size: .92em; }
|
|
94
|
+
.binder .binder-offer .binder-text-btn { padding: .2em 0; }
|
|
95
|
+
.binder-thumb { object-fit: cover; padding: 0; }
|
|
96
|
+
.binder .binder-star { font-size: .85em; white-space: nowrap; color: var(--binder-dim); }
|
|
97
|
+
.binder .binder-star[aria-pressed="true"] { color: var(--binder-ask); }
|
|
82
98
|
.binder .binder-title { font-size: 1.65rem; font-weight: 700; line-height: 1.25; letter-spacing: -.01em; border: 0; border-radius: 6px; background: transparent; padding: .05em .15em; margin: 0 -.15em;
|
|
83
99
|
resize: none; overflow: hidden; width: 100%; box-sizing: border-box; field-sizing: content; }
|
|
84
100
|
.binder .binder-title:hover:not([readonly]) { background: var(--binder-panel); }
|
|
@@ -154,4 +170,9 @@ button.binder-who:hover span:not(.binder-dot) { text-decoration: underline; text
|
|
|
154
170
|
.binder-record { gap: 1.3rem; }
|
|
155
171
|
.binder-row.binder-edit { grid-template-columns: 1fr auto; }
|
|
156
172
|
.binder-cols { grid-auto-columns: minmax(80%, 1fr); }
|
|
173
|
+
.binder-hero { max-height: 18rem; }
|
|
174
|
+
.binder-bar { gap: .4rem; }
|
|
175
|
+
.binder-bar h2 { flex-basis: 100%; }
|
|
176
|
+
.binder-seg button { padding: .4em .6em; }
|
|
177
|
+
.binder-bar .binder-primary { padding: .45em .75em; }
|
|
157
178
|
}
|
package/ui/binder-ui.js
CHANGED
|
@@ -18,14 +18,77 @@
|
|
|
18
18
|
|
|
19
19
|
export const KEYS = Object.freeze(['lists', 'new-list', 'new-item', 'no-lists', 'template', 'create', 'cancel', 'title', 'list', 'columns', 'cards', 'back',
|
|
20
20
|
'state', 'owners', 'due', 'notes', 'add-note', 'attached', 'attach', 'history', 'archive', 'restore', 'delete', 'delete-sure', 'approved', 'settings',
|
|
21
|
-
'states', 'fields', 'access', 'read', 'write', 'none', 'save', 'empty', '
|
|
21
|
+
'states', 'fields', 'access', 'read', 'write', 'none', 'save', 'empty', 'archived', 'show-archived', 'gate', 'done', 'add-state', 'add-field',
|
|
22
22
|
'kind-message', 'kind-file', 'kind-asset', 'kind-branch', 'kind-link', 'kind-record', 'what', 'name', 'type', 'saved', 'edited', 'by', 'mine', 'all',
|
|
23
|
-
'delete-list', 'delete-list-sure', 'archive-list', 'no-records', 'search', 'description', '
|
|
23
|
+
'delete-list', 'delete-list-sure', 'archive-list', 'no-records', 'search', 'description', 'edit', 'more', 'approve', 'add', 'ok',
|
|
24
|
+
'add-what', 'owner', 'attachment', 'tools', 'cover',
|
|
25
|
+
'op-add', 'op-edit', 'op-move', 'op-note', 'op-edit-note', 'op-remove-note', 'op-attach', 'op-detach', 'op-archive', 'op-restore', 'op-delete']);
|
|
24
26
|
const KINDS = ['message', 'file', 'asset', 'branch', 'link', 'record'];
|
|
25
27
|
const ICON = { message: '💬', file: '📄', asset: '🗄', branch: '⑂', link: '🔗', record: '▣' };
|
|
26
28
|
const TYPES = ['text', 'number', 'date', 'link', 'photo'];
|
|
27
29
|
const DESCRIPTION_MAX = 4000;
|
|
28
30
|
|
|
31
|
+
/* WHAT A CARD SHOWS: what is set, and nothing about what is empty. These rules
|
|
32
|
+
* know no template: only a list's states and fields, and a record's values. */
|
|
33
|
+
|
|
34
|
+
/* A file that is a picture, by its name: what a screen may put forward. */
|
|
35
|
+
export const isPicture = (ref) => /\.(jpe?g|png|webp|gif)$/i.test(String(ref ?? '').split(/[?#]/)[0]);
|
|
36
|
+
|
|
37
|
+
/* A card's pictures: its photo fields set ("field:ID"), then its files attached
|
|
38
|
+
* that are pictures (their id): [{ key, value }]. */
|
|
39
|
+
export function pictures(l, r) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const f of l.fields ?? []) if (f.type === 'photo' && r.fields?.[f.id]) out.push({ key: `field:${f.id}`, value: r.fields[f.id] });
|
|
42
|
+
for (const a of r.attached ?? []) if (a.kind === 'file' && isPicture(a.ref)) out.push({ key: a.id, value: a.ref });
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/* The one put forward: the cover a person or an agent chose; else a photo field
|
|
46
|
+
* set (a picture put there on purpose); else the only picture attached; else none. */
|
|
47
|
+
export function coverOf(l, r) {
|
|
48
|
+
const all = pictures(l, r);
|
|
49
|
+
const attached = all.filter((p) => !p.key.startsWith('field:'));
|
|
50
|
+
return all.find((p) => p.key === r.cover) ?? all.find((p) => p.key.startsWith('field:')) ?? (attached.length === 1 ? attached[0] : null);
|
|
51
|
+
}
|
|
52
|
+
/* It, as a picture to show, through the page's `picture` hook; else none. */
|
|
53
|
+
export function imageOf(l, r, picture = () => null) {
|
|
54
|
+
const c = coverOf(l, r);
|
|
55
|
+
return c ? picture(c.value) ?? null : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* The fields set, as one short line: [{ id, text }]. A date or a number says its
|
|
59
|
+
* field's name ("Naissance : 12 avril 1931": alone, a date or "3" says not what
|
|
60
|
+
* it is); a text or a link says itself. Photos are shown, not said. Empty fields
|
|
61
|
+
* are not there. */
|
|
62
|
+
export function facts(l, r, lang = 'en') {
|
|
63
|
+
const out = [];
|
|
64
|
+
const named = (name, text) => `${name}${String(lang).startsWith('fr') ? '\u202f: ' : ': '}${text}`;
|
|
65
|
+
for (const f of l.fields ?? []) {
|
|
66
|
+
const v = r.fields?.[f.id];
|
|
67
|
+
if (v === undefined || v === null || v === '' || f.type === 'photo') continue;
|
|
68
|
+
let text = String(v);
|
|
69
|
+
if (f.type === 'date') {
|
|
70
|
+
const t = Date.parse(`${text.slice(0, 10)}T00:00:00`);
|
|
71
|
+
if (Number.isFinite(t)) { try { text = new Date(t).toLocaleDateString(lang, { day: 'numeric', month: 'long', year: 'numeric' }); } catch { /* as typed */ } }
|
|
72
|
+
text = named(f.name, text);
|
|
73
|
+
} else if (f.type === 'number') {
|
|
74
|
+
try { text = named(f.name, Number(v).toLocaleString(lang)); } catch { text = named(f.name, text); }
|
|
75
|
+
} else if (f.type === 'link') {
|
|
76
|
+
try { text = new URL(text).host.replace(/^www\./, ''); } catch { /* as typed */ }
|
|
77
|
+
}
|
|
78
|
+
out.push({ id: f.id, text });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* Whether a card's state says anything. It says nothing when the card is in the
|
|
84
|
+
* first state and that is the list's only state not done, with no gate: then
|
|
85
|
+
* "open" is what every card is until it is done. Any other state is said. */
|
|
86
|
+
export function stateShown(l, r) {
|
|
87
|
+
const open = l.states.filter((s) => !s.done);
|
|
88
|
+
if (open.length !== 1 || l.states.some((s) => s.gate)) return true;
|
|
89
|
+
return r.state !== l.states[0]?.id;
|
|
90
|
+
}
|
|
91
|
+
|
|
29
92
|
/* A DESCRIPTION, READ: a few lines of Markdown-ish text, as blocks a screen can
|
|
30
93
|
* draw element by element. Paragraphs (a blank line between), lists ("- " or
|
|
31
94
|
* "* "), **bold**, links ([text](https://...) or a bare http(s) address).
|
|
@@ -117,18 +180,27 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
|
|
|
117
180
|
/* ---- the parts ---- */
|
|
118
181
|
const top = () => h('div', { class: 'binder-top' },
|
|
119
182
|
h('div', { class: 'binder-tabs', role: 'tablist' }, ui.lists.map((l) => h('button', { type: 'button', role: 'tab', 'aria-selected': String(l.id === ui.at), 'data-list': l.id,
|
|
120
|
-
onclick: () => { ui.at = l.id; ui.view = null; ui.mode = 'list'; ui.record = null; void refresh(); } }, l.title, h('small', {}, String(l.count))))),
|
|
183
|
+
onclick: () => { ui.at = l.id; ui.view = null; ui.mode = 'list'; ui.record = null; ui.open = null; void refresh(); } }, l.title, h('small', {}, String(l.count))))),
|
|
121
184
|
h('button', { type: 'button', class: 'binder-new-list', onclick: () => { ui.mode = 'new-list'; paint(); } }, `+ ${w('new-list')}`));
|
|
122
185
|
|
|
186
|
+
/* The bar: the title, the views, "+ Card"; the rest behind "…". A filter on is
|
|
187
|
+
* said by a chip that takes it off. */
|
|
123
188
|
function bar(l) {
|
|
124
189
|
const view = ui.view ?? l.view;
|
|
190
|
+
const flip = (k) => { ui[k] = !ui[k]; ui.open = null; void refresh(); };
|
|
191
|
+
const item = (on, text, act, fn) => h('button', { type: 'button', role: on === null ? 'menuitem' : 'menuitemcheckbox', 'aria-checked': on === null ? null : String(on), 'data-act': act, onclick: fn },
|
|
192
|
+
h('span', { class: 'binder-check', 'aria-hidden': 'true' }, on ? '✓' : ''), text);
|
|
193
|
+
const menu = ui.open === 'tools' ? h('div', { class: 'binder-menu binder-menu-end', role: 'menu' },
|
|
194
|
+
item(ui.mine, w('mine'), 'mine', () => flip('mine')),
|
|
195
|
+
item(ui.archived, w('archived'), 'archived', () => flip('archived')),
|
|
196
|
+
l.access === 'write' && l.grants ? item(null, w('settings'), 'settings', () => { ui.open = null; ui.mode = 'settings'; paint(); }) : null) : null;
|
|
197
|
+
const chip = (k) => (ui[k] ? h('button', { type: 'button', class: 'binder-chip', 'aria-pressed': 'true', 'data-act': `off-${k}`, onclick: () => flip(k) }, w(k === 'mine' ? 'mine' : 'archived'), h('span', { 'aria-hidden': 'true' }, ' ×')) : null);
|
|
125
198
|
return h('div', { class: 'binder-bar' },
|
|
126
|
-
h('h2', {}, l.title),
|
|
199
|
+
h('h2', {}, l.title), chip('mine'), chip('archived'),
|
|
127
200
|
h('div', { class: 'binder-seg', role: 'group' }, ['list', 'columns', 'cards'].map((v) => h('button', { type: 'button', 'aria-pressed': String(v === view), 'data-view': v, onclick: () => { ui.view = v; paint(); } }, w(v)))),
|
|
128
|
-
h('button', { type: 'button', class: 'binder-
|
|
129
|
-
h('button', { type: 'button', class: 'binder-
|
|
130
|
-
|
|
131
|
-
l.access === 'write' && !ui.archived ? h('button', { type: 'button', class: 'binder-primary', 'data-act': 'new-item', onclick: () => { ui.mode = 'new-item'; paint(); } }, `+ ${w('new-item')}`) : null);
|
|
201
|
+
l.access === 'write' && !ui.archived ? h('button', { type: 'button', class: 'binder-primary', 'data-act': 'new-item', onclick: () => { ui.mode = 'new-item'; paint(); } }, `+ ${w('new-item')}`) : null,
|
|
202
|
+
h('div', { class: 'binder-pop' }, h('button', { type: 'button', class: 'binder-ghost binder-dots', 'data-act': 'tools', 'aria-haspopup': 'menu', 'aria-expanded': String(ui.open === 'tools'), 'aria-label': w('tools'), title: w('tools'),
|
|
203
|
+
onclick: () => { ui.open = ui.open === 'tools' ? null : 'tools'; paint(); } }, '…'), menu));
|
|
132
204
|
}
|
|
133
205
|
|
|
134
206
|
/* A due date as a person reads it: "26 oct.", "4 nov. 2027 09:00". */
|
|
@@ -150,34 +222,35 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
|
|
|
150
222
|
return h('button', { type: 'button', class: 'binder-tick', role: 'checkbox', 'aria-checked': String(on), title: g >= 0 ? w('approved') : w('done'), disabled: l.access !== 'write' || !l.grants && g >= 0,
|
|
151
223
|
onclick: (e) => { e.stopPropagation(); void soft(g >= 0 ? ask('approve', { id: r.id, yes: !on }) : ask('move', { id: r.id, state: on ? l.states[0].id : doneOf(l).id })).then(refresh); } }, on ? '✓' : '');
|
|
152
224
|
}
|
|
153
|
-
const pill = (l, r) => { const st = l.states.find((s) => s.id === r.state); return h('span', { class: 'binder-pill', 'data-state': r.state, 'data-done': st?.done ? '' : null, 'data-gate': st?.gate ? '' : null }, st?.name ?? r.state); };
|
|
225
|
+
const pill = (l, r) => { if (!stateShown(l, r)) return null; const st = l.states.find((s) => s.id === r.state); return h('span', { class: 'binder-pill', 'data-state': r.state, 'data-done': st?.done ? '' : null, 'data-gate': st?.gate ? '' : null }, st?.name ?? r.state); };
|
|
154
226
|
const meta = (l, r) => h('span', { class: 'binder-meta' },
|
|
155
227
|
r.owners.length ? r.owners.map(nameOf).join(', ') : null,
|
|
156
228
|
r.due ? h('span', { class: late(l, r) ? 'binder-late' : null }, `⏰ ${when(r.due)}`) : null,
|
|
157
229
|
r.attached.length ? h('span', {}, `📎 ${r.attached.length}`) : null,
|
|
158
230
|
r.notes.length ? h('span', {}, `✎ ${r.notes.length}`) : null);
|
|
159
231
|
const sub = (r) => (r.description ? h('span', { class: 'binder-sub' }, firstLine(r.description)) : null);
|
|
232
|
+
/* The fields set, on one line: "Naissance 12 avril 1931 · Saint-Aubin". */
|
|
233
|
+
const factLine = (l, r, cls = 'binder-facts') => { const f = facts(l, r, lang); return f.length ? h('span', { class: cls }, f.map((x) => x.text).join(' · ')) : null; };
|
|
160
234
|
const leave = () => { ui.open = null; ui.more = false; ui.draft = {}; };
|
|
161
235
|
const open = (r) => { void soft(ask('get', { id: r.id }).then((d) => { leave(); ui.record = d; ui.mode = 'record'; paint(); window.scrollTo?.(0, 0); })); };
|
|
162
236
|
|
|
163
237
|
function asList(l, records) {
|
|
164
238
|
return h('div', { class: 'binder-rows' }, records.map((r) => h('div', { class: 'binder-row', 'data-record': r.id, tabindex: '0', onclick: () => open(r), onkeydown: (e) => { if (e.key === 'Enter') open(r); } },
|
|
165
|
-
tick(l, r), h('div', { class: 'binder-what' }, h('b', {}, r.title), sub(r), meta(l, r)), pill(l, r))));
|
|
239
|
+
tick(l, r), h('div', { class: 'binder-what' }, h('b', {}, r.title), factLine(l, r), sub(r), meta(l, r)), pill(l, r) ?? h('span'))));
|
|
166
240
|
}
|
|
167
241
|
function asColumns(l, records) {
|
|
168
242
|
return h('div', { class: 'binder-cols' }, l.states.map((s) => {
|
|
169
243
|
const here = records.filter((r) => r.state === s.id);
|
|
170
244
|
return h('section', { class: 'binder-col', 'data-state': s.id }, h('h3', {}, s.name, h('span', {}, String(here.length))),
|
|
171
|
-
here.map((r) => h('div', { class: 'binder-card', 'data-record': r.id, tabindex: '0', onclick: () => open(r), onkeydown: (e) => { if (e.key === 'Enter') open(r); } }, h('b', {}, r.title), sub(r), meta(l, r))));
|
|
245
|
+
here.map((r) => h('div', { class: 'binder-card', 'data-record': r.id, tabindex: '0', onclick: () => open(r), onkeydown: (e) => { if (e.key === 'Enter') open(r); } }, h('b', {}, r.title), factLine(l, r), sub(r), meta(l, r))));
|
|
172
246
|
}));
|
|
173
247
|
}
|
|
174
248
|
function asCards(l, records) {
|
|
175
|
-
const photo = l.fields.find((f) => f.type === 'photo');
|
|
176
249
|
return h('div', { class: 'binder-grid' }, records.map((r) => {
|
|
177
|
-
const src =
|
|
250
|
+
const src = imageOf(l, r, picture);
|
|
178
251
|
return h('div', { class: 'binder-card binder-face', 'data-record': r.id, tabindex: '0', onclick: () => open(r), onkeydown: (e) => { if (e.key === 'Enter') open(r); } },
|
|
179
252
|
src ? h('img', { src, alt: r.title, loading: 'lazy' }) : h('div', { class: 'binder-initial', 'aria-hidden': 'true' }, r.title.slice(0, 1).toUpperCase()),
|
|
180
|
-
h('b', {}, r.title), pill(l, r));
|
|
253
|
+
h('b', {}, r.title), factLine(l, r, 'binder-facts binder-facts-small'), pill(l, r));
|
|
181
254
|
}));
|
|
182
255
|
}
|
|
183
256
|
|
|
@@ -203,9 +276,11 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
|
|
|
203
276
|
h('button', { type: 'button', class: 'binder-primary', onclick: go }, w('create'))));
|
|
204
277
|
}
|
|
205
278
|
|
|
206
|
-
/* A RECORD, WHOLE, read at a glance: its
|
|
207
|
-
*
|
|
208
|
-
*
|
|
279
|
+
/* A RECORD, WHOLE, read at a glance: its picture, its title, its fields set on
|
|
280
|
+
* one line, its state when it says something, what it is (the description);
|
|
281
|
+
* who and when, when set; what is attached; the notes. What is empty is not
|
|
282
|
+
* shown: one quiet line offers to add it. The rest (fields to change,
|
|
283
|
+
* history, archive, delete) is folded under "more". */
|
|
209
284
|
function record() {
|
|
210
285
|
const { record: r, list: l, history } = ui.record;
|
|
211
286
|
const can = l.access === 'write';
|
|
@@ -231,6 +306,15 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
|
|
|
231
306
|
const approve = g >= 0 && can && l.grants && !r.approved
|
|
232
307
|
? h('button', { type: 'button', class: 'binder-primary binder-small', 'data-act': 'approve', onclick: () => done(ask('approve', { id: r.id, yes: true })) }, `✓ ${w('approve')}`) : null;
|
|
233
308
|
|
|
309
|
+
/* Its picture, put forward. */
|
|
310
|
+
const cover = coverOf(l, r);
|
|
311
|
+
const coverSrc = cover ? picture(cover.value) : null;
|
|
312
|
+
const hero = coverSrc ? h('img', { class: 'binder-hero', src: coverSrc, alt: r.title }) : null;
|
|
313
|
+
const all = pictures(l, r);
|
|
314
|
+
/* With several pictures, each says whether it is the one put forward, and can become it. */
|
|
315
|
+
const star = (key) => (can && all.length > 1 ? h('button', { type: 'button', class: 'binder-text-btn binder-star', 'data-act': 'cover', 'aria-pressed': String(cover?.key === key),
|
|
316
|
+
onclick: (e) => { e.preventDefault(); done(ask('edit', { id: r.id, cover: cover?.key === key ? '' : key })); } }, `${cover?.key === key ? '★' : '☆'} ${w('cover')}`) : null);
|
|
317
|
+
|
|
234
318
|
/* What it is. */
|
|
235
319
|
let about;
|
|
236
320
|
if (ui.open === 'description') {
|
|
@@ -241,16 +325,15 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
|
|
|
241
325
|
h('button', { type: 'button', class: 'binder-primary binder-small', 'data-act': 'save-description', onclick: () => { delete ui.draft.description; done(ask('edit', { id: r.id, description: text.value })); } }, w('save')),
|
|
242
326
|
h('button', { type: 'button', class: 'binder-text-btn', onclick: () => { delete ui.draft.description; set(null); } }, w('cancel'))));
|
|
243
327
|
} else {
|
|
244
|
-
about = h('div', { class: 'binder-desc' },
|
|
245
|
-
can ? h('button', { type: 'button', class: 'binder-text-btn', 'data-act': 'edit-description', onclick: () => set('description') },
|
|
328
|
+
about = r.description ? h('div', { class: 'binder-desc' }, rich(r.description),
|
|
329
|
+
can ? h('button', { type: 'button', class: 'binder-text-btn', 'data-act': 'edit-description', onclick: () => set('description') }, w('edit')) : null) : null;
|
|
246
330
|
}
|
|
247
331
|
|
|
248
|
-
/* Who and when, on one line. */
|
|
332
|
+
/* Who and when, on one line: only what is set. */
|
|
249
333
|
const names = r.owners.map(nameOf).join(', ');
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
h('span', { class: 'binder-dot', 'aria-hidden': 'true' }, '·'),
|
|
253
|
-
h('span', { class: r.due ? (late(l, r) ? 'binder-late' : null) : 'binder-dim' }, `⏰ ${r.due ? when(r.due) : w('no-due')}`));
|
|
334
|
+
const whoParts = [names ? h('span', {}, `👤 ${names}`) : null, r.due ? h('span', { class: late(l, r) ? 'binder-late' : null }, `⏰ ${when(r.due)}`) : null].filter(Boolean);
|
|
335
|
+
const whoLine = whoParts.length ? h(can ? 'button' : 'div', { type: can ? 'button' : null, class: 'binder-who', 'data-act': 'who', 'aria-expanded': can ? String(ui.open === 'who') : null, onclick: can ? () => set(ui.open === 'who' ? null : 'who') : null },
|
|
336
|
+
whoParts.flatMap((x, i) => (i ? [h('span', { class: 'binder-dot', 'aria-hidden': 'true' }, '·'), x] : [x]))) : null;
|
|
254
337
|
let whoEdit = null;
|
|
255
338
|
if (ui.open === 'who') {
|
|
256
339
|
const day = h('input', { type: 'date', value: r.due?.slice(0, 10) ?? '', 'aria-label': w('due') });
|
|
@@ -265,7 +348,7 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
|
|
|
265
348
|
|
|
266
349
|
/* What is attached: an icon and a name; a tap opens it. */
|
|
267
350
|
const openIt = (a) => { if (a.kind === 'record') open({ id: a.ref }); else if (a.kind === 'link') window.open(a.ref, '_blank', 'noopener'); else openAttached?.(a); };
|
|
268
|
-
const nameOfIt = (a) => a.title || (a.kind === 'record' ? `#${a.ref}` : a.kind === 'file' ? a.ref.split('/').filter(Boolean).at(-1) ?? a.ref : a.ref);
|
|
351
|
+
const nameOfIt = (a) => a.title || (a.kind === 'record' ? ui.data?.records?.find((x) => x.id === a.ref)?.title ?? `#${a.ref}` : a.kind === 'file' ? a.ref.split('/').filter(Boolean).at(-1) ?? a.ref : a.ref);
|
|
269
352
|
let attachForm = null;
|
|
270
353
|
if (can && ui.open === 'attach') {
|
|
271
354
|
const kind = h('select', { 'aria-label': w('attach') }, KINDS.map((k) => h('option', { value: k }, w(`kind-${k}`))));
|
|
@@ -275,17 +358,32 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
|
|
|
275
358
|
h('button', { type: 'button', class: 'binder-primary binder-small', 'data-act': 'attach', onclick: () => { if (ref.value.trim()) done(ask('attach', { id: r.id, kind: kind.value, ref: ref.value })); } }, w('attach')),
|
|
276
359
|
h('button', { type: 'button', class: 'binder-text-btn', onclick: () => set(null) }, w('cancel')));
|
|
277
360
|
}
|
|
278
|
-
const
|
|
361
|
+
const thumb = (a) => { const src = a.kind === 'file' && isPicture(a.ref) ? picture(a.ref) : null; return src ? h('img', { class: 'binder-ic binder-thumb', src, alt: '' }) : h('span', { class: 'binder-ic', 'aria-hidden': 'true' }, ICON[a.kind] ?? '•'); };
|
|
362
|
+
const attached = r.attached.length || attachForm ? h('section', { class: 'binder-sec' }, h('h3', {}, w('attached')),
|
|
279
363
|
r.attached.length ? h('ul', { class: 'binder-atts' }, r.attached.map((a) => h('li', { class: 'binder-att', 'data-kind': a.kind },
|
|
280
364
|
h('button', { type: 'button', class: 'binder-link', title: `${w(`kind-${a.kind}`)} · ${a.kind === 'record' ? `#${a.ref}` : a.ref}`, onclick: () => openIt(a) },
|
|
281
|
-
|
|
365
|
+
thumb(a), h('span', { class: 'binder-att-name' }, nameOfIt(a))),
|
|
366
|
+
a.kind === 'file' && isPicture(a.ref) ? star(a.id) : null,
|
|
282
367
|
can ? h('button', { type: 'button', class: 'binder-x', 'aria-label': `${w('delete')} ${nameOfIt(a)}`, onclick: () => void soft(ask('detach', { id: r.id, what: a.id })).then(refresh) }, '×') : null))) : null,
|
|
283
368
|
attachForm ?? (can ? h('button', { type: 'button', class: 'binder-text-btn', 'data-act': 'show-attach', onclick: () => set('attach') }, `+ ${w('attach')}`) : null)) : null;
|
|
284
369
|
|
|
370
|
+
/* What is empty, offered in one quiet line: "+ Ajouter : description · échéance · responsable · pièce jointe · champ". */
|
|
371
|
+
const low = (k) => w(k).toLocaleLowerCase(lang);
|
|
372
|
+
const offers = can ? [
|
|
373
|
+
!r.description && ui.open !== 'description' ? ['description', low('description'), () => set('description')] : null,
|
|
374
|
+
!r.due && ui.open !== 'who' ? ['due', low('due'), () => set('who')] : null,
|
|
375
|
+
!r.owners.length && people().length && ui.open !== 'who' ? ['owner', low('owner'), () => set('who')] : null,
|
|
376
|
+
!r.attached.length && ui.open !== 'attach' ? ['attach', low('attachment'), () => set('attach')] : null,
|
|
377
|
+
l.fields.some((f) => r.fields[f.id] === undefined || r.fields[f.id] === '') ? ['fields', low('fields'), () => { ui.more = true; paint(); requestAnimationFrame(() => root.querySelector?.('.binder-fields input')?.focus()); }] : null,
|
|
378
|
+
].filter(Boolean) : [];
|
|
379
|
+
const addRow = offers.length ? h('div', { class: 'binder-offer' }, h('span', { class: 'binder-dim' }, `+ ${w('add-what')}`),
|
|
380
|
+
offers.flatMap(([act, text, fn], i) => [i ? h('span', { class: 'binder-dot', 'aria-hidden': 'true' }, '·') : null,
|
|
381
|
+
h('button', { type: 'button', class: 'binder-text-btn', 'data-add': act, onclick: fn }, text)])) : null;
|
|
382
|
+
|
|
285
383
|
/* Notes: small dated messages, and a box to add one. */
|
|
286
384
|
const note = h('textarea', { rows: '2', placeholder: w('add-note'), 'aria-label': w('add-note'), maxlength: '2000', text: ui.draft.note ?? '' });
|
|
287
385
|
note.addEventListener('input', () => { ui.draft.note = note.value; });
|
|
288
|
-
const notes = r.notes.length || can ? h('section', { class: 'binder-sec' }, h('h3', {}, w('notes')),
|
|
386
|
+
const notes = r.notes.length || can ? h('section', { class: 'binder-sec' }, r.notes.length ? h('h3', {}, w('notes')) : null,
|
|
289
387
|
r.notes.map((n) => h('div', { class: 'binder-note', 'data-kind': n.by.kind },
|
|
290
388
|
h('div', { class: 'binder-note-by' }, h('b', {}, n.by.name), ` · ${stamp(n.at)}${n.edited ? ` · ${w('edited')}` : ''}`), h('p', {}, n.text))),
|
|
291
389
|
can ? h('div', { class: 'binder-note-add' }, note, h('button', { type: 'button', class: 'binder-ghost binder-small', 'data-act': 'note',
|
|
@@ -297,7 +395,7 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
|
|
|
297
395
|
const input = h('input', { type: f.type === 'date' ? 'date' : f.type === 'number' ? 'number' : 'text', value: String(v), disabled: !can, 'aria-label': f.name, 'data-field': f.id });
|
|
298
396
|
input.addEventListener('change', () => void soft(ask('edit', { id: r.id, fields: { [f.id]: input.value } })).then(refresh));
|
|
299
397
|
const src = f.type === 'photo' && v ? picture(v) : null;
|
|
300
|
-
return h('label', { class: 'binder-field' }, h('span', {}, f.name), src ? h('img', { src, alt: f.name, class: 'binder-photo' }) : null, input);
|
|
398
|
+
return h('label', { class: 'binder-field' }, h('span', {}, f.name), src ? h('img', { src, alt: f.name, class: 'binder-photo' }) : null, input, f.type === 'photo' && v ? star(`field:${f.id}`) : null);
|
|
301
399
|
};
|
|
302
400
|
const stateName = (id) => l.states.find((s) => s.id === id)?.name ?? id;
|
|
303
401
|
const sure = h('span', { class: 'binder-sure', hidden: true }, w('delete-sure'),
|
|
@@ -306,19 +404,22 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
|
|
|
306
404
|
const more = h('details', { class: 'binder-more', open: ui.more, ontoggle: (e) => { ui.more = e.currentTarget.open; } }, h('summary', { 'data-act': 'more' }, `⋯ ${w('more')}`),
|
|
307
405
|
l.fields.length ? h('div', { class: 'binder-fields' }, l.fields.map(field)) : null,
|
|
308
406
|
h('div', { class: 'binder-history' }, h('h3', {}, `${w('history')} · ${history.length}`),
|
|
309
|
-
history.slice().reverse().map((x) => h('div', { class: 'binder-log' }, h('time', {}, stamp(x.at)), `${x.by.name} · ${x.op}${x.op === 'move' && x.change ? ` → ${stateName(x.change.to)}` : ''}`))),
|
|
407
|
+
history.slice().reverse().map((x) => h('div', { class: 'binder-log' }, h('time', {}, stamp(x.at)), `${x.by.name} · ${words[`op-${x.op}`] ?? x.op}${x.op === 'move' && x.change ? ` → ${stateName(x.change.to)}` : ''}`))),
|
|
310
408
|
can ? h('div', { class: 'binder-actions' },
|
|
409
|
+
/* The state said nothing, so it is not shown: closing the card is here. */
|
|
410
|
+
!stateShown(l, r) ? h('button', { type: 'button', class: 'binder-ghost binder-small', 'data-act': 'to-done', onclick: () => done(ask('move', { id: r.id, state: doneOf(l).id })) }, `✓ ${doneOf(l).name}`) : null,
|
|
311
411
|
h('button', { type: 'button', class: 'binder-ghost binder-small', 'data-act': 'archive', onclick: () => void soft(ask('archive', { id: r.id, restore: !!r.archived })).then(() => { ui.record = null; ui.mode = 'list'; return refresh(); }) }, r.archived ? w('restore') : w('archive')),
|
|
312
412
|
l.grants ? h('button', { type: 'button', class: 'binder-ghost binder-small binder-warn', 'data-act': 'delete', onclick: () => { sure.hidden = false; } }, w('delete')) : null, sure) : null);
|
|
313
413
|
|
|
314
414
|
return h('div', { class: 'binder-page binder-record', 'data-record': r.id },
|
|
315
415
|
h('div', { class: 'binder-crumb' }, h('button', { type: 'button', class: 'binder-text-btn', 'data-act': 'back', onclick: () => { leave(); ui.record = null; ui.mode = 'list'; void refresh(); } }, `‹ ${l.title}`),
|
|
316
416
|
h('span', { class: 'binder-dim' }, `#${r.id}${r.archived ? ` · ${w('archived')}` : ''}`)),
|
|
317
|
-
|
|
318
|
-
|
|
417
|
+
hero,
|
|
418
|
+
h('header', { class: 'binder-head' }, title, factLine(l, r),
|
|
419
|
+
stateShown(l, r) || approve ? h('div', { class: 'binder-status' }, stateShown(l, r) ? h('div', { class: 'binder-state-wrap binder-pop' }, pillOf, menu) : null, approve) : null),
|
|
319
420
|
about,
|
|
320
|
-
h('div', { class: 'binder-who-wrap' }, whoLine, whoEdit),
|
|
321
|
-
attached, notes, more);
|
|
421
|
+
whoLine || whoEdit ? h('div', { class: 'binder-who-wrap' }, whoLine, whoEdit) : null,
|
|
422
|
+
attached, addRow, notes, more);
|
|
322
423
|
}
|
|
323
424
|
|
|
324
425
|
function settings(l) {
|
|
@@ -383,9 +484,10 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
|
|
|
383
484
|
box.replaceChildren(...parts);
|
|
384
485
|
}
|
|
385
486
|
|
|
386
|
-
/*
|
|
387
|
-
const
|
|
388
|
-
const
|
|
487
|
+
/* A menu closes on a tap elsewhere, or Escape. */
|
|
488
|
+
const menus = ['states', 'tools'];
|
|
489
|
+
const away = (e) => { if (menus.includes(ui.open) && !e.target?.closest?.('.binder-pop')) { ui.open = null; paint(); } };
|
|
490
|
+
const escape = (e) => { if (e.key === 'Escape' && menus.includes(ui.open)) { ui.open = null; paint(); } };
|
|
389
491
|
document.addEventListener('pointerdown', away);
|
|
390
492
|
document.addEventListener('keydown', escape);
|
|
391
493
|
void refresh();
|
package/ui/texts/en.json
CHANGED
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
"none": "None",
|
|
34
34
|
"save": "Save",
|
|
35
35
|
"empty": "Empty",
|
|
36
|
-
"nobody": "Nobody",
|
|
37
36
|
"archived": "Archived",
|
|
38
37
|
"show-archived": "Archived",
|
|
39
38
|
"gate": "Needs approval",
|
|
@@ -60,11 +59,25 @@
|
|
|
60
59
|
"no-records": "Nothing here.",
|
|
61
60
|
"search": "Search",
|
|
62
61
|
"description": "Description",
|
|
63
|
-
"add-description": "Add a description",
|
|
64
62
|
"edit": "Edit",
|
|
65
63
|
"more": "Details",
|
|
66
64
|
"approve": "Approve",
|
|
67
|
-
"no-due": "No due date",
|
|
68
65
|
"add": "Add",
|
|
69
|
-
"ok": "OK"
|
|
66
|
+
"ok": "OK",
|
|
67
|
+
"add-what": "Add:",
|
|
68
|
+
"owner": "Owner",
|
|
69
|
+
"attachment": "Attachment",
|
|
70
|
+
"tools": "More",
|
|
71
|
+
"cover": "Cover",
|
|
72
|
+
"op-add": "Created",
|
|
73
|
+
"op-edit": "Edited",
|
|
74
|
+
"op-move": "Moved",
|
|
75
|
+
"op-note": "Note",
|
|
76
|
+
"op-edit-note": "Note edited",
|
|
77
|
+
"op-remove-note": "Note removed",
|
|
78
|
+
"op-attach": "Attached",
|
|
79
|
+
"op-detach": "Detached",
|
|
80
|
+
"op-archive": "Archived",
|
|
81
|
+
"op-restore": "Restored",
|
|
82
|
+
"op-delete": "Deleted"
|
|
70
83
|
}
|
package/ui/texts/fr.json
CHANGED
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
"none": "Aucun",
|
|
34
34
|
"save": "Enregistrer",
|
|
35
35
|
"empty": "Vide",
|
|
36
|
-
"nobody": "Personne",
|
|
37
36
|
"archived": "Archivées",
|
|
38
37
|
"show-archived": "Archivées",
|
|
39
38
|
"gate": "À valider",
|
|
@@ -60,11 +59,25 @@
|
|
|
60
59
|
"no-records": "Rien ici.",
|
|
61
60
|
"search": "Chercher",
|
|
62
61
|
"description": "Description",
|
|
63
|
-
"add-description": "Ajouter une description",
|
|
64
62
|
"edit": "Modifier",
|
|
65
63
|
"more": "Détails",
|
|
66
64
|
"approve": "Valider",
|
|
67
|
-
"no-due": "Sans échéance",
|
|
68
65
|
"add": "Ajouter",
|
|
69
|
-
"ok": "OK"
|
|
66
|
+
"ok": "OK",
|
|
67
|
+
"add-what": "Ajouter :",
|
|
68
|
+
"owner": "Responsable",
|
|
69
|
+
"attachment": "Pièce jointe",
|
|
70
|
+
"tools": "Plus",
|
|
71
|
+
"cover": "À la une",
|
|
72
|
+
"op-add": "Créée",
|
|
73
|
+
"op-edit": "Modifiée",
|
|
74
|
+
"op-move": "Déplacée",
|
|
75
|
+
"op-note": "Note",
|
|
76
|
+
"op-edit-note": "Note modifiée",
|
|
77
|
+
"op-remove-note": "Note retirée",
|
|
78
|
+
"op-attach": "Attaché",
|
|
79
|
+
"op-detach": "Détaché",
|
|
80
|
+
"op-archive": "Archivée",
|
|
81
|
+
"op-restore": "Restaurée",
|
|
82
|
+
"op-delete": "Supprimée"
|
|
70
83
|
}
|