@arcforgelabs/flow 2026.10.25 → 2026.10.27
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/README.md +6 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +3 -1
- package/server/api/invoicing.js +586 -0
- package/server/app.js +38 -1
- package/server/auth/people.js +26 -0
- package/server/auth/routes.js +13 -0
- package/server/core/automations.js +140 -0
- package/server/core/invoice-rules.js +224 -0
- package/server/core/invoices.js +11 -1
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/ledger.js +50 -14
- package/server/core/links.js +3 -3
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +495 -0
- package/server/core/sending.js +282 -0
- package/server/db/migrations/0008_people_appearance.sql +1 -0
- package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
- package/server/db/migrations/0010_invoicing_rules.sql +51 -0
- package/server/db/migrations/0011_invoice_rules.sql +21 -0
- package/server/db/migrations/0012_invoice_sending.sql +9 -0
- package/server/db/migrations/meta/0008_snapshot.json +2479 -0
- package/server/db/migrations/meta/0009_snapshot.json +2809 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/_journal.json +35 -0
- package/server/db/schema.js +186 -2
- package/server/index.js +20 -3
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +8 -0
- package/server/integrations/seazona/feed.js +11 -0
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/ledger.js +4 -0
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +123 -16
- package/server/integrations/xero/import.js +22 -0
- package/server/integrations/xero/index.js +21 -3
- package/server/integrations/xero/lookup.js +94 -0
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/theme/color-contrast.js +39 -2
- package/server/theme/contrast-audit.js +63 -0
- package/server/theme/themes.js +10 -2
- package/server/ui/app-page.js +6 -10
- package/server/ui/app.js +364 -105
- package/server/ui/appearance.js +3 -2
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +18 -2
- package/server/ui/flow.css +334 -22
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +108 -0
- package/server/ui/invoicing.js +597 -0
- package/server/ui/jobs.js +172 -0
- package/server/ui/logos/seazona.svg +1 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/logos/xero.svg +1 -0
- package/server/ui/monitoring.js +53 -0
- package/server/ui/routes.js +5 -1
package/server/ui/app.js
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
import { getAppearance, onAppearanceChange, setAppearance, loadFonts } from '/assets/ui/appearance.js';
|
|
10
10
|
import { flowClerkAppearance } from '/assets/ui/clerk-appearance.js';
|
|
11
11
|
import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/assets/theme/themes.js';
|
|
12
|
+
import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
13
|
+
import { automationSection } from '/assets/ui/automations.js';
|
|
14
|
+
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
15
|
+
import { renderJobs } from '/assets/ui/jobs.js';
|
|
16
|
+
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
12
17
|
|
|
13
18
|
const $ = (id) => document.getElementById(id);
|
|
14
19
|
|
|
@@ -47,6 +52,8 @@ function clerkAppearance(kind) {
|
|
|
47
52
|
function mountClerk(kind, element, options = {}) {
|
|
48
53
|
const Clerk = window.Clerk;
|
|
49
54
|
const props = { ...options, appearance: clerkAppearance(kind) };
|
|
55
|
+
// The "Manage account" modal does not inherit the button's appearance.
|
|
56
|
+
if (kind === 'userButton') props.userProfileProps = { ...options.userProfileProps, appearance: clerkAppearance('modal') };
|
|
50
57
|
if (kind === 'signIn') Clerk.mountSignIn(element, props);
|
|
51
58
|
else if (kind === 'userButton') Clerk.mountUserButton(element, props);
|
|
52
59
|
else if (kind === 'profile') Clerk.mountUserProfile(element, props);
|
|
@@ -70,8 +77,10 @@ function unmountAllIn(container) {
|
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
// Clerk reads its appearance when a component mounts, so a new appearance
|
|
73
|
-
// remounts each one (after the tokens have changed on <html>).
|
|
80
|
+
// remounts each one (after the tokens have changed on <html>). Modals Clerk
|
|
81
|
+
// opens itself use the appearance from Clerk.load(), so update that too.
|
|
74
82
|
onAppearanceChange(() => {
|
|
83
|
+
window.Clerk?.__internal_updateProps?.({ appearance: clerkAppearance('modal') });
|
|
75
84
|
for (const [element, { kind, options }] of [...mounted]) {
|
|
76
85
|
unmountClerk(element);
|
|
77
86
|
mountClerk(kind, element, options);
|
|
@@ -109,7 +118,7 @@ async function flow(path, options = {}) {
|
|
|
109
118
|
headers: { Authorization: `Bearer ${token}`, ...(options.body ? { 'Content-Type': 'application/json' } : {}) },
|
|
110
119
|
});
|
|
111
120
|
const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
|
|
112
|
-
if (!res.ok) throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { status: res.status, code: body.code });
|
|
121
|
+
if (!res.ok) throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { status: res.status, code: body.code, details: body.details });
|
|
113
122
|
return body;
|
|
114
123
|
}
|
|
115
124
|
|
|
@@ -180,42 +189,77 @@ function roleSelect(value, attrs = {}) {
|
|
|
180
189
|
|
|
181
190
|
let me = null;
|
|
182
191
|
|
|
192
|
+
// The sidebar lists the views in this order, under their group's heading.
|
|
183
193
|
const VIEWS = {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
194
|
+
jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
|
|
195
|
+
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
196
|
+
'invoicing-settings': { label: 'Invoicing', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
|
|
197
|
+
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
198
|
+
integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
199
|
+
account: { label: 'Account', icon: 'person', group: 'Settings', render: renderAccount },
|
|
200
|
+
appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
|
|
188
201
|
};
|
|
189
202
|
|
|
203
|
+
// Line icons for the sidebar, drawn on a 24-unit grid in the text colour.
|
|
204
|
+
const ICON_PATHS = {
|
|
205
|
+
jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
|
|
206
|
+
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
207
|
+
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
|
|
208
|
+
plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
|
|
209
|
+
person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
|
|
210
|
+
flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
|
|
211
|
+
palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
function icon(name) {
|
|
215
|
+
const node = h('span', { class: 'fx-ico', 'aria-hidden': 'true' });
|
|
216
|
+
node.innerHTML = `<svg viewBox="0 0 24 24">${ICON_PATHS[name] ?? ''}</svg>`;
|
|
217
|
+
return node;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The view and, for one record, its id: /invoices/<id> is ['invoices', '<id>'].
|
|
190
221
|
function currentView() {
|
|
191
|
-
const name = location.pathname.replace(/^\/+|\/+$/g, '');
|
|
192
|
-
if (VIEWS[name] && (!VIEWS[name].ownerOnly || me?.person.role === 'owner')) return name;
|
|
193
|
-
return
|
|
222
|
+
const [name, id = null] = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
|
|
223
|
+
if (VIEWS[name] && (!VIEWS[name].ownerOnly || me?.person.role === 'owner')) return [name, id];
|
|
224
|
+
return ['jobs', null];
|
|
194
225
|
}
|
|
195
226
|
|
|
196
227
|
async function route() {
|
|
197
228
|
const main = $('main');
|
|
198
229
|
unmountAllIn(main);
|
|
199
|
-
const view = currentView();
|
|
200
|
-
|
|
230
|
+
const [view, id] = currentView();
|
|
231
|
+
const path = id ? `/${view}/${id}` : `/${view}`;
|
|
232
|
+
if (location.pathname !== path) history.replaceState(null, '', path);
|
|
201
233
|
for (const link of document.querySelectorAll('[data-nav]')) {
|
|
202
234
|
link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
|
|
203
235
|
}
|
|
204
236
|
main.replaceChildren();
|
|
237
|
+
main.className = 'fx-main';
|
|
205
238
|
document.title = `${VIEWS[view].label} · Flow`;
|
|
206
|
-
await VIEWS[view].render(main);
|
|
239
|
+
await VIEWS[view].render(main, id);
|
|
207
240
|
main.focus({ preventScroll: true });
|
|
208
241
|
}
|
|
209
242
|
|
|
210
|
-
//
|
|
243
|
+
// Opens a path in place; the address changes, the page stays.
|
|
244
|
+
function go(path, { replace = false } = {}) {
|
|
245
|
+
if (path !== location.pathname) history[replace ? 'replaceState' : 'pushState'](null, '', path);
|
|
246
|
+
route();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// A link's click opens it in place (a new tab still works).
|
|
211
250
|
function navigate(event) {
|
|
212
251
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
|
|
213
252
|
event.preventDefault();
|
|
214
|
-
|
|
215
|
-
if (href !== location.pathname) history.pushState(null, '', href);
|
|
216
|
-
route();
|
|
253
|
+
go(event.currentTarget.getAttribute('href'));
|
|
217
254
|
}
|
|
218
255
|
|
|
256
|
+
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
257
|
+
// takes a path, or a link's click event.
|
|
258
|
+
const ui = () => ({
|
|
259
|
+
h, flow, toast, notice, pageHeader, dialog, me,
|
|
260
|
+
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
261
|
+
});
|
|
262
|
+
|
|
219
263
|
function renderSignedOut() {
|
|
220
264
|
document.body.dataset.state = 'signed-out';
|
|
221
265
|
document.title = 'Sign in · Flow';
|
|
@@ -236,10 +280,21 @@ function renderSignedOut() {
|
|
|
236
280
|
function renderShell() {
|
|
237
281
|
document.body.dataset.state = 'signed-in';
|
|
238
282
|
const nav = $('nav');
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
283
|
+
const items = [];
|
|
284
|
+
let group = null;
|
|
285
|
+
for (const [name, view] of Object.entries(VIEWS)) {
|
|
286
|
+
if (view.ownerOnly && me?.person.role !== 'owner') continue;
|
|
287
|
+
if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
|
|
288
|
+
group = view.group;
|
|
289
|
+
items.push(h('a', { href: `/${name}`, 'data-nav': name, onclick: navigate }, icon(view.icon), h('span', {}, view.label)));
|
|
290
|
+
}
|
|
291
|
+
nav.replaceChildren(...items);
|
|
242
292
|
mountClerk('userButton', $('user-button'), { afterSignOutUrl: '/sign-in', showName: false });
|
|
293
|
+
// Errors and problem reports go to the installation's own Sentry, when the
|
|
294
|
+
// Sentry integration is on (monitoring.js). Flow works the same without it.
|
|
295
|
+
$('report-problem').replaceChildren(icon('flag'));
|
|
296
|
+
startMonitoring({ flow, person: me.person, button: $('report-problem') })
|
|
297
|
+
.catch((error) => console.warn(`[monitoring] ${error.message}`));
|
|
243
298
|
window.addEventListener('popstate', route);
|
|
244
299
|
return route();
|
|
245
300
|
}
|
|
@@ -348,39 +403,138 @@ function personRow(person, host) {
|
|
|
348
403
|
h('td', { class: 'fx-table__actions' }, h('div', { class: 'fx-row fx-row--end' }, actions)));
|
|
349
404
|
}
|
|
350
405
|
|
|
351
|
-
// Integrations:
|
|
352
|
-
//
|
|
406
|
+
// Integrations: a classic catalogue. Each system is a card with its logo;
|
|
407
|
+
// the card opens a side panel where an owner turns it on and enters its
|
|
408
|
+
// settings. Credentials are write-only: the server says whether each is set,
|
|
353
409
|
// never its value (server/integrations/setup.js).
|
|
354
|
-
const STATE_LABEL = { ready: '
|
|
355
|
-
const STATE_TONE = { ready: 'success', disabled: '
|
|
410
|
+
const STATE_LABEL = { ready: 'Connected', disabled: 'Not set up', failed: 'Needs attention', restart_needed: 'Restart needed', not_started: 'Starting' };
|
|
411
|
+
const STATE_TONE = { ready: 'success', disabled: 'mute', failed: 'danger', restart_needed: 'warn', not_started: 'info' };
|
|
412
|
+
|
|
413
|
+
// What the catalogue shows for each adapter: its group, one line on what it
|
|
414
|
+
// does, and its logo (server/ui/logos/, each maker's own mark).
|
|
415
|
+
const CATALOGUE = {
|
|
416
|
+
xero: { maker: 'Xero', category: 'accounting', blurb: 'Send invoices to Xero as drafts and bring payments back.', logo: 'xero.svg' },
|
|
417
|
+
seazona: { maker: 'Seazona', category: 'lab', blurb: 'Bring lab cases and orders into Flow as jobs.', logo: 'seazona.svg' },
|
|
418
|
+
sentry: { maker: 'Sentry', category: 'monitoring', blurb: 'Send errors and problem reports to your own Sentry project.', logo: 'sentry.svg' },
|
|
419
|
+
};
|
|
420
|
+
const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software'], ['monitoring', 'Monitoring']];
|
|
421
|
+
const CATEGORY_LABEL = Object.fromEntries(CATEGORIES);
|
|
422
|
+
|
|
423
|
+
function integrationLogo(integration, size = '') {
|
|
424
|
+
const file = CATALOGUE[integration.id]?.logo;
|
|
425
|
+
return h('span', { class: `fx-logo${size ? ` fx-logo--${size}` : ''}`, 'aria-hidden': 'true' },
|
|
426
|
+
file ? h('img', { src: `/assets/ui/logos/${file}`, alt: '' }) : icon('plug'));
|
|
427
|
+
}
|
|
356
428
|
|
|
357
429
|
async function renderIntegrations(main) {
|
|
358
|
-
const
|
|
430
|
+
const state = { tab: 'all', category: 'all', q: '' };
|
|
431
|
+
const tabs = h('div', { class: 'fx-seg', role: 'tablist' });
|
|
432
|
+
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Search integrations', 'aria-label': 'Search integrations' });
|
|
433
|
+
const category = h('select', { class: 'fx-select', 'aria-label': 'Category' },
|
|
434
|
+
CATEGORIES.map(([id, label]) => h('option', { value: id }, id === 'all' ? 'All categories' : label)));
|
|
435
|
+
const notices = h('div', { class: 'fx-fields' });
|
|
436
|
+
const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
|
|
359
437
|
main.append(
|
|
360
|
-
|
|
361
|
-
|
|
438
|
+
h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, h('h1', { class: 'fx-title' }, 'Integrations'))),
|
|
439
|
+
h('div', { class: 'fx-int-bar' }, tabs, search),
|
|
440
|
+
notices, content);
|
|
362
441
|
let body;
|
|
363
442
|
try {
|
|
364
443
|
body = await flow('/api/integrations');
|
|
365
444
|
} catch (error) {
|
|
366
|
-
|
|
445
|
+
content.replaceChildren(notice(error.message, 'danger'));
|
|
367
446
|
return;
|
|
368
447
|
}
|
|
369
448
|
const canChange = Boolean(me.ownerActions?.allowed);
|
|
370
|
-
|
|
449
|
+
notices.replaceChildren(...[
|
|
371
450
|
canChange ? null : notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
|
|
372
451
|
body.secretsKey ? null : notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
|
|
373
|
-
...body.integrations.map((integration) => integrationCard(integration, { canChange, organisations: body.organisations })),
|
|
374
452
|
].filter(Boolean));
|
|
375
|
-
|
|
453
|
+
const list = [...body.integrations].sort((a, b) => a.name.localeCompare(b.name));
|
|
454
|
+
const connected = (i) => i.enabled;
|
|
455
|
+
|
|
456
|
+
const draw = () => {
|
|
457
|
+
tabs.replaceChildren(...[['all', 'All integrations'], ['yours', 'Your integrations']].map(([id, label]) => h('button', {
|
|
458
|
+
type: 'button', role: 'tab', class: 'fx-seg__item', 'aria-selected': String(state.tab === id), 'aria-checked': String(state.tab === id),
|
|
459
|
+
onclick: () => { state.tab = id; draw(); },
|
|
460
|
+
}, label)));
|
|
461
|
+
const q = state.q.toLowerCase();
|
|
462
|
+
const matches = (i) => !q || `${i.name} ${CATALOGUE[i.id]?.blurb ?? ''}`.toLowerCase().includes(q);
|
|
463
|
+
const sections = [];
|
|
464
|
+
if (state.tab === 'yours') {
|
|
465
|
+
const yours = list.filter(connected).filter(matches);
|
|
466
|
+
sections.push(h('section', { class: 'fx-int-section-list' },
|
|
467
|
+
yours.length ? h('div', { class: 'fx-int-grid' }, yours.map(integrationTile))
|
|
468
|
+
: h('p', { class: 'fx-empty' }, q ? 'No integration matches.' : 'Nothing is connected yet. Pick one under All integrations.')));
|
|
469
|
+
} else {
|
|
470
|
+
// Recommended: what is not connected yet, once the catalogue is long
|
|
471
|
+
// enough that a short row helps (with a few, it would repeat the grid).
|
|
472
|
+
const recommended = q || list.length < 4 ? [] : list.filter((i) => !connected(i)).slice(0, 3);
|
|
473
|
+
if (recommended.length) {
|
|
474
|
+
sections.push(h('section', { class: 'fx-int-section-list' },
|
|
475
|
+
h('h2', { class: 'fx-int-heading' }, 'Recommended for you'),
|
|
476
|
+
h('div', { class: 'fx-int-grid' }, recommended.map(integrationTile))));
|
|
477
|
+
}
|
|
478
|
+
const all = list.filter(matches).filter((i) => state.category === 'all' || CATALOGUE[i.id]?.category === state.category);
|
|
479
|
+
sections.push(h('section', { class: 'fx-int-section-list' },
|
|
480
|
+
h('div', { class: 'fx-int-heading-row' }, h('h2', { class: 'fx-int-heading' }, 'All integrations'), category),
|
|
481
|
+
all.length ? h('div', { class: 'fx-int-grid' }, all.map(integrationTile)) : h('p', { class: 'fx-empty' }, 'No integration matches.')));
|
|
482
|
+
}
|
|
483
|
+
content.replaceChildren(...sections);
|
|
484
|
+
};
|
|
485
|
+
search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
|
|
486
|
+
category.addEventListener('change', () => { state.category = category.value; draw(); });
|
|
487
|
+
draw();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function integrationTile(integration) {
|
|
491
|
+
const href = `/integrations/${integration.id}`;
|
|
492
|
+
const entry = CATALOGUE[integration.id];
|
|
493
|
+
const on = integration.enabled;
|
|
494
|
+
const attention = ['failed', 'restart_needed'].includes(integration.state);
|
|
495
|
+
return h('a', { class: 'fx-int-card', href, onclick: navigate },
|
|
496
|
+
h('header', { class: 'fx-int-card__head' },
|
|
497
|
+
integrationLogo(integration),
|
|
498
|
+
h('h3', {}, integration.name),
|
|
499
|
+
on ? h('span', { class: 'fx-status', 'data-tone': STATE_TONE[integration.state] ?? 'mute' }, STATE_LABEL[integration.state] ?? integration.state) : null),
|
|
500
|
+
h('p', { class: 'fx-int-card__blurb' }, entry?.blurb ?? ''),
|
|
501
|
+
h('span', { class: 'fx-int-card__btn' }, attention ? 'Fix' : on ? 'Manage' : '+ Connect'));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// One integration's page (/integrations/<id>): its logo and maker, Connect
|
|
505
|
+
// with each step shown as it happens, the setup steps, its settings, and the
|
|
506
|
+
// facts beside them.
|
|
507
|
+
async function renderIntegration(main, id) {
|
|
508
|
+
const host = h('div', { class: 'fx-int-page' }, h('div', { class: 'fx-skeleton' }));
|
|
509
|
+
main.append(host);
|
|
510
|
+
let body;
|
|
511
|
+
try {
|
|
512
|
+
body = await flow('/api/integrations');
|
|
513
|
+
} catch (error) {
|
|
514
|
+
host.replaceChildren(notice(error.message, 'danger'));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const found = body.integrations.find((i) => i.id === id);
|
|
518
|
+
if (!found) {
|
|
519
|
+
host.replaceChildren(notice('No integration by that name.', 'danger'));
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const canChange = Boolean(me.ownerActions?.allowed);
|
|
376
523
|
|
|
377
|
-
|
|
378
|
-
const card = h('section', { class: 'fx-panel', 'aria-label': integration.name });
|
|
379
|
-
const draw = (current) => {
|
|
524
|
+
const draw = (current, progress = null) => {
|
|
380
525
|
const inputs = new Map();
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
526
|
+
const rows = [];
|
|
527
|
+
for (const field of current.fields) {
|
|
528
|
+
if (field.source === 'host') {
|
|
529
|
+
// The host sets it: a value to read, not a box to type in.
|
|
530
|
+
rows.push(settingRow({
|
|
531
|
+
label: field.label,
|
|
532
|
+
hint: 'Set by whoever hosts Flow.',
|
|
533
|
+
control: field.secret ? h('span', { class: 'fx-dim' }, 'Set') : h('code', { class: 'fx-setting__value' }, field.value),
|
|
534
|
+
}));
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const disabled = !canChange;
|
|
384
538
|
let input;
|
|
385
539
|
if (field.kind === 'choice' || field.kind === 'onoff') {
|
|
386
540
|
const choices = field.kind === 'onoff' ? [{ value: 'off', label: 'Off' }, { value: 'on', label: 'On' }] : field.choices;
|
|
@@ -388,40 +542,64 @@ function integrationCard(integration, { canChange, organisations }) {
|
|
|
388
542
|
field.kind === 'choice' ? h('option', { value: '' }, 'Choose…') : null,
|
|
389
543
|
choices.map((c) => h('option', { value: c.value, selected: (field.value ?? (field.kind === 'onoff' ? 'off' : '')) === c.value }, c.label)));
|
|
390
544
|
} else if (field.secret) {
|
|
391
|
-
input = h('input', { class: 'fx-input', type: 'password', autocomplete: 'off', disabled, placeholder: field.set ? '
|
|
545
|
+
input = h('input', { class: 'fx-input', type: 'password', autocomplete: 'off', disabled, placeholder: field.set ? 'Saved' : 'Paste it here' });
|
|
392
546
|
} else {
|
|
393
547
|
input = h('input', { class: 'fx-input', type: field.kind === 'url' ? 'url' : 'text', autocomplete: 'off', disabled, value: field.value ?? '' });
|
|
394
548
|
}
|
|
395
549
|
inputs.set(field.name, { field, input });
|
|
396
550
|
const suggestion = field.suggest === 'callback' ? `${location.origin}/api/${current.id}/oauth/callback` : null;
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { input.value = suggestion; } }, 'Use this')) : null);
|
|
407
|
-
});
|
|
551
|
+
rows.push(settingRow({
|
|
552
|
+
label: field.label,
|
|
553
|
+
hint: field.unreadable ? 'Stored with another key. Enter it again.' : field.help,
|
|
554
|
+
control: suggestion && !disabled
|
|
555
|
+
? h('div', { class: 'fx-setting__stack' }, input,
|
|
556
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', title: suggestion, onclick: () => { input.value = suggestion; navigator.clipboard?.writeText(suggestion); toast('Address filled in and copied.', 'success'); } }, 'Use this installation\'s address'))
|
|
557
|
+
: input,
|
|
558
|
+
}));
|
|
559
|
+
}
|
|
408
560
|
|
|
409
561
|
const values = () => {
|
|
410
562
|
const out = {};
|
|
411
563
|
for (const [name, { field, input }] of inputs) {
|
|
412
|
-
if (field.source === 'host') continue;
|
|
413
564
|
const value = input.value.trim();
|
|
414
565
|
if (field.secret) { if (value) out[name] = value; continue; }
|
|
415
566
|
if (value !== (field.value ?? (field.kind === 'onoff' ? 'off' : ''))) out[name] = value || null;
|
|
416
567
|
}
|
|
417
568
|
return out;
|
|
418
569
|
};
|
|
570
|
+
const put = (enabled) => flow(`/api/integrations/${current.id}`, { method: 'PUT', body: JSON.stringify({ values: values(), ...(enabled === undefined ? {} : { enabled }) }) });
|
|
571
|
+
|
|
572
|
+
// Connect: each step is shown as it runs, then ticked or crossed.
|
|
573
|
+
const connect = async () => {
|
|
574
|
+
const steps = [
|
|
575
|
+
{ label: 'Saving settings', state: 'running' },
|
|
576
|
+
{ label: `Starting ${current.name}`, state: 'waiting' },
|
|
577
|
+
];
|
|
578
|
+
const started = Date.now();
|
|
579
|
+
const show = () => draw(current, { steps, started });
|
|
580
|
+
show();
|
|
581
|
+
let next;
|
|
582
|
+
try {
|
|
583
|
+
next = (await put(true)).integration;
|
|
584
|
+
} catch (error) {
|
|
585
|
+
steps[0] = { label: error.message, state: 'failed' };
|
|
586
|
+
show();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
steps[0].state = 'done';
|
|
590
|
+
steps[1].state = next.state === 'ready' ? 'done' : 'failed';
|
|
591
|
+
if (next.state !== 'ready') steps[1].label = next.error ? `${current.name} could not start: ${next.error}` : `${current.name}: ${STATE_LABEL[next.state] ?? next.state}`;
|
|
592
|
+
if (next.state === 'ready') steps.push({ label: `${current.name} is connected`, state: 'done' });
|
|
593
|
+
current = next;
|
|
594
|
+
show();
|
|
595
|
+
setTimeout(() => { if (steps.every((step) => step.state === 'done')) draw(current); }, 2400);
|
|
596
|
+
};
|
|
419
597
|
const save = async (enabled) => {
|
|
420
598
|
try {
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
draw(
|
|
599
|
+
const next = (await put(enabled)).integration;
|
|
600
|
+
toast(enabled === false ? `${next.name} is disconnected.` : `${next.name} settings saved.`, 'success');
|
|
601
|
+
current = next;
|
|
602
|
+
draw(current);
|
|
425
603
|
} catch (error) {
|
|
426
604
|
toast(error.message, 'danger');
|
|
427
605
|
}
|
|
@@ -429,64 +607,121 @@ function integrationCard(integration, { canChange, organisations }) {
|
|
|
429
607
|
|
|
430
608
|
const on = current.enabled;
|
|
431
609
|
const guide = current.guide;
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
610
|
+
const hostOwned = current.enabledBy === 'host';
|
|
611
|
+
const entry = CATALOGUE[current.id];
|
|
612
|
+
const mode = current.fields.find((f) => /_MODE$/.test(f.name));
|
|
613
|
+
const modeLabel = mode?.choices?.find((c) => c.value === mode.value)?.label ?? mode?.value;
|
|
614
|
+
const busy = progress && progress.steps.some((step) => step.state === 'running');
|
|
615
|
+
|
|
616
|
+
const primary = !canChange || hostOwned ? null : on
|
|
617
|
+
? h('button', { class: 'fx-btn', type: 'button', onclick: () => save(false) }, 'Disconnect')
|
|
618
|
+
: h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: busy, onclick: connect },
|
|
619
|
+
busy ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : null, busy ? 'Connecting' : 'Connect');
|
|
620
|
+
|
|
621
|
+
host.replaceChildren(
|
|
622
|
+
h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
|
|
623
|
+
h('a', { href: '/integrations', onclick: navigate }, 'Integrations'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, current.name)),
|
|
624
|
+
h('header', { class: 'fx-int-hero' },
|
|
625
|
+
integrationLogo(current, 'xl'),
|
|
626
|
+
h('div', { class: 'fx-int-hero__text' },
|
|
627
|
+
h('h1', { class: 'fx-title' }, current.name),
|
|
628
|
+
h('p', { class: 'fx-int-hero__maker' }, entry?.maker ?? current.name, h('span', { class: 'fx-dim' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')),
|
|
629
|
+
h('p', { class: 'fx-int-hero__blurb' }, entry?.blurb ?? ''),
|
|
630
|
+
h('div', { class: 'fx-int-hero__actions' },
|
|
631
|
+
primary,
|
|
632
|
+
on ? h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute' }, STATE_LABEL[current.state] ?? current.state) : null,
|
|
633
|
+
progress ? connectProgress(progress) : null))),
|
|
634
|
+
h('div', { class: 'fx-int-page__grid' },
|
|
635
|
+
h('div', { class: 'fx-int-page__main' }, ...[
|
|
636
|
+
!canChange ? notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.') : null,
|
|
637
|
+
!body.secretsKey ? notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
|
|
638
|
+
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
639
|
+
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
640
|
+
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
641
|
+
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
|
|
642
|
+
guide ? h('section', { class: 'fx-int-section' },
|
|
643
|
+
h('h2', { class: 'fx-int-section__title' }, 'Set up'),
|
|
644
|
+
h('ol', { class: 'fx-steps' }, guide.steps.map((step) => h('li', {}, step))),
|
|
645
|
+
guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' }, `Open ${current.name} developer site ↗`) : null) : null,
|
|
646
|
+
rows.length ? h('section', { class: 'fx-int-section' },
|
|
647
|
+
h('h2', { class: 'fx-int-section__title' }, 'Settings'),
|
|
648
|
+
h('div', { class: 'fx-settings' }, rows),
|
|
649
|
+
canChange && on && inputs.size ? h('div', { class: 'fx-row fx-row--end' }, h('button', { class: 'fx-btn', type: 'button', onclick: () => save(undefined) }, 'Save changes')) : null) : null,
|
|
650
|
+
].filter(Boolean)),
|
|
651
|
+
h('aside', { class: 'fx-int-page__meta' },
|
|
652
|
+
h('dl', { class: 'fx-meta' }, ...[
|
|
653
|
+
['Status', h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute' }, STATE_LABEL[current.state] ?? current.state)],
|
|
654
|
+
['Category', h('span', { class: 'fx-chip fx-chip--static' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')],
|
|
655
|
+
modeLabel ? ['Mode', modeLabel] : null,
|
|
656
|
+
['Turned on by', current.enabledBy === 'host' ? 'The host' : current.enabledBy === 'flow' ? 'An owner, here' : '—'],
|
|
657
|
+
current.updatedAt ? ['Last changed', new Date(current.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })] : null,
|
|
658
|
+
guide?.link ? ['Documentation', h('a', { href: guide.link, target: '_blank', rel: 'noopener' }, 'Developer site ↗')] : null,
|
|
659
|
+
].filter(Boolean).map(([label, value]) => h('div', {}, h('dt', {}, label), h('dd', {}, value)))))));
|
|
450
660
|
};
|
|
451
|
-
|
|
452
|
-
|
|
661
|
+
document.title = `${found.name} · Flow`;
|
|
662
|
+
draw(found);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// One setting, laid out the same on every settings screen: a short name, one
|
|
666
|
+
// line of smaller text under it, and the control on the right. Longer help
|
|
667
|
+
// lives behind a link, not here. A wide control (a grid of choices) sits on
|
|
668
|
+
// its own line under the name.
|
|
669
|
+
function settingRow({ label, hint, control, wide = false }) {
|
|
670
|
+
const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
|
|
671
|
+
const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
|
|
672
|
+
return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}` },
|
|
673
|
+
h('div', { class: 'fx-setting__text' },
|
|
674
|
+
id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
|
|
675
|
+
hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
|
|
676
|
+
h('div', { class: 'fx-setting__control' }, control));
|
|
677
|
+
}
|
|
678
|
+
settingRow.next = 0;
|
|
679
|
+
|
|
680
|
+
// The steps of a Connect, each ticked, running or crossed, with the time taken.
|
|
681
|
+
function connectProgress({ steps, started }) {
|
|
682
|
+
const seconds = Math.max(0, Math.round((Date.now() - started) / 1000));
|
|
683
|
+
return h('div', { class: 'fx-progress', role: 'status' },
|
|
684
|
+
h('div', { class: 'fx-progress__head' }, h('span', { class: 'fx-strong' }, 'Connecting'), h('span', { class: 'fx-dim' }, `${seconds}s`)),
|
|
685
|
+
h('ol', {}, steps.map((step) => h('li', { 'data-state': step.state },
|
|
686
|
+
h('span', { class: 'fx-progress__mark', 'aria-hidden': 'true' }, step.state === 'done' ? '✓' : step.state === 'failed' ? '!' : ''),
|
|
687
|
+
step.label))));
|
|
453
688
|
}
|
|
454
689
|
|
|
455
690
|
// Once Xero is on: each organisation's connection, the history copy, and
|
|
456
691
|
// Connect, which goes to Xero and comes back.
|
|
457
692
|
function xeroConnection(organisations, canChange) {
|
|
458
|
-
const host = h('div', { class: 'fx-
|
|
693
|
+
const host = h('div', { class: 'fx-settings' });
|
|
459
694
|
for (const organisation of organisations) {
|
|
460
|
-
const row =
|
|
695
|
+
const row = settingRow({ label: organisation.name, hint: 'Checking…' });
|
|
461
696
|
host.append(row);
|
|
462
697
|
(async () => {
|
|
698
|
+
let hint;
|
|
699
|
+
let control = null;
|
|
463
700
|
try {
|
|
464
701
|
const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
|
|
465
|
-
const parts = [h('span', {}, organisation.name)];
|
|
466
702
|
if (status.connected) {
|
|
467
|
-
parts.push(h('span', { class: 'fx-tag', 'data-tone': 'success' }, `Connected to ${status.connection?.tenantName ?? 'Xero'}`));
|
|
468
703
|
const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
|
|
469
|
-
|
|
704
|
+
hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.` : ''}`;
|
|
470
705
|
} else {
|
|
471
|
-
|
|
706
|
+
hint = 'Not connected.';
|
|
472
707
|
}
|
|
473
708
|
if (canChange) {
|
|
474
|
-
|
|
709
|
+
control = h('button', { class: `fx-btn fx-btn--sm${status.connected ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
475
710
|
try {
|
|
476
711
|
const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
|
|
477
712
|
location.assign(authorizeUrl);
|
|
478
713
|
} catch (error) {
|
|
479
714
|
toast(error.message, 'danger');
|
|
480
715
|
}
|
|
481
|
-
} }, status.connected ? 'Connect again' : 'Connect to Xero')
|
|
716
|
+
} }, status.connected ? 'Connect again' : 'Connect to Xero');
|
|
482
717
|
}
|
|
483
|
-
row.replaceChildren(...parts);
|
|
484
718
|
} catch (error) {
|
|
485
|
-
|
|
719
|
+
hint = error.message;
|
|
486
720
|
}
|
|
721
|
+
row.replaceWith(settingRow({ label: organisation.name, hint, control }));
|
|
487
722
|
})();
|
|
488
723
|
}
|
|
489
|
-
return h('div', {}, h('
|
|
724
|
+
return h('div', {}, h('h2', { class: 'fx-int-section__title' }, 'Xero organisation'), host);
|
|
490
725
|
}
|
|
491
726
|
|
|
492
727
|
async function invitePerson() {
|
|
@@ -524,11 +759,13 @@ function renderAccount(main) {
|
|
|
524
759
|
mountClerk('profile', host, { routing: 'virtual' });
|
|
525
760
|
}
|
|
526
761
|
|
|
527
|
-
// Appearance: light or dark, the design system and an accent colour
|
|
528
|
-
//
|
|
762
|
+
// Appearance: light or dark, the design system and an accent colour. Each
|
|
763
|
+
// person's choice is saved with their account (PUT /api/me/appearance) and
|
|
764
|
+
// applied when they sign in; the browser keeps a copy so the next page paints
|
|
765
|
+
// in it at once.
|
|
529
766
|
function renderAppearance(main) {
|
|
530
767
|
main.append(
|
|
531
|
-
pageHeader('Appearance', { subtitle: '
|
|
768
|
+
pageHeader('Appearance', { subtitle: 'Saved with your account, on every device.' }),
|
|
532
769
|
h('div', { class: 'fx-appearance', id: 'appearance' }));
|
|
533
770
|
drawAppearance();
|
|
534
771
|
}
|
|
@@ -564,13 +801,13 @@ function drawAppearance() {
|
|
|
564
801
|
|
|
565
802
|
const modes = [{ value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }, { value: 'system', label: 'Auto' }];
|
|
566
803
|
const modeGroup = radioGroup('Mode', modes, modes.findIndex((m) => m.value === colorMode),
|
|
567
|
-
(m) =>
|
|
804
|
+
(m) => chooseAppearance({ colorMode: m.value }),
|
|
568
805
|
(m) => h('button', { type: 'button', class: 'fx-seg__item' }, m.label));
|
|
569
806
|
modeGroup.classList.add('fx-seg');
|
|
570
807
|
|
|
571
808
|
const familyIndex = THEME_FAMILIES.findIndex((f) => f.id === family);
|
|
572
809
|
const families = radioGroup('Design system', THEME_FAMILIES, familyIndex,
|
|
573
|
-
(f) =>
|
|
810
|
+
(f) => chooseAppearance({ family: f.id }),
|
|
574
811
|
(f) => {
|
|
575
812
|
// The card shows the family as it is, not the active theme.
|
|
576
813
|
const t = tokensFor(f.id, mode);
|
|
@@ -591,30 +828,51 @@ function drawAppearance() {
|
|
|
591
828
|
const presetIndex = accent ? ACCENT_PRESETS.findIndex((p) => p.id === accent) : 0;
|
|
592
829
|
const customHex = accent && presetIndex < 0 ? normalizeAccent(accent) : null;
|
|
593
830
|
const accents = radioGroup('Accent colour', ACCENT_PRESETS, presetIndex,
|
|
594
|
-
(p) =>
|
|
831
|
+
(p) => chooseAppearance({ accent: p.hex ? p.id : null }),
|
|
595
832
|
(p) => p.hex
|
|
596
833
|
? h('button', { type: 'button', class: 'fx-swatch', title: p.label, 'aria-label': p.label, style: { background: p.hex } })
|
|
597
834
|
: h('button', { type: 'button', class: 'fx-swatch fx-swatch--default', title: `${active.label}'s own`, 'aria-label': `${active.label}'s own accent` },
|
|
598
835
|
h('span', { 'aria-hidden': 'true', style: { background: `linear-gradient(135deg, ${active.swatches[mode].primary} 50%, ${active.swatches[mode].accent} 50%)` } })));
|
|
599
836
|
accents.classList.add('fx-swatches');
|
|
600
837
|
const custom = h('label', { class: 'fx-swatch-custom' },
|
|
601
|
-
h('input', { type: 'color', 'aria-label': 'Custom accent colour', value: customHex ?? '#0d4e5f', onchange: (event) =>
|
|
838
|
+
h('input', { type: 'color', 'aria-label': 'Custom accent colour', value: customHex ?? '#0d4e5f', onchange: (event) => chooseAppearance({ accent: event.target.value }) }),
|
|
602
839
|
'Custom');
|
|
603
840
|
const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label : `${active.label}'s own`;
|
|
604
841
|
|
|
605
|
-
host.replaceChildren(
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
842
|
+
host.replaceChildren(h('div', { class: 'fx-settings' },
|
|
843
|
+
settingRow({ label: 'Mode', hint: 'Auto follows this device.', control: modeGroup }),
|
|
844
|
+
settingRow({ label: 'Accent colour', hint: `Using ${accentLabel}.`, control: h('div', { class: 'fx-row' }, accents, custom) }),
|
|
845
|
+
settingRow({ label: 'Design system', hint: 'Colours, type and corners.', control: families, wide: true })));
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// A person's own change: show it, then save it to their account once they
|
|
849
|
+
// are signed in. Signed out (the sign-in screen's switch) it stays in this
|
|
850
|
+
// browser.
|
|
851
|
+
let savingAppearance = null;
|
|
852
|
+
function chooseAppearance(update) {
|
|
853
|
+
setAppearance(update);
|
|
854
|
+
saveAccountAppearance();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function accountAppearance() {
|
|
858
|
+
const { colorMode, family, accent } = getAppearance();
|
|
859
|
+
return { colorMode, themeFamily: family, ...(accent ? { accent } : {}) };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function saveAccountAppearance() {
|
|
863
|
+
if (!me) return;
|
|
864
|
+
clearTimeout(savingAppearance);
|
|
865
|
+
savingAppearance = setTimeout(() => {
|
|
866
|
+
flow('/api/me/appearance', { method: 'PUT', body: JSON.stringify(accountAppearance()) })
|
|
867
|
+
.catch(() => toast('Flow could not save your appearance to your account. It stays in this browser for now.', 'danger'));
|
|
868
|
+
}, 300);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// On sign-in: the account's appearance wins. An account with none yet takes
|
|
872
|
+
// this browser's current choice.
|
|
873
|
+
function adoptAccountAppearance(saved) {
|
|
874
|
+
if (!saved) return saveAccountAppearance();
|
|
875
|
+
setAppearance({ colorMode: saved.colorMode, family: saved.themeFamily, accent: saved.accent ?? null });
|
|
618
876
|
}
|
|
619
877
|
|
|
620
878
|
onAppearanceChange(() => {
|
|
@@ -639,7 +897,7 @@ function drawModeToggles() {
|
|
|
639
897
|
}
|
|
640
898
|
|
|
641
899
|
for (const button of document.querySelectorAll('[data-mode-toggle]')) {
|
|
642
|
-
button.addEventListener('click', () =>
|
|
900
|
+
button.addEventListener('click', () => chooseAppearance({ colorMode: getAppearance().mode === 'dark' ? 'light' : 'dark' }));
|
|
643
901
|
}
|
|
644
902
|
onAppearanceChange(drawModeToggles);
|
|
645
903
|
drawModeToggles();
|
|
@@ -687,6 +945,7 @@ async function start() {
|
|
|
687
945
|
}
|
|
688
946
|
try {
|
|
689
947
|
me = await flow('/api/me');
|
|
948
|
+
adoptAccountAppearance(me.appearance);
|
|
690
949
|
} catch (error) {
|
|
691
950
|
renderNoAccess(error);
|
|
692
951
|
return;
|