Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
// Generated, do not edit. sha256:88f1ff40757f8e5095efe909c3a45c2f437406121459c4091bd0b4d3c80eac8c
import { localDate } from '../../shared/time.js';
const DAY_MS = 86400000;
const dayMs = iso => {
const d = localDate(iso);
return d ? Date.UTC(Number(d.slice(0, 4)), Number(d.slice(5, 7)) - 1, Number(d.slice(8, 10))) : null;
};
export function activityItems({ accountIds, trades = [], transfers = [], interest = [], valuations = [],
schedules = [], accounts = [], reservations = [], interestDue = [] }) {
const ids = new Set(accountIds);
const items = [];
const add = (at, accountId, kind, dir, table = null, id = null, assetId = null) => {
const x = dayMs(at);
if (x != null && ids.has(accountId)) items.push({ x, accountId, kind, dir, table, id, assetId });
};
for (const r of trades) {
if (r.type === 'buy') add(r.occurredAt, r.toAccountId, 'buy', 1, 'trades', r.id, r.assetId);
else if (r.type === 'sell') add(r.occurredAt, r.fromAccountId, 'sell', -1, 'trades', r.id, r.assetId);
}
for (const r of transfers) {
add(r.occurredAt, r.fromAccountId, 'transferOut', -1, 'transfers', r.id, r.assetId);
add(r.occurredAt, r.toAccountId, 'transferIn', 1, 'transfers', r.id, r.assetId);
}
for (const r of interest) add(r.occurredAt, r.toAccountId, 'interest', 1, 'interest', r.id, r.assetId);
for (const r of valuations) add(r.rateAt || r.valuedOn, r.accountId, 'balance', 0, 'valuations', r.id, r.assetId);
for (const s of schedules) {
add(s.startsOn, s.accountId, 'agreementStart', 0);
if (s.endsOn) add(s.endsOn, s.accountId, 'agreementEnd', 0);
}
for (const r of reservations) add(r.occurredAt, r.fromAccountId ?? r.toAccountId, 'reservation', 0, 'reservations', r.id, r.assetId);
for (const p of interestDue) add(p.periodEnd, p.accountId, 'interestDue', 0);
for (const a of accounts) {
if (a.createdAt) add(a.createdAt, a.id, 'accountOpened', 0);
if (a.closedAt) add(a.closedAt, a.id, 'accountClosed', 0);
if (a.updatedAt) add(a.updatedAt, a.id, 'accountChanged', 0);
}
return items;
}
export function heartbeat(items, todayIso, fromIso = null) {
const from = fromIso ? dayMs(fromIso) : null;
if (from != null) items = items.filter(it => it.x >= from);
if (!items.length) return null;
const byDay = new Map();
for (const it of items) {
if (!byDay.has(it.x)) byDay.set(it.x, []);
byDay.get(it.x).push(it);
}
const today = dayMs(todayIso);
const last = Math.max(...byDay.keys(), today);
const first = Math.min(from ?? Math.min(...byDay.keys(), today) - DAY_MS, last - DAY_MS);
const points = [];
for (let x = first; x <= last; x += DAY_MS) {
const day = byDay.get(x) || [];
const up = day.filter(i => i.dir > 0).length;
const down = day.filter(i => i.dir < 0).length;
points.push([x, up || down ? (up >= down ? Math.sqrt(up) : -Math.sqrt(down)) : 0]);
}
return { points, byDay };
}
+14
View File
@@ -0,0 +1,14 @@
// Generated, do not edit. sha256:9524042357ce22fcf244cdc26ee3cb73b68a915569fd002433a8ce0705465b4d
import { esc } from './escape.js';
const ICONS = { info: 'ℹ️', success: '✓', error: '⚠️', neutral: '' };
export function alertHtml({ kind = 'neutral', title = '', body = '', icon, extra = '', className = '' }) {
if (!(kind in ICONS)) throw new Error(`Unknown alert kind: ${kind}.`);
const teken = icon === undefined ? ICONS[kind] : (icon || '');
const iconHtml = teken ? `<span class="alert-icon">${teken}</span>` : '';
const titleHtml = title ? `<div class="alert-title">${esc(title)}</div>` : '';
const bodyHtml = body ? `<div class="alert-body">${esc(body)}</div>` : '';
return `<div class="alert alert-${kind}${className ? ` ${className}` : ''}">${iconHtml}`
+ `<div class="alert-content">${titleHtml}${bodyHtml}${extra}</div></div>`;
}
+41
View File
@@ -0,0 +1,41 @@
// Generated, do not edit. sha256:514022a1fc2c9eb3b97d3f6069234344075741ea67baafe13687b608706d1657
import { appSetting } from '../app-settings.js';
import { ENUMS } from '../../shared/config.js';
const veld = (a, eigen, alias) => String((eigen in a ? a[eigen] : (alias in a ? a[alias] : '')) ?? '').trim();
const zelfde = (name, symbol) => !!name && name.toLowerCase() === symbol.toLowerCase();
export function assetLabel(a) {
if (!a) return '';
const name = veld(a, 'name', 'asset_name');
const symbol = veld(a, 'symbol', 'asset_symbol');
const showName = appSetting('asset_show_name') === 1;
const showSymbol = appSetting('asset_show_symbol') === 1;
if (showName && showSymbol) return name && symbol && !zelfde(name, symbol) ? `${name} (${symbol})` : (name || symbol);
if (showName) return name || symbol;
return symbol || name;
}
export function byKindAndLabel(a, b) {
const soort = x => { const i = ENUMS.ASSET_KIND.indexOf(x && x.kind); return i === -1 ? Number.MAX_SAFE_INTEGER : i; };
return soort(a) - soort(b)
|| assetLabel(a).localeCompare(assetLabel(b), 'nl', { sensitivity: 'base' })
|| String(a.symbol || '').localeCompare(String(b.symbol || ''));
}
export function assetLabelHtml(a, { withoutSymbol = false } = {}) {
if (!a) return '';
const e = s => String(s ?? '').trim().replace(/&/g, '&amp;').replace(/</g, '&lt;');
const name = e(veld(a, 'name', 'asset_name'));
const symbol = e(veld(a, 'symbol', 'asset_symbol'));
const showName = appSetting('asset_show_name') === 1;
const showSymbol = appSetting('asset_show_symbol') === 1;
const nm = txt => `<span class="asset-name">${txt}</span>`;
if (withoutSymbol) return nm(showName ? (name || symbol) : (symbol || name));
if (showName && showSymbol && name && symbol && !zelfde(name, symbol)) return `${nm(name)} <span class="t-muted-cell">${symbol}</span>`;
if (showName) return nm(name || symbol);
return nm(symbol || name);
}
+31
View File
@@ -0,0 +1,31 @@
// Generated, do not edit. sha256:0c32d1144a8a497df1fbe9e1a74301ff3b50ab942217dad783d7ff7727f69eb8
import { CONFIG } from '../../shared/config.js';
const timers = new WeakMap();
export function pulseAttention(card, { rounds = CONFIG.ATTENTION_RING.ROUNDS } = {}) {
if (!card) return;
ringIn(card);
card.classList.add('is-attention');
clearTimeout(timers.get(card));
timers.set(card, setTimeout(() => card.classList.remove('is-attention'), rounds * CONFIG.ATTENTION_RING.ROUND_MS));
}
export function holdAttention(card) {
if (!card) return;
ringIn(card);
clearTimeout(timers.get(card));
card.classList.add('is-attention');
}
function ringIn(card) {
let ring = card.querySelector(':scope > .attn');
if (!ring) {
ring = document.createElement('span');
ring.className = 'attn';
ring.setAttribute('aria-hidden', 'true');
ring.innerHTML = '<span class="attn-spin"></span>';
card.prepend(ring);
}
card.style.setProperty('--attn-d', `${Math.ceil(Math.hypot(card.offsetWidth, card.offsetHeight))}px`);
}
+203
View File
@@ -0,0 +1,203 @@
// Generated, do not edit. sha256:51b53dc3f01e74bb7f221f45ce7f2b9b6f6a164a12cedc96e33ad4f1b1b701a0
import { currentNotifications } from '../data/notifications.js';
import { countAiToolCallsSince } from '../data/ai-audit.js';
import { getPref } from '../data/preferences.js';
import { listProviders, getProvider } from '../data/providers.js';
import { listPriceSource } from '../data/price-source.js';
import { listPriceGaps } from '../data/price-history.js';
import { apiCallsByProvider } from '../data/api-metrics.js';
import { rateSourceFor } from '../price/providers.js';
import { t } from '../i18n/index.js';
import { CONFIG, TARGETS, NOTIFICATION_TARGETS, SOURCE_ENDPOINTS, targetHash, targetTab } from '../../shared/config.js';
import { registerAttention } from './attention.js';
import { appSetting, baseCurrency } from '../app-settings.js';
import { licenseChangedSinceSeen } from '../license.js';
async function integrationsNeedingKey() {
const rijen = await listProviders();
const configs = await Promise.all(rijen.map(r => getProvider(r.key)));
const providers = configs
.filter(p => p && p.isActive && p.authenticationRequired && p.authenticationKind !== 'none');
const out = [];
for (const p of providers) {
let complete = false;
try {
const st = await (await fetch(`/api/keys/status?provider=${encodeURIComponent(p.key)}&authenticationKind=${encodeURIComponent(p.authenticationKind)}`)).json();
complete = !!(st && st.complete);
} catch { continue; }
if (!complete) {
out.push({
id: `integration:${p.key}`,
severity: 'problem',
route: TARGETS.PROVIDER_KEYS.route,
tab: targetTab(TARGETS.PROVIDER_KEYS),
hash: targetHash(TARGETS.PROVIDER_KEYS),
label: p.name + t('attention.integrationNoKeySuffix'),
});
}
}
return out;
}
async function sourcesNearLimit() {
const maand = `${new Date().toISOString().slice(0, 7)}-01T00:00`;
const calls = new Map((await apiCallsByProvider(maand)).map(r => [r.providerId, r.calls]));
const out = [];
for (const r of await listProviders()) {
if (!r.isActive || !calls.get(r.id)) continue;
const p = await getProvider(r.key);
if (!p || !p.callsPerMonth) continue;
const n = calls.get(r.id);
const deel = n / p.callsPerMonth;
if (deel < CONFIG.API_USAGE.WARN_SHARE) continue;
out.push({
id: `usage:${p.key}`,
severity: deel >= 1 ? 'problem' : 'attention',
route: TARGETS.PROVIDER_KEYS.route,
tab: targetTab(TARGETS.PROVIDER_KEYS),
hash: targetHash(TARGETS.PROVIDER_KEYS),
label: t(deel >= 1 ? 'attention.usageOver' : 'attention.usageNear', { naam: p.name, calls: n, limiet: p.callsPerMonth }),
});
}
return out;
}
async function sourcesWithoutRate() {
const configs = new Map();
for (const r of await listProviders()) configs.set(r.id, await getProvider(r.key));
const wanted = new Map();
for (const cel of await listPriceSource()) {
const p = configs.get(cel.providerId);
if (cel.rank == null || !p || !p.isActive) continue;
for (const e of (p ? p.endpoints : []).filter(x => SOURCE_ENDPOINTS[cel.capability].includes(x.capability))) {
const currency = e.quoteCurrency;
if (!currency || currency === baseCurrency()) continue;
wanted.set(`${p.key}|${currency}`, { provider: p, currency });
}
}
const out = [];
for (const { provider, currency } of wanted.values()) {
if (await rateSourceFor(currency)) continue;
out.push({
id: `fx:${provider.key}:${currency}`,
severity: 'problem',
route: TARGETS.SOURCE_PROPOSALS.route,
tab: targetTab(TARGETS.SOURCE_PROPOSALS),
hash: targetHash(TARGETS.SOURCE_PROPOSALS),
label: `${provider.name}: ${t('integrations.noRateSource', { valuta: currency })}`,
});
}
return out;
}
const GAPS_TTL_MS = 10 * 60 * 1000;
let gapsCache = { at: 0, total: 0 };
if (typeof document !== 'undefined') {
document.addEventListener('price-gaps-changed', () => { gapsCache = { at: 0, total: 0 }; });
}
async function priceGaps() {
if (Date.now() - gapsCache.at > GAPS_TTL_MS) {
try { gapsCache = { at: Date.now(), total: (await listPriceGaps()).total }; }
catch { return []; }
}
if (!gapsCache.total) return [];
return [{
id: 'price:gaps',
severity: 'attention',
route: TARGETS.PRICE_HISTORY.route,
tab: targetTab(TARGETS.PRICE_HISTORY),
hash: targetHash(TARGETS.PRICE_HISTORY),
label: t('attention.priceGaps', { n: gapsCache.total }),
}];
}
const ACTIE_LABEL = {
reconcile: n => t('attention.actionsReconcilePrefix') + n + t('attention.actionsReconcileSuffix'),
interest: n => t('attention.actionsInterestPrefix') + n,
valuation: n => t('attention.actionsValuationPrefix') + n,
ai_proposal: n => t('attention.actionsAiPrefix') + n,
reservation: n => t('attention.actionsReservationPrefix') + n,
};
async function openActions() {
const meldingen = await currentNotifications();
return meldingen.filter(n => ACTIE_LABEL[n.kind] && n.count > 0).map(n => ({
id: `actions:${n.kind}`,
severity: 'attention',
route: NOTIFICATION_TARGETS[n.kind].route,
tab: targetTab(NOTIFICATION_TARGETS[n.kind]),
hash: targetHash(NOTIFICATION_TARGETS[n.kind]),
count: n.count,
label: ACTIE_LABEL[n.kind](n.count),
}));
}
function backupReminder() {
const last = appSetting('last_backup_at');
const dayMs = 86400000;
if (!last) {
return [{ id: 'backup:never', route: null, severity: 'attention', hash: targetHash(TARGETS.PORTFOLIOS),
label: t('attention.backupNever') }];
}
const days = Math.floor((Date.now() - new Date(last).getTime()) / dayMs);
if (Number.isFinite(days) && days >= CONFIG.BACKUP.REMIND_AFTER_DAYS) {
return [{ id: 'backup:stale', route: null, severity: 'attention', hash: targetHash(TARGETS.PORTFOLIOS),
label: t('attention.backupStalePrefix') + days + t('attention.backupStaleSuffix') }];
}
return [];
}
async function aiActivity() {
const gezien = await getPref('ai', 'seen_call_id', 0);
const { count } = await countAiToolCallsSince(Number(gezien) || 0);
if (count <= 0) return [];
return [{
id: 'ai:activity',
severity: 'attention',
route: TARGETS.AI_ACTIVITY.route,
tab: targetTab(TARGETS.AI_ACTIVITY),
hash: targetHash(TARGETS.AI_ACTIVITY),
count,
label: t('attention.aiActivityPrefix') + count + t('attention.aiActivitySuffix'),
}];
}
async function licenseChanged() {
if (!await licenseChangedSinceSeen()) return [];
return [{
id: 'license:changed',
severity: 'attention',
route: TARGETS.LICENSE.route,
tab: targetTab(TARGETS.LICENSE),
hash: targetHash(TARGETS.LICENSE),
label: t('attention.licenseChanged'),
}];
}
let registered = false;
function healthCheckReminder() {
const last = appSetting('last_health_check_at');
const days = last ? Math.floor((Date.now() - new Date(last).getTime()) / 86400000) : null;
if (!last) {
return [{ id: 'health:never', route: null, severity: 'attention', hash: targetHash(TARGETS.HEALTH),
label: t('attention.healthNever') }];
}
if (Number.isFinite(days) && days >= CONFIG.HEALTH_CHECK.REMIND_AFTER_DAYS) {
return [{ id: 'health:stale', route: null, severity: 'attention', hash: targetHash(TARGETS.HEALTH),
label: t('attention.healthStalePrefix') + days + t('attention.healthStaleSuffix') }];
}
return [];
}
export function registerAttentionSources() {
if (registered) return;
registered = true;
registerAttention(integrationsNeedingKey);
registerAttention(sourcesWithoutRate);
registerAttention(sourcesNearLimit);
registerAttention(priceGaps);
registerAttention(openActions);
registerAttention(backupReminder);
registerAttention(healthCheckReminder);
registerAttention(aiActivity);
registerAttention(licenseChanged);
}
+160
View File
@@ -0,0 +1,160 @@
// Generated, do not edit. sha256:08b414b8e046583bdfdf9c3302134a9e220a7c451265f464b9ed56dca2d3d460
import { getPref, setPref } from '../data/preferences.js';
import { CONFIG, targetHash } from '../../shared/config.js';
import { esc } from './escape.js';
const producers = [];
let cache = [];
let cacheKey = '';
const PREF_SCOPE = 'attention';
const PREF_ACT = 'activated';
const PREF_SEEN = 'seen';
const PREF_DAY = 'day';
const activated = new Map();
const seen = new Set();
let loaded = false;
let dayN = 0;
const stateKey = a => `${a.id}|${a.severity || 'attention'}`;
function todayStr() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
async function ensureLoaded() {
if (loaded) return;
loaded = true;
try {
const act = await getPref(PREF_SCOPE, PREF_ACT, {});
if (act && typeof act === 'object' && !Array.isArray(act)) {
for (const [id, s] of Object.entries(act)) activated.set(String(id), Number(s) || 0);
}
} catch { }
try {
const s = await getPref(PREF_SCOPE, PREF_SEEN, []);
if (Array.isArray(s)) for (const k of s) seen.add(String(k));
} catch { }
let day = { date: '', n: 0 };
try { const d = await getPref(PREF_SCOPE, PREF_DAY, null); if (d && typeof d === 'object') day = d; } catch { }
const today = todayStr();
if (day.date !== today) { day = { date: today, n: (Number(day.n) || 0) + 1 }; saveDay(day); }
dayN = Number(day.n) || 1;
}
async function saveDay(day) { try { await setPref(PREF_SCOPE, PREF_DAY, day); } catch { } }
async function saveActivated() {
try { await setPref(PREF_SCOPE, PREF_ACT, Object.fromEntries(activated)); } catch { }
}
async function saveSeen() {
try { await setPref(PREF_SCOPE, PREF_SEEN, [...seen]); } catch { }
}
function fadeFor(a) {
const k = stateKey(a);
if (!activated.has(k)) return 'hidden';
const age = dayN - activated.get(k);
if (age >= CONFIG.ATTENTION.HIDE_AFTER_DAYS) return 'hidden';
if (age >= CONFIG.ATTENTION.FADE_AFTER_DAYS || seen.has(k)) return 'dim';
return 'full';
}
function currentRouteKey() {
const h = (typeof location !== 'undefined' ? location.hash : '') || '';
return h.replace(/^#\/?/, '') || 'home';
}
function opDezePlek(a, here) {
const hereHead = here.split('/')[0];
if (!a.tab) return a.route === here || a.route === hereHead;
const plek = `${a.route}/${a.tab}`;
return here === plek || here.startsWith(`${plek}/`);
}
export function registerAttention(producer) {
if (typeof producer === 'function') producers.push(producer);
return () => {
const i = producers.indexOf(producer);
if (i >= 0) producers.splice(i, 1);
};
}
export async function refreshAttention() {
const collected = [];
for (const p of producers) {
try {
const items = await p();
if (Array.isArray(items)) collected.push(...items.filter(Boolean));
} catch { }
}
const norm = collected.map(a => ({
id: String(a.id),
severity: a.severity || 'attention',
route: a.route ?? null,
tab: a.tab ?? null,
hash: a.hash || (a.route ? targetHash({ route: a.route }) : null),
label: a.label || '',
count: Number.isFinite(a.count) ? a.count : null,
})).sort((x, y) => (x.route || '').localeCompare(y.route || '') || x.id.localeCompare(y.id));
cache = norm;
await ensureLoaded();
const here = currentRouteKey();
const liveKeys = new Set(norm.map(stateKey));
let activatedChanged = false, seenChanged = false;
for (const a of norm) {
const k = stateKey(a);
if (activated.has(k)) {
if (a.route !== null && opDezePlek(a, here) && !seen.has(k)) { seen.add(k); seenChanged = true; }
continue;
}
if (a.route === null || !opDezePlek(a, here)) {
activated.set(k, dayN); activatedChanged = true;
}
}
for (const k of [...activated.keys()]) {
if (!liveKeys.has(k)) { activated.delete(k); activatedChanged = true; }
}
for (const k of [...seen]) {
if (!liveKeys.has(k)) { seen.delete(k); seenChanged = true; }
}
if (activatedChanged) saveActivated();
if (seenChanged) saveSeen();
const actKey = norm.filter(a => activated.has(stateKey(a))).map(a => `${stateKey(a)}:${fadeFor(a)}`).sort().join(',');
const key = JSON.stringify(norm) + '|' + actKey;
const changed = key !== cacheKey;
cacheKey = key;
if (changed && typeof document !== 'undefined' && typeof document.dispatchEvent === 'function') {
document.dispatchEvent(new CustomEvent('attention-changed'));
}
return cache;
}
export function attentionItems() { return cache; }
export function generalAttention() {
return cache.filter(a => a.route === null).map(a => ({ ...a, fade: fadeFor(a) })).filter(a => a.fade !== 'hidden');
}
export function attentionForRoute(route) {
return cache.filter(a => a.route === route)
.map(a => ({ ...a, fade: fadeFor(a) })).filter(a => a.fade !== 'hidden');
}
export function hasAttentionForRoute(route) { return attentionForRoute(route).length > 0; }
export function attentionHasProblem(items) {
return (items || []).some(a => a.severity === 'problem');
}
export function attentionDimmed(items) {
return !!(items && items.length) && items.every(a => a.fade === 'dim');
}
export function attentionDotHtml(items) {
if (!items || !items.length) return '';
const hint = items.map(a => a.label).filter(Boolean).join(' \u00b7 ');
const dim = attentionDimmed(items);
const stuk = attentionHasProblem(items);
return ` <span class="nav-attention${stuk ? ' is-problem' : ''}${dim ? ' is-dim' : ''}" title="${esc(hint)}" aria-label="${esc(hint)}"></span>`;
}
+187
View File
@@ -0,0 +1,187 @@
// Generated, do not edit. sha256:8ea45396bfd27f4dbf4c416aa596846fa428d7394799796a19ce526c30b7ada7
import { CONFIG } from '../../shared/config.js';
const BLOBS = Object.freeze([
Object.freeze({ cls: 'blob-a', x: [0, 42], y: [0, 26], firstMs: 4000 }),
Object.freeze({ cls: 'blob-b', x: [-52, 0], y: [0, 32], firstMs: 14000 }),
Object.freeze({ cls: 'blob-c', x: [-38, 38], y: [-38, -4], firstMs: 26000, sink: 0.3 }),
]);
const SINK_VH = 80;
export const BAND_LAYERS = Object.freeze([
Object.freeze({ w: 1400, h: 1800, count: 4, len: [700, 1300], width: [40, 70], alpha: [0.2, 0.4], blur: 12, dur: 21, opacity: 0.75, tilt: 10, seed: 19 }),
Object.freeze({ w: 1800, h: 2100, count: 3, len: [900, 1600], width: [110, 180], alpha: [0.12, 0.26], blur: 28, dur: 17, opacity: 0.9, tilt: 12, seed: 29 }),
Object.freeze({ w: 2200, h: 2400, count: 2, len: [1200, 1900], width: [300, 450], alpha: [0.08, 0.16], blur: 64, dur: 14, opacity: 1, tilt: 14, seed: 43 }),
]);
const FADE_MS = 5200;
const between = (rnd, a, b) => a + rnd() * (b - a);
export function tempoOf(key) {
return CONFIG.GLOW_TEMPO[key] || CONFIG.GLOW_TEMPO.calm;
}
export function motionMode({ profile, glow, motion, reducedMotion = false, hidden = false } = {}) {
if (profile !== 'glass' || glow === 'off' || reducedMotion || hidden) return 'off';
return motion === 'step' || motion === 'always' ? motion : 'off';
}
export function nextDelayMs(tempo, rnd = Math.random) {
return Math.round((tempo.MOVE_S + between(rnd, tempo.PAUSE_MIN_S, tempo.PAUSE_MAX_S)) * 1000);
}
export function driftTransform(area, rnd = Math.random) {
const sinks = area.sink ? rnd() < area.sink : false;
const x = between(rnd, area.x[0], area.x[1]).toFixed(1);
const y = sinks ? SINK_VH.toFixed(1) : between(rnd, area.y[0], area.y[1]).toFixed(1);
const turn = between(rnd, -16, 16).toFixed(1);
const sx = between(rnd, 0.85, 1.2).toFixed(3);
const sy = between(rnd, 0.85, 1.2).toFixed(3);
return `translate(${x}vw,${y}vh) rotate(${turn}deg) scale(${sx},${sy})`;
}
export function nextCore(cores, i, rnd = Math.random, chance = CONFIG.GLOW_CORE_CHANCE) {
if (cores[i]) return false;
if (cores.some((c, n) => c && n !== i)) return false;
return rnd() < chance;
}
export function showerMs(tempo, on, rnd = Math.random) {
return Math.round((on ? tempo.MOVE_S * 2 : between(rnd, tempo.PAUSE_MIN_S, tempo.PAUSE_MAX_S)) * 1000);
}
function seeded(a) {
return () => {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
export function bandTile({ w, h, count, len, width, alpha, blur, seed }) {
const rnd = seeded(seed);
const halo = blur * 3, pad = 2 + halo;
let shapes = '';
for (let i = 0; i < count; i++) {
const sw = between(rnd, width[0], width[1]);
const x = between(rnd, pad, w - pad - sw), y = between(rnd, 0, h);
const L = between(rnd, len[0], len[1]), a = between(rnd, alpha[0], alpha[1]);
for (const dy of [0, -h, h]) {
if (dy < 0 && y + L + halo <= h) continue;
if (dy > 0 && y - halo >= 0) continue;
shapes += `<rect x="${x.toFixed(1)}" y="${(y + dy).toFixed(1)}" width="${sw.toFixed(1)}" height="${L.toFixed(1)}"`
+ ` rx="${(sw / 2).toFixed(1)}" fill="url(#g)" opacity="${a.toFixed(2)}"/>`;
}
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><defs>`
+ '<linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/>'
+ '<stop offset="0.35" stop-color="#fff"/><stop offset="0.65" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>'
+ `<filter id="f" filterUnits="userSpaceOnUse" x="0" y="0" width="${w}" height="${h}"><feGaussianBlur stdDeviation="${blur}"/></filter>`
+ `</defs><g filter="url(#f)">${shapes}</g></svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
}
const timers = [];
let shapeTimer = null;
let showerTimer = null;
let fadeTimer = null;
let bandsDrawn = false;
let wired = false;
let last = {};
function drawBands(box) {
if (bandsDrawn) return;
bandsDrawn = true;
box.querySelectorAll('.band').forEach((el, i) => {
const spec = BAND_LAYERS[i];
if (!spec) return;
el.style.maskImage = el.style.webkitMaskImage = bandTile(spec);
el.style.maskSize = el.style.webkitMaskSize = `${spec.w}px ${spec.h}px`;
el.style.setProperty('--tile-h', `${spec.h}px`);
el.style.setProperty('--band-dur', `${spec.dur}s`);
el.style.setProperty('--tilt', `${spec.tilt}deg`);
el.style.opacity = String(spec.opacity);
});
}
function shower(box, tempo, on) {
clearTimeout(showerTimer); clearTimeout(fadeTimer);
box.classList.add('is-falling');
box.classList.toggle('is-shown', on);
if (!on) fadeTimer = setTimeout(() => box.classList.remove('is-falling'), FADE_MS);
showerTimer = setTimeout(() => shower(box, tempo, !on), showerMs(tempo, on));
}
function applyBands(box, bands, mode, tempo) {
clearTimeout(showerTimer); clearTimeout(fadeTimer);
box.classList.remove('is-falling');
if (bands) drawBands(box);
box.classList.toggle('is-shown', bands);
if (!bands) return;
if (mode === 'always') box.classList.add('is-falling');
else if (mode === 'step') shower(box, tempo, true);
}
function elements() {
return BLOBS.map(b => document.querySelector(`.aurora .${b.cls}`));
}
function coresOf(els) { return els.map(el => !!el?.classList.contains('is-core')); }
function reshape(els, i) {
const el = els[i];
if (el) el.classList.toggle('is-core', nextCore(coresOf(els), i));
}
function stopAll(els, keepTransform) {
timers.forEach(t => clearTimeout(t));
timers.length = 0;
clearTimeout(shapeTimer);
for (const el of els) {
if (!el) continue;
el.classList.remove('is-core');
if (!keepTransform) el.style.transform = '';
}
}
export function applyAurora(settings = last) {
last = settings;
const root = document.documentElement;
const els = elements();
if (!els.some(Boolean)) return;
const tempo = tempoOf(settings.glow_tempo);
root.style.setProperty('--drift-dur', `${tempo.MOVE_S}s`);
root.style.setProperty('--drift-mult', String(tempo.ALWAYS_MULT));
const mode = motionMode({
profile: settings.profile, glow: settings.glow, motion: settings.motion,
reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
hidden: document.hidden,
});
const bands = settings.glowshape === 'bands';
const box = document.querySelector('.aurora .bands');
if (box) applyBands(box, bands, mode, tempo);
const blobMode = bands ? 'off' : mode;
stopAll(els, blobMode === 'step');
if (blobMode === 'step') {
BLOBS.forEach((b, i) => {
const tick = () => {
if (els[i]) els[i].style.transform = driftTransform(b);
reshape(els, i);
timers[i] = setTimeout(tick, nextDelayMs(tempo));
};
timers[i] = setTimeout(tick, b.firstMs);
});
} else if (blobMode === 'always') {
const tick = () => {
reshape(els, Math.floor(Math.random() * els.length));
shapeTimer = setTimeout(tick, nextDelayMs(tempo) / els.length);
};
shapeTimer = setTimeout(tick, 20000);
}
if (!wired) {
wired = true;
document.addEventListener('visibilitychange', () => applyAurora());
matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => applyAurora());
}
}
+56
View File
@@ -0,0 +1,56 @@
// Generated, do not edit. sha256:3f4bf112e28f009a738272dd18bef14d5e6736a31c2f3e1f6e434733f909d6ea
import { appSetting, setAppSetting } from '../app-settings.js';
import { t } from '../i18n/index.js';
import { EYE, EYE_OFF } from './icons.js';
import { CONFIG } from '../../shared/config.js';
let idleTimer = null;
let btn = null;
function isOn() { return appSetting('blur') === 1; }
export const isBlurred = () => isOn();
export const unblur = () => setOn(false);
function apply(on) {
document.documentElement.dataset.blur = on ? 'on' : 'off';
if (btn) {
btn.innerHTML = on ? EYE_OFF : EYE;
btn.classList.toggle('is-on', on);
}
document.dispatchEvent(new CustomEvent('blur-changed', { detail: { on } }));
}
function armIdle() {
clearTimeout(idleTimer);
const min = appSetting('auto_blur_min');
if (min >= CONFIG.AUTO_BLUR.NEVER_MIN) return;
idleTimer = setTimeout(() => { setOn(true); }, min * 60 * 1000);
}
async function setOn(on) {
apply(on);
try { await setAppSetting('blur', on ? 1 : 0); } catch { }
if (on) clearTimeout(idleTimer); else armIdle();
}
function onActivity() { if (!isOn()) armIdle(); }
export function initPrivacyBlur(hostEl) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'statusbar-eye';
btn.title = t('blur.toggle');
btn.setAttribute('aria-label', t('blur.toggle'));
btn.onclick = () => { setOn(!isOn()); };
hostEl.appendChild(btn);
apply(isOn());
armIdle();
for (const ev of ['pointerdown', 'keydown', 'wheel', 'touchstart']) {
document.addEventListener(ev, onActivity, { passive: true });
}
document.addEventListener('settings-changed', () => { if (!isOn()) armIdle(); });
}
+28
View File
@@ -0,0 +1,28 @@
// Generated, do not edit. sha256:39cef5d1d7c6691e4a7d1766d7878d7b1a1aa62aaa034e47b2930230f92c8080
import { CONFIG } from '../../shared/config.js';
import { t } from '../i18n/index.js';
import { loadCharting as loadSharedCharting, readPalette as readSharedPalette, tintSeries } from './charting.js';
export function loadCharting() {
const c = CONFIG.CHART_LIBS;
if (!c || !c.CHARTJS || !c.HAMMER || !c.ZOOM) {
throw new Error('Chart libs are missing in shared/config.js (CHART_LIBS).');
}
return loadSharedCharting(c, { failed: src => t('integrations.scriptLoadFailed', { src }) });
}
export function readPalette() {
const cs = getComputedStyle(document.documentElement);
const v = n => cs.getPropertyValue(n).trim();
const shared = readSharedPalette();
const [, mid, faint] = tintSeries(shared.accent, 3, { min: 0.45 });
return {
...shared,
border: v('--border'), accentMid: mid, accentFaint: faint, blue: v('--sig-blue'),
red: v('--sig-red'), sigGreen: v('--sig-green'), sigYellow: v('--sig-yellow'), sigRed: v('--sig-red'),
greenZone: v('--sig-green-zone'), yellowZone: v('--sig-yellow-zone'), redZone: v('--sig-red-zone'),
ringSeam: parseFloat(v('--ring-seam')) || 0,
ringSheen: v('--ring-sheen') === '1',
hairline: v('--hairline'), rimBottom: v('--rim-bottom'), accentGlow: v('--accent-glow'),
};
}
+48
View File
@@ -0,0 +1,48 @@
// Generated, do not edit. sha256:ee03300e55aca9bafacba37ec01daf41bd49c8f2f20ec64a315107a3b816d0b3
let chartingPromise = null;
export function loadCharting(libs, { failed = (src) => `Cannot load ${src}.` } = {}) {
if (chartingPromise) return chartingPromise;
const c = libs;
const addScript = src => new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src; s.async = false;
s.onload = resolve; s.onerror = () => reject(new Error(failed(src)));
document.head.appendChild(s);
});
chartingPromise = (async () => {
if (!window.Chart) await addScript(c.CHARTJS);
if (!window.Hammer) await addScript(c.HAMMER);
await addScript(c.ZOOM);
return window.Chart;
})();
chartingPromise.catch(() => { chartingPromise = null; });
return chartingPromise;
}
export function hexToRgb(hex) {
const s = String(hex || '').trim().replace(/^#/, '');
const full = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
return { r: parseInt(full.slice(0, 2), 16), g: parseInt(full.slice(2, 4), 16), b: parseInt(full.slice(4, 6), 16) };
}
export function tintSeries(hex, count, { min = 0.28, max = 1 } = {}) {
const n = Math.max(0, Math.floor(count) || 0);
if (n === 0) return [];
const rgb = hexToRgb(hex) || { r: 128, g: 128, b: 128 };
const step = n === 1 ? 0 : (max - min) / (n - 1);
return Array.from({ length: n }, (_, i) => `rgba(${rgb.r},${rgb.g},${rgb.b},${Math.round((max - i * step) * 1000) / 1000})`);
}
export function readPalette() {
const cs = getComputedStyle(document.documentElement);
const v = n => cs.getPropertyValue(n).trim();
const accent = v('--accent');
return {
text: v('--text-primary'), textSec: v('--text-sec'), textTer: v('--text-ter'), grid: v('--border'),
accent, accentSoft: v('--accent-soft'), pos: v('--pos'), neg: v('--neg'), surface: v('--bg-elevated'),
tooltipBg: v('--bg-overlay'), borderMid: v('--border-mid'),
series: (n) => tintSeries(accent, n, { min: 0.45 }),
};
}
+43
View File
@@ -0,0 +1,43 @@
// Generated, do not edit. sha256:2c4f9a283b8c1358df232fdf7f2ef98f6443ae5dd830e97908059c85c34cc9d7
import { t } from '../i18n/index.js';
import { esc } from './escape.js';
const GLYPH = '⧉';
export function copyButton(text, title) {
if (text == null || text === '') return '';
const ttl = esc(title || t('action.copy'));
return `<button type="button" class="copy-btn" data-copy="${esc(text)}" title="${ttl}" aria-label="${ttl}">${GLYPH}</button>`;
}
const CLEAR_AFTER_MS = 20000;
let clearTimer = null;
let pendingClear = false;
function clearClipboard() {
navigator.clipboard.writeText('')
.then(() => { pendingClear = false; })
.catch(() => { pendingClear = true; window.addEventListener('focus', onFocusClear, { once: true }); });
}
function onFocusClear() { if (pendingClear) clearClipboard(); }
function scheduleClear() {
clearTimeout(clearTimer);
pendingClear = false;
clearTimer = setTimeout(clearClipboard, CLEAR_AFTER_MS);
}
let installed = false;
function install() {
if (installed) return;
installed = true;
document.addEventListener('click', e => {
const btn = e.target.closest('[data-copy]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
navigator.clipboard.writeText(btn.dataset.copy)
.then(() => { btn.classList.add('is-copied'); setTimeout(() => btn.classList.remove('is-copied'), 1200); scheduleClear(); })
.catch(() => {});
}, true);
}
install();
+303
View File
@@ -0,0 +1,303 @@
// Generated, do not edit. sha256:26856efc0d14b9a73e26fd078294b6d0c3f7eebff4e23cc52adf3839ca9c9ebe
import { esc } from './escape.js';
const CLOSE = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const DEFAULT_LABELS = Object.freeze({
ok: 'OK', cancel: 'Cancel', close: 'Close', next: 'Next', back: 'Back', finish: 'Finish', skip: 'Skip',
});
let labels = { ...DEFAULT_LABELS };
let closeIcon = CLOSE;
let onDetail = null;
export function configureDialog({ labels: l = null, closeIcon: icon = null, onDetail: hook } = {}) {
labels = { ...DEFAULT_LABELS, ...(l || {}) };
closeIcon = icon || CLOSE;
onDetail = typeof hook === 'function' ? hook : null;
}
let overlayEl = null;
let activeResolve = null;
let overlayIsDetail = false;
export function isDialogOpen(el = null) {
if (topEl) return true;
if (!overlayEl) return false;
return !(el && overlayEl.contains(el));
}
let prevOverflow = null;
let prevPadding = null;
function lockPage() {
if (prevOverflow !== null) return;
const el = document.documentElement;
const gutter = window.innerWidth - el.clientWidth;
prevOverflow = el.style.overflow;
prevPadding = el.style.paddingRight;
el.style.overflow = 'hidden';
if (gutter > 0) el.style.paddingRight = `${gutter}px`;
}
function unlockPage() {
if (prevOverflow === null) return;
const el = document.documentElement;
el.style.overflow = prevOverflow;
el.style.paddingRight = prevPadding;
prevOverflow = null;
prevPadding = null;
}
function settle(result) {
if (topEl) settleTop(false);
document.removeEventListener('keydown', onKey, true);
if (overlayEl) { overlayEl.remove(); overlayEl = null; }
overlayIsDetail = false;
unlockPage();
const r = activeResolve; activeResolve = null;
if (r) r(result);
}
function onKey(e) {
if (!overlayEl || topEl) return;
if (overlayIsDetail && e.target && typeof e.target.matches === 'function' && overlayEl.contains(e.target)
&& e.target.matches('input, select, textarea')) return;
if (e.key === 'Escape') { e.preventDefault(); settle(false); }
}
let topEl = null;
let topResolve = null;
let topReturn = null;
function settleTop(result) {
document.removeEventListener('keydown', onKeyTop, true);
if (topEl) { topEl.remove(); topEl = null; }
const r = topResolve; topResolve = null;
if (topReturn && topReturn.isConnected) topReturn.focus();
topReturn = null;
if (r) r(result);
}
function onKeyTop(e) {
if (topEl && e.key === 'Escape') { e.preventDefault(); settleTop(false); }
}
export function confirmOver({ title, message, confirmLabel, cancelLabel, danger } = {}) {
if (!overlayEl) return confirm({ title, message, confirmLabel, cancelLabel, danger });
return openTop({ title, bodyHtml: `<div class="dialog-body">${esc(message)}</div>`,
showCancel: true, confirmLabel, cancelLabel, danger });
}
function openTop({ title, bodyHtml, showCancel, confirmLabel, cancelLabel, danger, collect = null }) {
if (topEl) settleTop(false);
return new Promise(resolve => {
topResolve = resolve;
topReturn = document.activeElement;
topEl = document.createElement('div');
topEl.className = 'dialog-overlay dialog-overlay--stacked';
topEl.innerHTML = `
<div class="dialog" role="${collect ? 'dialog' : 'alertdialog'}" aria-modal="true" tabindex="-1">
${title ? `<div class="dialog-title">${esc(title)}</div>` : ''}
${bodyHtml}
<div class="dialog-actions">
${showCancel ? `<button type="button" class="btn btn-ghost btn-md" data-dlg="cancel">${esc(cancelLabel || labels.cancel)}</button>` : ''}
<button type="button" class="btn ${danger ? 'btn-danger' : 'btn-primary'} btn-md" data-dlg="ok">${esc(confirmLabel || labels.ok)}</button>
</div>
</div>`;
const outcome = ok => (collect ? (ok ? collect(topEl) : null) : ok);
topEl.addEventListener('click', e => {
const b = e.target.closest('[data-dlg]');
if (b) settleTop(outcome(b.dataset.dlg === 'ok'));
});
if (collect) {
topEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && e.target.matches('input[data-dlg-field]')) { e.preventDefault(); settleTop(outcome(true)); }
});
}
document.body.appendChild(topEl);
document.addEventListener('keydown', onKeyTop, true);
(topEl.querySelector('[data-dlg-field]') || topEl.querySelector('[data-dlg="ok"]')).focus();
});
}
function open({ title, message, bodyHtml, wide, confirmLabel, cancelLabel, danger, showCancel, showOk = true,
closeCross = false, titleAside = false }) {
if (overlayEl) settle(false);
return new Promise(resolve => {
activeResolve = resolve;
const confirmCls = danger ? 'btn-danger' : 'btn-primary';
overlayEl = document.createElement('div');
overlayEl.className = 'dialog-overlay';
const actions = showCancel || showOk ? `
<div class="dialog-actions">
${showCancel ? `<button type="button" class="btn btn-ghost btn-md" data-dlg="cancel">${esc(cancelLabel || labels.cancel)}</button>` : ''}
${showOk ? `<button type="button" class="btn ${confirmCls} btn-md" data-dlg="ok">${esc(confirmLabel || labels.ok)}</button>` : ''}
</div>` : '';
const body = bodyHtml != null
? `<div class="dialog-body dialog-body--rich">${bodyHtml}</div>`
: `<div class="dialog-body">${esc(message)}</div>`;
const head = title
? `<div class="dialog-title">
<span class="dialog-title-text">${esc(title)}</span>
${titleAside ? '<span class="dialog-title-aside" data-dialog-aside></span>' : ''}
</div>`
: '';
const cross = closeCross
? `<button type="button" class="dialog-close" data-dlg="cancel" aria-label="${esc(labels.close)}"
title="${esc(labels.close)}">${closeIcon}</button>`
: '';
overlayEl.innerHTML = `
<div class="dialog${wide ? ' dialog--wide' : ''}" role="dialog" aria-modal="true" tabindex="-1">
${cross}${head}${body}${actions}
</div>`;
const backdropCloses = !(showOk || showCancel || closeCross);
overlayEl.addEventListener('click', e => {
if (e.target === overlayEl) { if (backdropCloses) settle(false); return; }
const b = e.target.closest('[data-dlg]');
if (b) settle(b.dataset.dlg === 'ok');
});
document.body.appendChild(overlayEl);
lockPage();
document.addEventListener('keydown', onKey, true);
(overlayEl.querySelector('[data-dlg="ok"]') || overlayEl.querySelector('.dialog')).focus();
});
}
export function stepper({ title, steps, state = {}, skipLabel, finishLabel, hero = '' } = {}) {
if (overlayEl) settle(false);
return new Promise(resolve => {
activeResolve = resolve;
const visible = () => steps.filter(s => !(s.skip && s.skip(state)));
let i = 0;
overlayEl = document.createElement('div');
overlayEl.className = 'dialog-overlay dialog-overlay--focus';
overlayEl.innerHTML = `
<div class="dialog dialog--wide dialog-stepper${hero ? ' dialog-stepper--hero' : ''}" role="dialog" aria-modal="true" tabindex="-1">
${hero ? `<div class="stepper-hero" aria-hidden="true">${hero}</div>` : ''}
${title ? `<div class="dialog-title"><span class="dialog-title-text">${esc(title)}</span></div>` : ''}
<div class="dialog-body dialog-body--rich"></div>
<div class="dialog-actions stepper-actions"></div>
</div>`;
const body = overlayEl.querySelector('.dialog-body');
const actions = overlayEl.querySelector('.stepper-actions');
const draw = () => {
const list = visible();
i = Math.min(i, list.length - 1);
const step = list[i];
const last = i === list.length - 1;
const ready = !step.ready || step.ready(state);
body.innerHTML = `${step.title ? `<div class="stepper-title">${esc(step.title)}</div>` : ''}${step.html(state)}`;
actions.innerHTML = `
<button type="button" class="btn btn-ghost btn-md stepper-skip" data-step="skip">${esc(skipLabel || labels.skip)}</button>
${i > 0 ? `<button type="button" class="btn btn-ghost btn-md" data-step="back">${esc(labels.back)}</button>` : ''}
<button type="button" class="btn btn-primary btn-md" data-step="next"${ready ? '' : ' disabled'}>${esc(last ? (finishLabel || labels.finish) : labels.next)}</button>`;
const focus = overlayEl.querySelector('[data-step="next"]:not([disabled])') || overlayEl.querySelector('.dialog');
focus.focus();
};
overlayEl.addEventListener('click', e => {
const set = e.target.closest('[data-step-set]');
if (set) { state[set.dataset.stepSet] = set.dataset.value; draw(); return; }
const toggle = e.target.closest('[data-step-toggle]');
if (toggle) {
const key = toggle.dataset.stepToggle;
const v = toggle.dataset.value;
const list = Array.isArray(state[key]) ? state[key] : [];
state[key] = list.includes(v) ? list.filter(x => x !== v) : [...list, v];
draw();
return;
}
const b = e.target.closest('[data-step]');
if (!b || b.disabled) return;
if (b.dataset.step === 'skip') settle(false);
else if (b.dataset.step === 'back') { i = Math.max(0, i - 1); draw(); }
else if (i === visible().length - 1) settle(state);
else { i += 1; draw(); }
});
document.body.appendChild(overlayEl);
lockPage();
document.addEventListener('keydown', onKey, true);
draw();
});
}
export function confirm({ title, message, confirmLabel, cancelLabel, danger } = {}) {
if (overlayEl && overlayIsDetail) return confirmOver({ title, message, confirmLabel, cancelLabel, danger });
return open({ title, message, confirmLabel, cancelLabel, danger, showCancel: true });
}
export function alert({ title, message, confirmLabel } = {}) {
if (overlayEl && overlayIsDetail) {
return openTop({ title, bodyHtml: `<div class="dialog-body">${esc(message)}</div>`, showCancel: false, confirmLabel });
}
return open({ title, message, confirmLabel, danger: false, showCancel: false });
}
export function notice({ title, message } = {}) {
return open({ title, message, showCancel: false, showOk: false });
}
export function detail({ title, html, size, titleHtml, asideHtml } = {}) {
const done = open({ title, bodyHtml: html ?? '', wide: true, showCancel: false, showOk: false,
closeCross: true, titleAside: true });
overlayIsDetail = true;
const d = overlayEl.querySelector('.dialog');
if (size === 'xl') d.classList.add('dialog--xl');
if (titleHtml) d.querySelector('.dialog-title-text').innerHTML = titleHtml;
if (asideHtml) d.querySelector('[data-dialog-aside]').innerHTML = asideHtml;
if (onDetail) onDetail(d, done);
return done;
}
function fieldsHtml(fields) {
return `<div class="dialog-body">
${fields.map(f => `
<div class="form-group">
<label class="form-label">${esc(f.label)}</label>
${f.type === 'textarea'
? `<textarea class="input" rows="4" data-dlg-field="${esc(f.key)}" placeholder="${esc(f.placeholder ?? '')}" autocomplete="off">${esc(f.value ?? '')}</textarea>`
: f.type === 'select'
? `<select class="input" data-dlg-field="${esc(f.key)}">${(f.options || []).map(o =>
`<option value="${esc(o.value)}"${String(o.value) === String(f.value ?? '') ? ' selected' : ''}>${esc(o.label)}</option>`).join('')}</select>`
: `<input class="input" type="${esc(f.type || 'text')}" data-dlg-field="${esc(f.key)}"
value="${esc(f.value ?? '')}" placeholder="${esc(f.placeholder ?? '')}" autocomplete="off">`}
</div>`).join('')}
</div>`;
}
const fieldValues = host => {
const out = {};
host.querySelectorAll('[data-dlg-field]').forEach(i => { out[i.dataset.dlgField] = i.value; });
return out;
};
export function form({ title, fields = [], confirmLabel, cancelLabel } = {}) {
if (overlayEl && overlayIsDetail) {
return openTop({ title, bodyHtml: fieldsHtml(fields), showCancel: true, confirmLabel, cancelLabel, collect: fieldValues });
}
if (overlayEl) settle(false);
return new Promise(resolve => {
activeResolve = resolve;
overlayEl = document.createElement('div');
overlayEl.className = 'dialog-overlay';
overlayEl.innerHTML = `
<div class="dialog" role="dialog" aria-modal="true">
${title ? `<div class="dialog-title">${esc(title)}</div>` : ''}
${fieldsHtml(fields)}
<div class="dialog-actions">
<button type="button" class="btn btn-ghost btn-md" data-dlg="cancel">${esc(cancelLabel || labels.cancel)}</button>
<button type="button" class="btn btn-primary btn-md" data-dlg="ok">${esc(confirmLabel || labels.ok)}</button>
</div>
</div>`;
const values = () => fieldValues(overlayEl);
overlayEl.addEventListener('click', e => {
if (e.target === overlayEl) return;
const b = e.target.closest('[data-dlg]');
if (b) settle(b.dataset.dlg === 'ok' ? values() : null);
});
overlayEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && e.target.matches('input[data-dlg-field]')) { e.preventDefault(); settle(values()); }
});
document.body.appendChild(overlayEl);
lockPage();
document.addEventListener('keydown', onKey, true);
const first = overlayEl.querySelector('[data-dlg-field]');
if (first) first.focus();
});
}
+8
View File
@@ -0,0 +1,8 @@
// Generated, do not edit. sha256:8efb043edf9aeef15b56ebe14e6ecd130f9141c2173576297d6d687a1b9c7d88
import { esc } from './escape.js';
export function emptyStateHtml(text, { why = '', href = '', linkText = '' } = {}) {
const reden = why ? ` <span class="empty-state-why">${esc(why)}</span>` : '';
const link = href && linkText ? ` <a href="${esc(href)}">${esc(linkText)}</a>` : '';
return `<div class="empty-state">${esc(text)}${reden}${link}</div>`;
}
+18
View File
@@ -0,0 +1,18 @@
// Generated, do not edit. sha256:38e0d607ac0466a47b09dd863442a411b1bd579da41867454f04940a19819271
import { t } from '../i18n/index.js';
import { esc } from './escape.js';
import { alertHtml } from './alert.js';
const label = (key, fallback) => { try { return t(key); } catch { return fallback; } };
export function renderErrorPanel(host, err, context) {
if (!host) return;
console.error(`[render] ${context || ''}`, err);
const title = label('error.panelTitle', 'Er ging iets mis bij het laden van deze sectie');
const hint = label('error.panelHint', 'De rest van de app blijft werken. Details hieronder (ook in de console).');
const msg = err && err.message ? err.message : String(err);
host.innerHTML = alertHtml({
kind: 'error', title, body: hint,
extra: `<pre style="white-space:pre-wrap;margin:0.5rem 0 0;font-size:0.78rem;opacity:0.85">${esc(msg)}</pre>`,
});
}
+10
View File
@@ -0,0 +1,10 @@
// Generated, do not edit. sha256:b6966cd87b9511be3b1677051299af34144f53d2923d0082003d5a66a5025eb3
const HTML_ENTITIES = Object.freeze({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
});
export const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => HTML_ENTITIES[c]);
+32
View File
@@ -0,0 +1,32 @@
// Generated, do not edit. sha256:1a4cf8672609e7907c7ebb7585b76d7f298027e896295fa767475cc915ce9da4
import { getPref, setPref } from '../data/preferences.js';
const SCOPE = 'expand';
export async function expanders(pagina, standaard = {}) {
let bewaard = {};
try {
const rij = await getPref(SCOPE, pagina, {});
if (rij && typeof rij === 'object' && !Array.isArray(rij)) bewaard = rij;
} catch { }
const stand = { ...standaard, ...bewaard };
return {
open: naam => stand[naam] === true,
attr(naam) {
return `data-expand="${naam}"${stand[naam] === true ? ' open' : ''}`;
},
bind(host) {
if (!host) return;
for (const kader of host.querySelectorAll('[data-expand]')) {
kader.addEventListener('toggle', () => {
stand[kader.dataset.expand] = kader.open;
setPref(SCOPE, pagina, stand).catch(() => { });
});
}
},
};
}
+142
View File
@@ -0,0 +1,142 @@
// Generated, do not edit. sha256:90b33d041c8c7e24dc16bdda9708154576175dfedc5c9ea7860984b38b588f03
import { listAssets } from '../data/assets.js';
import { upsertPriceHistory, getPricePoint, getPriceAt } from '../data/price-history.js';
import { onchainAsset } from '../data/tx-links.js';
import { liveSpotFor, ensurePriceHistoryFor, hasDailyGridFor, historicalPointForVia, primarySourceKey, lastSpotSource, basePriceOf, rateOn } from '../price/providers.js';
import { priceAtMoment } from '../data/market.js';
import { baseCurrency } from '../app-settings.js';
import { getProvider, listProviders } from '../data/providers.js';
import { provenanceOfRow, originFrom, liveOrigin } from '../../shared/price-provenance.js';
import { fmtBase, fmtMoney, isBaseAsset, priceFromBase } from './format.js';
const spot = new Map();
const hist = new Map();
const defaultTarget = async () => onchainAsset();
const mainValue = (amountSub, asset) => amountSub / Math.pow(10, asset.decimals ?? 8);
const histKey = (assetId, dateKey) => `${assetId}:${dateKey}`;
const unixAtMidnight = dateKey => Math.floor(Date.parse(`${dateKey}T00:00:00Z`) / 1000);
export async function loadFiat(assets = null) {
const targets = assets || [await defaultTarget()];
for (const asset of targets) {
if (!asset) continue;
try {
const s = await liveSpotFor(asset);
spot.set(asset.id, s || { price: null });
} catch { }
}
return spot;
}
export function fiatValue(amountSub, asset) {
if (isBaseAsset(asset)) return '';
const s = asset && spot.get(asset.id);
if (!s) return '';
const price = basePriceOf(s);
if (price == null) return '';
return fmtMoney(mainValue(amountSub, asset) * price, baseCurrency());
}
export async function loadHistorical(items) {
if (!namen.size) await laadNamen();
const byAsset = new Map();
for (const it of items) {
if (!it || !it.assetId || !it.dateKey) continue;
if (!byAsset.has(it.assetId)) byAsset.set(it.assetId, new Set());
byAsset.get(it.assetId).add(it.dateKey);
}
for (const [assetId, keys] of byAsset) {
const asset = (await listAssets()).find(a => a.id === assetId);
if (asset) await loadHistoricalForAsset(asset, keys);
}
}
async function loadHistoricalForAsset(asset, keys) {
const gridSrc = await primarySourceKey(asset, 'daily_history');
const pointSrc = await primarySourceKey(asset, 'history_point');
const readSrc = gridSrc || pointSrc;
const hasGrid = await hasDailyGridFor(asset);
const punten = await Promise.all([...keys].map(k => getPricePoint(asset.id, k)));
if (hasGrid && punten.some(p => !p)) {
try { await ensurePriceHistoryFor(asset); } catch { }
}
for (const key of keys) {
const hk = histKey(asset.id, key);
if (hist.has(hk)) continue;
const stored = await getPriceAt(asset.id, unixAtMidnight(key), readSrc);
if (stored) { hist.set(hk, stored.fiatPrice); histRij.set(hk, stored); continue; }
if (!hasGrid) {
try {
const p = await historicalPointForVia(asset, key);
if (p) {
await upsertPriceHistory(asset.id, [p], pointSrc, 'day');
hist.set(hk, p.price);
const bron = await getProvider(pointSrc).catch(() => null);
histRij.set(hk, { fiatPrice: p.price, providerId: bron ? bron.id : null, resolution: 'day',
observedAt: `${key}T00:00:00Z`, createdAt: new Date().toISOString() });
}
else hist.set(hk, null);
} catch { hist.set(hk, null); }
} else {
hist.set(hk, null);
}
}
}
export function fiatHistorical(amountSub, asset, dateKey) {
if (!asset) return '';
const price = hist.get(histKey(asset.id, dateKey));
if (price == null) return '';
return fmtBase(mainValue(amountSub, asset) * price);
}
const histRij = new Map();
let namen = new Map();
let manualId = null;
async function laadNamen() {
try {
const providers = await listProviders();
namen = new Map(providers.map(p => [p.id, p.name]));
const m = providers.find(p => p.key === 'manual');
manualId = m ? m.id : null;
} catch { }
}
export function fiatHistoricalOrigin(asset, dateKey) {
if (!asset || isBaseAsset(asset)) return null;
const rij = histRij.get(histKey(asset.id, dateKey));
return rij ? originFrom(provenanceOfRow(rij, { day: dateKey, manualProviderId: manualId }), namen) : null;
}
export function fiatValueOrigin(asset) {
if (!asset || isBaseAsset(asset)) return null;
const s = spot.get(asset.id);
const prijs = basePriceOf(s);
return prijs != null ? liveOrigin(prijs, lastSpotSource(asset)) : null;
}
export async function spotOnDate(asset, dateKey) {
if (!asset || !dateKey) return null;
try { await loadHistorical([{ assetId: asset.id, dateKey }]); } catch { }
const pt = await getPricePoint(asset.id, dateKey);
return pt && pt.fiatPrice != null ? priceFromBase(pt.fiatPrice, asset) : null;
}
export async function spotAtMoment(asset, atIso) {
if (!asset || !atIso) return null;
try {
const r = await priceAtMoment(asset.id, atIso);
if (r.status !== 'ok') return null;
const rate = await rateOn(r.currency, String(atIso).slice(0, 10));
if (rate == null) return null;
return { price: priceFromBase(r.price * rate, asset), at: r.at, source: r.source };
} catch {
return null;
}
}
export function fiatAtPrice(amountSub, decimals, pricePerUnit) {
if (pricePerUnit == null) return '';
return fmtBase((amountSub / Math.pow(10, decimals ?? 8)) * pricePerUnit);
}
+52
View File
@@ -0,0 +1,52 @@
// Generated, do not edit. sha256:8ff8b9a894c249281ed87f26a36d73c4e403eda8ce50d7da357c4aad90e8f817
import { esc } from './escape.js';
const DEFAULT_LABELS = Object.freeze({ all: 'All', none: 'None' });
let labels = { ...DEFAULT_LABELS };
export function configureFilter({ labels: l = null } = {}) {
labels = { ...DEFAULT_LABELS, ...(l || {}) };
}
export function resolveSelection({ saved, items, initial }) {
const ids = items.map(i => i.id);
const valid = new Set(ids);
if (Array.isArray(saved)) {
const kept = saved.filter(id => valid.has(id));
if (kept.length || saved.length === 0) return new Set(kept);
}
const init = initial ? [...initial(items)].filter(id => valid.has(id)) : ids;
return new Set(init.length ? init : ids);
}
export function create(host, opts) {
const items = opts.items || [];
const selected = opts.selected instanceof Set ? opts.selected : new Set(opts.selected || []);
const dis = opts.disabled ? ' disabled' : '';
const collapsedIds = opts.collapsedIds instanceof Set ? opts.collapsedIds : new Set();
host.classList.add('fbar', 'fbar--chip', ...(opts.className ? opts.className.split(' ') : []));
const allOn = items.every(i => selected.has(i.id));
const act = `<button type="button" class="fbar-item fbar-act" data-fact${dis}>${esc(allOn ? labels.none : labels.all)}</button>`;
const btn = (i, idx) =>
`<button type="button" class="fbar-item${selected.has(i.id) ? ' is-on' : ''}${collapsedIds.has(i.id) ? ' is-collapsed' : ''}" data-fi="${idx}" title="${esc(i.title || i.label)}"${dis}>${esc(i.label)}</button>`;
host.innerHTML = act + items.map(btn).join('');
host.onclick = (e) => {
if (opts.disabled) return;
if (e.target.closest('[data-fact]')) {
opts.onChange(allOn ? new Set() : new Set(items.map(i => i.id)));
return;
}
const b = e.target.closest('[data-fi]');
if (!b) return;
const id = items[Number(b.dataset.fi)].id;
if (typeof opts.onCycle === 'function') { opts.onCycle(id); return; }
const next = new Set(selected);
if (next.has(id)) next.delete(id); else next.add(id);
opts.onChange(next);
};
}
+147
View File
@@ -0,0 +1,147 @@
// Generated, do not edit. sha256:de7888fd00d7c2b69a752977ab166eb917e92dd391509e94d32511d630d11079
import { CONFIG } from '../../shared/config.js';
import { isMetal, metalPrimary, metalSecondary, fromBaseByFactor } from '../../shared/units.js';
import { esc } from './escape.js';
import { appSetting, baseCurrency } from '../app-settings.js';
import { localeTag } from '../i18n/index.js';
export {
isMetal, hasSubunit, toBaseByFactor, fromBaseByFactor,
mainToSub, subToMain, toSubunit, fromSubunit,
priceToBase, priceFromBase,
} from '../../shared/units.js';
export const fmtInt = n => Math.round(n).toLocaleString(localeTag());
export function parseLocaleNumber(text) {
const s = String(text ?? '').replace(/[^0-9.,-]/g, '');
if (!/\d/.test(s)) return NaN;
const decimal = new Intl.NumberFormat(localeTag()).formatToParts(1.5).find(p => p.type === 'decimal').value;
const group = decimal === ',' ? '.' : ',';
let whole = s, frac = '';
const at = s.lastIndexOf(decimal);
if (at >= 0) {
whole = s.slice(0, at);
frac = s.slice(at + 1);
} else {
const pieces = s.split(group);
const head = pieces[0].replace('-', '');
if (pieces.length === 2 && (pieces[1].length !== 3 || head === '' || head === '0')) [whole, frac] = pieces;
}
return Number(`${whole.replaceAll(group, '') || '0'}.${frac.replace(/[.,]/g, '') || '0'}`);
}
const MUNT = new Map();
function munt(currency) {
const code = String(currency).toUpperCase();
if (!CONFIG.PORTFOLIO.BASE_CURRENCIES.includes(code)) throw new Error(`Unknown currency: '${currency}'.`);
const key = `${localeTag()}|${code}`;
if (!MUNT.has(key)) {
const nf = new Intl.NumberFormat(localeTag(), { style: 'currency', currency: code, currencyDisplay: 'narrowSymbol' });
MUNT.set(key, {
sym: nf.formatToParts(0).find(p => p.type === 'currency').value,
dec: nf.resolvedOptions().maximumFractionDigits,
});
}
return MUNT.get(key);
}
export const currencySymbol = currency => munt(currency).sym;
export function currencyName(currency) {
munt(currency);
const naam = new Intl.DisplayNames([localeTag()], { type: 'currency' }).of(String(currency).toUpperCase());
return naam.charAt(0).toUpperCase() + naam.slice(1);
}
export function fmtMoney(value, currency, { decimals } = {}) {
const { sym, dec } = munt(currency);
if (decimals != null) {
return `${sym} ${Number(value).toLocaleString(localeTag(), { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}`;
}
const abs = Math.abs(Number(value));
const maxDec = (abs > 0 && abs < 0.5 * Math.pow(10, -dec)) ? Math.min(8, dec - Math.floor(Math.log10(abs))) : dec;
return `${sym} ${Number(value).toLocaleString(localeTag(), { minimumFractionDigits: dec, maximumFractionDigits: maxDec })}`;
}
export const fmtBase = value => fmtMoney(value, baseCurrency());
export const pct = (fractie) => `${+(fractie * 100).toFixed(4)}%`;
export function fmtPct(fractie, { fine = false, signs = 'both' } = {}) {
if (typeof fractie !== 'number' || !Number.isFinite(fractie)) return '';
const tekst = d => Math.abs(fractie * 100).toLocaleString(localeTag(), { minimumFractionDigits: d, maximumFractionDigits: d });
const nul = s => !/[1-9]/.test(s);
let getal = tekst(1);
if (fine && nul(getal) && !nul(tekst(2))) getal = tekst(2);
const teken = nul(getal) ? '' : signFor(fractie >= 0, signs);
return `${teken}${getal}%`;
}
const signFor = (positief, signs) => (positief
? (signs === 'both' ? '+' : '')
: (signs === 'none' ? '' : '\u2212'));
export const colouredSigns = () => appSetting('result_signs');
export function fmtBaseSigned(value, signs) {
const v = Number(value) || 0;
if (Math.abs(v) < 0.005) return fmtBase(0);
const tekst = fmtBase(Math.abs(v));
const spatie = tekst.indexOf(' ');
return `${tekst.slice(0, spatie + 1)}${signFor(v > 0, signs)}${tekst.slice(spatie + 1)}`;
}
export function pctTone(fractie, opts = {}) {
const s = fmtPct(fractie, { ...opts, signs: 'both' });
return s.startsWith('+') ? 't-pos' : s.startsWith('\u2212') ? 't-neg' : '';
}
function priceDecimals(abs) {
if (abs >= 100) return 2;
if (abs >= 1) return 4;
return 8;
}
export function fmtPrice(value, currency) {
const { sym, dec } = munt(currency);
const n = Number(value);
const max = Math.max(dec, priceDecimals(Math.abs(n)));
return `${sym} ${n.toLocaleString(localeTag(), { minimumFractionDigits: dec, maximumFractionDigits: max })}`;
}
export const fmtBasePrice = value => fmtPrice(value, baseCurrency());
export const val = text => `<span class="val">${esc(text)}</span>`;
export const subline = html => (html ? `<div class="t-muted-cell t-subline">${html}</div>` : '');
const M = CONFIG.METALS;
function fmtMetal(amount, code) {
return `${fromBaseByFactor(amount, code).toLocaleString(localeTag(), { maximumFractionDigits: M.DISPLAY_DECIMALS })} ${code}`;
}
const isFiat = asset => !!asset && asset.kind === 'fiat';
const minDecimalen = (asset, dec) => isFiat(asset) ? dec : 0;
export const isBaseAsset = asset => !!asset && asset.symbol === baseCurrency();
export function fmtUnit(amount, asset) {
if (isMetal(asset)) return fmtMetal(amount, metalPrimary(asset));
const dec = asset.decimals ?? 0;
if (asset.displayUnit === 'subunit' && asset.subunit) return `${fmtInt(amount)} ${asset.subunit}`;
const main = amount / Math.pow(10, dec);
return `${main.toLocaleString(localeTag(), { minimumFractionDigits: minDecimalen(asset, dec), maximumFractionDigits: dec })} ${asset.unit}`;
}
export function fmtAmount(amount, asset) {
if (isMetal(asset)) {
const sec = metalSecondary(asset);
return { primary: fmtMetal(amount, metalPrimary(asset)), secondary: sec ? fmtMetal(amount, sec) : '' };
}
const dec = asset.decimals ?? 0;
const main = amount / Math.pow(10, dec);
const subStr = asset.subunit ? `${fmtInt(amount)} ${asset.subunit}` : '';
const mainStr = `${main.toLocaleString(localeTag(), { minimumFractionDigits: minDecimalen(asset, dec), maximumFractionDigits: dec })} ${asset.unit}`;
const useSub = asset.displayUnit === 'subunit' && asset.subunit;
return useSub ? { primary: subStr, secondary: mainStr } : { primary: mainStr, secondary: subStr };
}
+83
View File
@@ -0,0 +1,83 @@
// Generated, do not edit. sha256:ea89ed2f3c7667a985d117ad93531e86e7422f6ef0152c179e997cfe96d6abd0
import { t } from '../i18n/index.js';
import { esc } from './escape.js';
const TEXT = 'fill:var(--text-primary);font-size:13px';
const SOFT = 'fill:var(--text-sec);font-size:12px';
const label = (x, y, text, style = TEXT, anchor = 'start') =>
`<text x="${x}" y="${y}" text-anchor="${anchor}" style="${style}">${esc(text)}</text>`;
const svg = (w, h, caption, body) =>
`<svg viewBox="0 0 ${w} ${h}" role="img" aria-label="${esc(caption)}" `
+ 'style="display:block;width:100%;min-width:30rem;height:auto;font-family:inherit">'
+ `${body}</svg>`;
function standPeriod(caption) {
return svg(640, 184, caption, [
`<rect x="330" y="20" width="270" height="122" rx="6" style="fill:var(--accent-soft)"/>`,
label(465, 38, t('guide.figures.period'), 'fill:var(--accent-text);font-size:12px', 'middle'),
`<rect x="90" y="52" width="510" height="28" rx="4" style="fill:var(--text-sec);fill-opacity:0.18;stroke:var(--text-sec)"/>`,
label(102, 71, t('guide.figures.sinceBuy')),
`<rect x="330" y="94" width="270" height="28" rx="4" style="fill:var(--accent);fill-opacity:0.28;stroke:var(--accent)"/>`,
label(342, 113, t('guide.figures.inPeriod')),
`<line x1="40" y1="152" x2="600" y2="152" style="stroke:var(--text-sec)"/>`,
...[90, 330, 600].map(x => `<line x1="${x}" y1="146" x2="${x}" y2="158" style="stroke:var(--text-sec)"/>`),
label(90, 176, t('guide.figures.purchase'), SOFT, 'middle'),
label(330, 176, t('guide.figures.yearStart'), SOFT, 'middle'),
label(600, 176, t('guide.figures.now'), SOFT, 'end'),
].join(''));
}
function accountExample(caption) {
const y = v => (230 - ((v - 140) / 60) * 200).toFixed(1);
const cost = y(191.2), inn = y(154.92), now = y(186.43);
const waarde = [[260, inn], [300, 168], [340, 173], [380, 145], [420, 124], [460, 132], [495, 96], [520, now]];
const lijn = waarde.map(([x, v]) => `${x},${v}`).join(' ');
return svg(680, 290, caption, [
`<polygon points="${lijn} 520,${inn} 260,${inn}" style="fill:var(--pos-bg)"/>`,
`<line x1="60" y1="${cost}" x2="520" y2="${cost}" style="stroke:var(--text-sec);stroke-dasharray:5 4"/>`,
`<circle cx="80" cy="${cost}" r="4" style="fill:var(--text-sec)"/>`,
label(94, Number(cost) - 8, t('guide.figures.cost'), SOFT),
`<line x1="260" y1="${inn}" x2="520" y2="${inn}" style="stroke:var(--accent);stroke-width:2"/>`,
label(268, Number(inn) + 20, t('guide.figures.broughtIn'), 'fill:var(--accent-text);font-size:12px'),
`<polyline points="${lijn}" style="fill:none;stroke:var(--text-primary);stroke-width:2;stroke-linejoin:round"/>`,
`<circle cx="520" cy="${now}" r="4" style="fill:var(--text-primary)"/>`,
`<line x1="534" y1="${cost}" x2="534" y2="${now}" style="stroke:var(--neg);stroke-width:3"/>`,
label(544, Number(now) - 3, t('guide.figures.unrealized'), 'fill:var(--neg);font-size:12px'),
`<line x1="534" y1="${Number(now) + 3}" x2="534" y2="${inn}" style="stroke:var(--pos);stroke-width:3"/>`,
label(544, 128, t('guide.figures.result'), 'fill:var(--pos);font-size:12px'),
label(544, 144, t('guide.figures.resultAmount'), 'fill:var(--pos);font-size:12px'),
`<line x1="60" y1="232" x2="520" y2="232" style="stroke:var(--text-sec)"/>`,
...[80, 260, 520].map(x => `<line x1="${x}" y1="226" x2="${x}" y2="238" style="stroke:var(--text-sec)"/>`),
label(80, 254, t('guide.figures.purchaseExchange'), SOFT, 'middle'),
label(260, 254, t('guide.figures.transfer'), SOFT, 'middle'),
label(520, 254, t('guide.figures.value'), SOFT, 'middle'),
`<line x1="60" y1="278" x2="84" y2="278" style="stroke:var(--text-primary);stroke-width:2"/>`,
label(90, 282, t('guide.figures.legendValue'), SOFT),
`<line x1="160" y1="278" x2="184" y2="278" style="stroke:var(--accent);stroke-width:2"/>`,
label(190, 282, t('guide.figures.legendBroughtIn'), SOFT),
`<line x1="320" y1="278" x2="344" y2="278" style="stroke:var(--text-sec);stroke-dasharray:5 4"/>`,
label(350, 282, t('guide.figures.legendCost'), SOFT),
].join(''));
}
function resultSum(caption) {
const deel = (x, w, opacity, text) =>
`<rect x="${x}" y="62" width="${w}" height="30" rx="4" style="fill:var(--accent);fill-opacity:${opacity};stroke:var(--accent)"/>`
+ label(x + 10, 82, text);
return svg(640, 104, caption, [
label(180, 32, t('guide.figures.result'), TEXT, 'end'),
`<rect x="196" y="12" width="404" height="30" rx="4" style="fill:var(--accent);fill-opacity:0.35;stroke:var(--accent)"/>`,
label(180, 82, t('guide.figures.roughly'), SOFT, 'end'),
deel(196, 220, 0.25, t('guide.figures.unrealizedPart')),
deel(420, 120, 0.15, t('guide.figures.realized')),
deel(544, 56, 0.06, t('guide.figures.interest')),
].join(''));
}
export const GUIDE_FIGURE_DRAWINGS = Object.freeze({
'stand-period': standPeriod,
'account-example': accountExample,
'result-sum': resultSum,
});
+72
View File
@@ -0,0 +1,72 @@
// Generated, do not edit. sha256:618e9d10819427320df9cea13ee26eca05db3121f4e69b541d2a8a431e85957a
import { esc } from './escape.js';
const MOUNT = 'page-hint';
const INFO = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="11"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>';
let lead = '';
let empty = null;
export function configureHint({ lead: l = '', empty: e = null } = {}) {
lead = l || '';
empty = e;
}
function legend(title, content, className = '') {
return `
<div class="hint${className ? ' ' + className : ''}">
<button type="button" class="hint-btn" aria-label="${esc(title)}">${INFO}</button>
<div class="hint-panel" role="tooltip">
<div class="t-label">${esc(title)}</div>
${lead}
${content}
</div>
</div>`;
}
export function setPageHint(host, { title, rows = [] } = {}) {
const mount = document.getElementById(MOUNT);
if (!mount || !host?.isConnected) return;
mount.innerHTML = legend(title, rows.map(row).join(''));
watchFit(mount);
}
export function clearPageHint() {
const mount = document.getElementById(MOUNT);
if (!mount) return;
mount.innerHTML = empty ? legend(empty.title, '', empty.className || '') : '';
if (empty) watchFit(mount);
}
function watchFit(mount) {
if (mount.dataset.fitWatch) return;
mount.dataset.fitWatch = '1';
const fit = () => {
const panel = mount.querySelector('.hint-panel');
if (!panel) return;
panel.classList.remove('is-wide');
if (panel.scrollHeight > panel.clientHeight + 1) panel.classList.add('is-wide');
};
mount.addEventListener('pointerenter', fit);
mount.addEventListener('focusin', fit);
}
function row(r) {
if (r && typeof r === 'object' && r.heading) return `<div class="hint-heading t-label">${r.heading}</div>`;
const text = typeof r === 'string' ? r : (r?.text ?? '');
const sample = typeof r === 'string' ? '' : (r?.sample ?? '');
return `<div class="hint-row"><span class="hint-sample">${sample}</span><span>${text}</span></div>`;
}
export const SAMPLES = Object.freeze({
line: '<span class="hint-line"></span>',
dashed: '<span class="hint-line is-dashed"></span>',
dot: '<span class="hint-dot"></span>',
dotOpen: '<span class="hint-dot is-open"></span>',
ring: '<span class="hint-ring"></span>',
cellFull: '<span class="hm-sample hm-full"></span>',
cellPart: '<span class="hm-sample hm-part"></span>',
cellLow: '<span class="hm-sample hm-low"></span>',
cellClosed: '<span class="hm-sample hm-closed"></span>',
cellNone: '<span class="hm-sample hm-none"></span>',
});
+39
View File
@@ -0,0 +1,39 @@
// Generated, do not edit. sha256:06627f5d01d37034867aaf9d6b234f888eb4b99f4ecd0e53cb5df7c45bce718a
export const CHEVRON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
export const CHEVRON_RIGHT = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>';
export const ARROW_DOWN = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>';
export const ARROW_UP = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>';
export const EYE = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>';
export const EYE_OFF = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
export const SWAP = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 4 7 20"/><polyline points="3 8 7 4 11 8"/><polyline points="17 20 17 4"/><polyline points="13 16 17 20 21 16"/></svg>';
export const BITCOIN = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23.638 14.904c-1.602 6.43-8.113 10.342-14.542 8.736C2.67 22.05-1.244 15.525.362 9.105 1.962 2.67 8.475-1.243 14.9.358c6.43 1.605 10.342 8.115 8.738 14.546zm-6.35-4.613c.24-1.59-.974-2.45-2.64-3.03l.54-2.153-1.315-.328-.525 2.107c-.345-.087-.705-.167-1.064-.25l.526-2.127-1.32-.33-.54 2.165c-.285-.067-.565-.132-.84-.2l-1.815-.45-.35 1.407s.974.225.955.236c.535.136.63.486.615.766l-1.477 5.92c-.075.166-.24.406-.614.314.015.02-.96-.24-.96-.24l-.66 1.51 1.71.426.93.242-.54 2.19 1.32.327.54-2.17c.36.1.705.19 1.05.273l-.51 2.154 1.32.33.545-2.19c2.24.427 3.93.257 4.64-1.774.57-1.637-.03-2.58-1.217-3.196.854-.193 1.5-.76 1.68-1.93zm-3.01 4.22c-.404 1.64-3.157.75-4.05.53l.72-2.9c.896.23 3.757.67 3.33 2.37zm.41-4.24c-.37 1.49-2.66.735-3.405.55l.654-2.64c.744.18 3.137.524 2.75 2.084z"/></svg>';
export const CHECK_CIRCLE = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
export const MERGE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="4" x2="12" y2="12"/><line x1="19" y1="4" x2="12" y2="12"/><line x1="12" y1="12" x2="12" y2="20"/></svg>';
export const ADJUST = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="7" x2="21" y2="7"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="17" x2="21" y2="17"/><circle cx="8" cy="7" r="2.3" fill="currentColor" stroke="none"/><circle cx="16" cy="12" r="2.3" fill="currentColor" stroke="none"/><circle cx="10" cy="17" r="2.3" fill="currentColor" stroke="none"/></svg>';
export const GRIP = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg>';
export const CLOSE = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
export const PLUS = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
export const PENCIL = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>';
export const TRASH = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
export const INFO = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="11"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>';
export const SHIELD = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l7 3v5c0 4.4-3 8.3-7 10-4-1.7-7-5.6-7-10V6l7-3z"/></svg>';
export const CHECK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
export const REFRESH = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
export const COPY = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
+83
View File
@@ -0,0 +1,83 @@
// Generated, do not edit. sha256:9856275bb06c64defb28cac958ae6f3ea1416a635a680ca0739edd9bad0379ee
import * as dialog from './dialog.js';
import { esc } from './escape.js';
let _seq = 0;
export function labelComboMarkup(fieldKey, currentId) {
const id = currentId == null ? '' : currentId;
const dl = `lc-dl-${++_seq}`;
return `<span class="label-combo" data-field="${esc(fieldKey)}" data-label-combo>
<input type="hidden" data-label-id value="${esc(id)}">
<input class="input label-combo-input" data-label-input list="${dl}" placeholder="label…" autocomplete="off">
<datalist id="${dl}"></datalist>
<button type="button" class="label-combo-btn" data-label-add title="Label maken/koppelen">+</button>
<button type="button" class="label-combo-btn" data-label-del title="Label ontkoppelen">−</button>
</span>`;
}
export function labelComboRead(host) {
if (!host) return null;
const v = host.querySelector('[data-label-id]').value;
return v === '' ? null : Number(v);
}
export function labelComboMount(host, labelApi, onChange) {
const hidden = host.querySelector('[data-label-id]');
const input = host.querySelector('[data-label-input]');
const datalist = host.querySelector('datalist');
const addBtn = host.querySelector('[data-label-add]');
const delBtn = host.querySelector('[data-label-del]');
let cache = [];
const byName = name => {
const n = name.trim().toLowerCase();
return cache.find(l => l.name.toLowerCase() === n) || null;
};
function refreshList() {
datalist.innerHTML = cache.map(l => `<option value="${esc(l.name)}"></option>`).join('');
}
function setId(id) {
hidden.value = id == null ? '' : id;
const cur = cache.find(l => String(l.id) === String(id));
input.value = cur ? cur.name : '';
delBtn.disabled = !cur;
}
async function load() {
cache = (await labelApi.listLabels()) || [];
refreshList();
}
input.onfocus = () => { input.value = ''; load(); };
input.onblur = () => setTimeout(() => {
if (document.activeElement === input) return;
if (!input.value.trim()) {
const cur = cache.find(l => String(l.id) === String(hidden.value));
input.value = cur ? cur.name : '';
}
}, 150);
input.onchange = () => {
const name = input.value.trim();
if (!name) { setId(''); onChange?.(null); return; }
const m = byName(name);
if (m) { setId(m.id); onChange?.(m.id); }
else { hidden.value = ''; delBtn.disabled = true; }
};
addBtn.onclick = async () => {
const name = input.value.trim();
if (!name) { input.focus(); return; }
const m = byName(name);
if (m) { setId(m.id); onChange?.(m.id); return; }
try {
const id = await labelApi.createLabel({ name });
await load(); setId(id); onChange?.(id);
} catch (e) { dialog.alert({ message: e.message }); }
};
delBtn.onclick = () => { setId(''); onChange?.(null); };
return load().then(() => setId(hidden.value || ''));
}
+81
View File
@@ -0,0 +1,81 @@
// Generated, do not edit. sha256:3eb303d976e1be5498b5aef4a7cdd95c577070d967bef44ed7c9e2767146c564
import { esc } from './escape.js';
const DEFAULT_LABELS = Object.freeze({ loading: 'Loading…', loadError: 'Could not load this.' });
let labels = { ...DEFAULT_LABELS };
export function configureLoading({ labels: l = null } = {}) {
labels = { ...DEFAULT_LABELS, ...(l || {}) };
}
const sizes = ({ w, h, r, flex } = {}) =>
(w ? ` data-skel-w="${esc(w)}"` : '') + (h ? ` data-skel-h="${esc(h)}"` : '')
+ (r ? ` data-skel-r="${esc(r)}"` : '') + (flex ? ` data-skel-flex="${esc(flex)}"` : '');
export function sizeSkeleton(host) {
if (!host) return;
for (const el of host.querySelectorAll('[data-skel-w], [data-skel-h], [data-skel-r], [data-skel-flex]')) {
const d = el.dataset;
if (d.skelW) el.style.setProperty('width', d.skelW);
if (d.skelH) el.style.setProperty('height', d.skelH);
if (d.skelR) el.style.setProperty('border-radius', d.skelR);
if (d.skelFlex) el.style.setProperty('flex', d.skelFlex);
}
}
export function block(w = '100%', h = '1rem', radius = 'var(--radius-sm)') {
return `<span class="skel skel-block"${sizes({ w, h, r: radius })}></span>`;
}
export function line(w = '100%') {
return `<span class="skel skel-line"${sizes({ w })}></span>`;
}
export function circle(size = '2rem') {
return `<span class="skel skel-block"${sizes({ w: size, h: size, r: '50%', flex: 'none' })}></span>`;
}
export function card({ lines = 3 } = {}) {
const body = Array.from({ length: lines }, (_, i) =>
`<div class="skel-row">${line(i === 0 ? '55%' : '35%')}${line('20%')}</div>`).join('');
return `<div class="skel-card">
<div class="skel-row"><div class="skel-row-l">${circle('1.6rem')}${line('40%')}</div>${block('3rem', '1.1rem')}</div>
<div class="skel-card-body">${body}</div>
</div>`;
}
export function table({ rows = 6, cols = 4 } = {}) {
const cell = (head) => `<span class="skel ${head ? 'skel-line' : 'skel-cell'}"${sizes({ w: head ? '60%' : '80%' })}></span>`;
const row = (head) => `<div class="skel-trow">${Array.from({ length: cols }, () => `<div class="skel-td">${cell(head)}</div>`).join('')}</div>`;
const bodyRows = Array.from({ length: rows }, () => row(false)).join('');
return `<div class="skel-table">${row(true)}${bodyRows}</div>`;
}
export function cards({ count = 3, lines = 3 } = {}) {
return `<div class="skel-grid">${Array.from({ length: count }, () => card({ lines })).join('')}</div>`;
}
export function skeleton(parts) {
const inner = Array.isArray(parts) ? parts.join('') : String(parts ?? '');
const label = `<span class="sr-only">${esc(labels.loading)}</span>`;
return `<div class="skel-wrap" aria-busy="true" aria-live="polite">${label}<div aria-hidden="true">${inner}</div></div>`;
}
export function show(host, variant = 'lines', opts = {}) {
if (!host) return;
let parts;
if (variant === 'cards') parts = cards(opts);
else if (variant === 'table') parts = table(opts);
else if (variant === 'card') parts = card(opts);
else parts = Array.from({ length: opts.count || 3 }, (_, i) => line(i % 2 ? '70%' : '90%'));
host.innerHTML = skeleton(parts);
sizeSkeleton(host);
}
export function spinner(host, label = labels.loading) {
if (!host) return;
host.innerHTML = `<div class="skel-spin-wrap" aria-busy="true" aria-live="polite">
<span class="skel-spinner" aria-hidden="true"></span>
<span class="t-body-sm t-muted">${esc(label)}</span>
</div>`;
}
export function error(host, msg) {
if (!host) return;
host.innerHTML = `<div class="skel-error t-body-sm t-neg" role="alert">⚠ ${esc(msg || labels.loadError)}</div>`;
}
+37
View File
@@ -0,0 +1,37 @@
// Generated, do not edit. sha256:4bf6f8ef0a0bf339d290a677844034b6b894f341f060833980b2dc5ccb489b09
import { esc } from './escape.js';
import { SHIELD } from './icons.js';
import { t } from '../i18n/index.js';
import { TARGETS, targetHash } from '../../shared/config.js';
import { limitFor } from '../license.js';
export function lockIcon(title = t('license.lockedTitle')) {
return `<span class="lock-icon" title="${esc(title)}" aria-label="${esc(title)}">${SHIELD}</span>`;
}
export function lockPanelHtml(what) {
const href = targetHash(TARGETS.LICENSE);
return `<div class="lock-panel">
<div class="lock-panel-head">${lockIcon()}<span>${esc(t('license.lockedTitle'))}</span></div>
<p class="lock-panel-what">${esc(what)}</p>
<p class="lock-panel-more"><a href="${esc(href)}">${esc(t('license.whatIsPaid'))}</a></p>
</div>`;
}
export function lockNoteHtml(what) {
return `<div class="lock-note">${lockIcon()}<span>${esc(what)}</span> `
+ `<a href="${esc(targetHash(TARGETS.LICENSE))}">${esc(t('license.whatIsPaid'))}</a></div>`;
}
export function counterHtml(text) {
return text ? `<span class="lock-counter">${esc(text)}</span>` : '';
}
export function capacityCfg(kind, used) {
const max = limitFor(kind);
if (max === null) return {};
return {
toolbarNote: () => t('license.counter', { n: used(), max }),
createHidden: () => used() >= max,
};
}
+52
View File
@@ -0,0 +1,52 @@
// Generated, do not edit. sha256:87d755cce2f1ccb8186c668965f676f63e76de1e6021910a7efccabd9473cc5d
export function spreidOverKolommen(hoogtes, kolommen) {
const n = Math.max(1, Math.floor(kolommen) || 1);
const som = new Array(n).fill(0);
return hoogtes.map(h => {
let k = 0;
for (let j = 1; j < n; j++) if (som[j] < som[k]) k = j;
som[k] += h;
return k;
});
}
export function kolomAantal(el) {
const raw = getComputedStyle(el).getPropertyValue('--cols');
const n = parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : 1;
}
export function layoutMasonry(host, kaartSelector = ':scope > *') {
const bestaande = [...host.querySelectorAll(':scope > .masonry-col')];
const kaarten = bestaande.length
? bestaande.flatMap(c => [...c.children])
: [...host.querySelectorAll(kaartSelector)];
if (!kaarten.length) { host.replaceChildren(); return; }
const n = kolomAantal(host);
const kolommen = Array.from({ length: n }, () => {
const c = document.createElement('div');
c.className = 'masonry-col';
return c;
});
host.replaceChildren(...kolommen);
kolommen[0].append(...kaarten);
if (n === 1) return;
const hoogtes = kaarten.map(k => k.offsetHeight);
const doel = spreidOverKolommen(hoogtes, n);
kaarten.forEach((k, i) => kolommen[doel[i]].appendChild(k));
}
export function volgMasonry(host, kaartSelector) {
let laatste = kolomAantal(host);
layoutMasonry(host, kaartSelector);
const onResize = () => {
const nu = kolomAantal(host);
if (nu === laatste) return;
laatste = nu;
layoutMasonry(host, kaartSelector);
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}
+130
View File
@@ -0,0 +1,130 @@
// Generated, do not edit. sha256:864ef84f3268d175b0ba11b7c30d7395b9e816338f8739d95658a7e8e95ba768
import { localDateNl } from '../../shared/time.js';
import {
linkTxEvent, unlinkTxEvent, onchainAsset, linksForTx, linksForEvent, eventCandidatesForTx,
txCandidatesForEvent
} from '../data/tx-links.js';
import { t } from '../i18n/index.js';
import { fmtInt } from './format.js';
import { openPopover, closePopover } from './popover.js';
import { esc } from './escape.js';
const trunc = s => (s && s.length > 22) ? `${s.slice(0, 10)}…${s.slice(-8)}` : (s || '');
const MANUAL = 'manual';
export function matchDot({ matched, method, kind, id }) {
const title = matched ? t('link.' + method) : t('match.add');
return `<span class="tx-link${matched ? ' is-matched' : ''}" data-match-trigger`
+ ` data-match-kind="${kind}" data-match-id="${id}" role="button" tabindex="0"`
+ ` title="${esc(title)}" aria-label="${esc(title)}"></span>`;
}
export function closeMatchPopover() { closePopover(); }
export async function openMatchPopover(anchorEl, { kind, id, assetId, onChange }) {
let changed = false;
const unit = (await onchainAsset() || {}).subunit || '';
const sats = n => `${fmtInt(n)} ${unit}`.trim();
const dateOf = iso => iso ? localDateNl(iso) : t('match.unconfirmed');
let filter = '';
const currentLinks = () => kind === 'tx' ? linksForTx(id) : linksForEvent(id);
const rawCands = () => kind === 'tx' ? eventCandidatesForTx(id, assetId) : txCandidatesForEvent(id);
const pairFor = itemId => kind === 'tx' ? { tx: id, ev: itemId } : { tx: itemId, ev: id };
const eventLine = e => ({
l1: `${esc(t('enums.event_type.' + e.type))} · ${sats(e.amount)}`,
l2: `${dateOf(e.occurredAt)} · ${esc(e.from_name || '-')} → ${esc(e.to_name || '-')}`,
text: `${t('enums.event_type.' + e.type)} ${e.amount} ${e.occurredAt} ${e.from_name || ''} ${e.to_name || ''}`,
});
const txLine = x => ({
l1: `${x.direction === 'in' ? '↓' : '↑'} ${sats(x.valueSats)}`,
l2: `${dateOf(x.blockTime)} · ${esc(x.account_name || '-')}${x.address ? ' · ' + esc(trunc(x.address)) : ''}`,
text: `${x.direction} ${x.valueSats} ${x.blockTime || ''} ${x.account_name || ''} ${x.txid || ''} ${x.address || ''}`,
});
const lineOf = item => kind === 'tx' ? eventLine(item) : txLine(item);
async function candidates() {
const list = await rawCands();
if (!filter) return list;
const f = filter.toLowerCase();
return list.filter(c => lineOf(c).text.toLowerCase().includes(f));
}
async function syncAnchor() {
if (anchorEl.isConnected) anchorEl.classList.toggle('is-matched', (await currentLinks()).length > 0);
}
const { el: pop, position } = openPopover(anchorEl, {
className: 'match-pop',
onClose: () => {
document.querySelectorAll('.tx-link.is-open').forEach(d => d.classList.remove('is-open'));
if (changed && onChange) onChange();
},
});
anchorEl.classList.add('is-open');
async function draw() {
const links = await currentLinks();
const cands = await candidates();
const addLabel = kind === 'tx' ? t('match.toEvent') : t('match.toTx');
const linksHtml = links.length ? `
<div class="t-label match-pop-head">${t('match.linked')}</div>
${links.map(it => {
const ln = lineOf(it);
return `<div class="match-pop-link">
<div class="match-pop-main"><div class="l1">${ln.l1}</div><div class="l2">${ln.l2}</div><span class="match-pop-method">${esc(t('link.' + it.method))}</span></div>
<button class="match-pop-x" data-unlink="${it.id}" title="${esc(t('match.unlink'))}" aria-label="${esc(t('match.unlink'))}">×</button>
</div>`;
}).join('')}
<div class="match-pop-sep"></div>` : '';
const listHtml = cands.length
? cands.map(c => {
const ln = lineOf(c);
return `<div class="match-pop-cand" data-link="${c.id}">
<div class="match-pop-main"><div class="l1">${ln.l1}</div><div class="l2">${ln.l2}</div></div>
</div>`;
}).join('')
: `<div class="match-pop-empty">${t('match.noCandidates')}</div>`;
pop.innerHTML = `${linksHtml}
<div class="t-label match-pop-head">${esc(addLabel)}</div>
<input class="input match-pop-filter" data-filter placeholder="${esc(t('match.filter'))}" value="${esc(filter)}">
<div class="match-pop-list">${listHtml}</div>`;
position();
}
pop.addEventListener('click', async e => {
const un = e.target.closest('[data-unlink]');
if (un) { const p = pairFor(Number(un.dataset.unlink)); await unlinkTxEvent(p.tx, p.ev); changed = true; await syncAnchor(); await draw(); return; }
const lk = e.target.closest('[data-link]');
if (lk) { const p = pairFor(Number(lk.dataset.link)); await linkTxEvent(p.tx, p.ev, MANUAL); changed = true; await syncAnchor(); await draw(); return; }
});
pop.addEventListener('input', e => {
if (e.target.matches('[data-filter]')) { filter = e.target.value; draw(); }
});
await draw();
}
export function wireMatchTriggers(container, optsFactory) {
const open = el => openMatchPopover(el, optsFactory(el.dataset.matchKind, Number(el.dataset.matchId)));
const onClick = e => {
const dot = e.target.closest('[data-match-trigger]');
if (!dot || !container.contains(dot)) return;
e.preventDefault(); e.stopPropagation();
open(dot);
};
const onKey = e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const dot = e.target.closest('[data-match-trigger]');
if (!dot || !container.contains(dot)) return;
e.preventDefault(); e.stopPropagation();
open(dot);
};
container.addEventListener('click', onClick, true);
container.addEventListener('keydown', onKey, true);
}
+68
View File
@@ -0,0 +1,68 @@
// Generated, do not edit. sha256:dd3cdccc38ee940cd5da182fe49a2feb4a0d00eb7f08ee8c95f1fe0e712cbd46
import { getAssetNote, setAssetNote } from '../data/asset-notes.js';
import { t } from '../i18n/index.js';
import { esc } from './escape.js';
const SAVE_DELAY_MS = 700;
function nuLokaal() {
const d = new Date();
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
export function notesHtml() {
return `<div class="notes">
<div class="notes-add">
<input type="datetime-local" class="input notes-when" data-notes-when aria-label="${esc(t('notes.when'))}" title="${esc(t('notes.when'))}">
<input type="text" class="input notes-line" data-notes-line placeholder="${esc(t('notes.line'))}" aria-label="${esc(t('notes.line'))}" autocomplete="off">
</div>
<textarea class="input notes-body" data-notes-body placeholder="${esc(t('notes.body'))}" aria-label="${esc(t('notes.tab'))}"></textarea>
<div class="notes-status t-caption t-muted" data-notes-status aria-live="polite"></div>
</div>`;
}
export async function mountNotes(host, assetId) {
const when = host.querySelector('[data-notes-when]');
const line = host.querySelector('[data-notes-line]');
const body = host.querySelector('[data-notes-body]');
const status = host.querySelector('[data-notes-status]');
if (!when || !line || !body) return;
when.value = nuLokaal();
let whenTouched = false;
when.addEventListener('input', () => { whenTouched = true; });
line.addEventListener('focus', () => { if (!whenTouched) when.value = nuLokaal(); });
let opgeslagen = '';
try { opgeslagen = (await getAssetNote(assetId))?.body || ''; } catch (e) { status.textContent = e.message; }
body.value = opgeslagen;
let timer = null;
async function bewaar() {
clearTimeout(timer); timer = null;
const tekst = body.value;
if (tekst === opgeslagen) return;
status.textContent = t('notes.saving');
try {
await setAssetNote(assetId, tekst);
opgeslagen = tekst;
status.textContent = t('notes.saved');
} catch (e) { status.textContent = e.message; }
}
body.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(bewaar, SAVE_DELAY_MS); });
body.addEventListener('blur', bewaar);
line.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
e.preventDefault();
const tekst = line.value.trim();
if (!tekst) return;
const stempel = (when.value || nuLokaal()).replace('T', ' ');
const rest = body.value.replace(/^\s+/, '');
body.value = `${stempel} ${tekst}${rest ? `\n${rest}` : ''}`;
line.value = '';
whenTouched = false;
when.value = nuLokaal();
bewaar();
});
}
+11
View File
@@ -0,0 +1,11 @@
// Generated, do not edit. sha256:e707d55c60167dc48ebad04a3ae56015d4265e62aba96f4c7672c6d5d9b9e466
import { configureHint, setPageHint, clearPageHint, SAMPLES } from './hint.js';
import { INFO } from './icons.js';
import { t } from '../i18n/index.js';
configureHint({
lead: `<div class="hint-row hint-warn"><span class="hint-sample">${INFO}</span><span>${t('priceOrigin.pageWarn')}</span></div>`,
empty: { title: t('priceOrigin.pageWarnTitle'), className: 'is-fallback' },
});
export { setPageHint, clearPageHint, SAMPLES };
+60
View File
@@ -0,0 +1,60 @@
// Generated, do not edit. sha256:0538b89d6c5865b07db285c6e78de905962d2d2f2b35ac9aeee58cf04054fbe2
let current = null;
export function isPopoverOpen() { return current !== null; }
export function closePopover() {
if (!current) return;
const c = current;
current = null;
c.cleanup();
c.el.remove();
if (c.onClose) c.onClose();
}
export function openPopover(anchorEl, { className, onClose } = {}) {
closePopover();
const el = document.createElement('div');
el.className = className || 'popover';
const overlays = document.querySelectorAll('.dialog-overlay');
(overlays.length ? overlays[overlays.length - 1] : document.body).appendChild(el);
function position() {
const r = anchorEl.getBoundingClientRect();
const pad = 8;
let left = r.left;
let top = r.bottom + 6;
const pw = el.offsetWidth, ph = el.offsetHeight;
if (left + pw > window.innerWidth - pad) left = window.innerWidth - pad - pw;
if (left < pad) left = pad;
if (top + ph > window.innerHeight - pad) top = Math.max(pad, r.top - ph - 6);
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}
const reposition = () => { if (current && current.el === el) position(); };
const onDocClick = e => {
if (el.contains(e.target) || (anchorEl && anchorEl.contains(e.target))) return;
closePopover();
};
const onKey = e => { if (e.key === 'Escape') closePopover(); };
let bindTimer = null;
const cleanup = () => {
clearTimeout(bindTimer);
document.removeEventListener('click', onDocClick, true);
document.removeEventListener('keydown', onKey);
window.removeEventListener('resize', reposition);
window.removeEventListener('scroll', reposition, true);
};
current = { el, cleanup, onClose };
bindTimer = setTimeout(() => document.addEventListener('click', onDocClick, true), 0);
document.addEventListener('keydown', onKey);
window.addEventListener('resize', reposition);
window.addEventListener('scroll', reposition, true);
return { el, position, close: closePopover };
}
+169
View File
@@ -0,0 +1,169 @@
// Generated, do not edit. sha256:b9ab466fcbb17da69242310739ff9bb1a6f805583cc7c757d134c773ce086dc3
import { t, localeTag } from '../i18n/index.js';
import { esc } from './escape.js';
import { INFO } from './icons.js';
import { openPopover, closePopover } from './popover.js';
import { fmtBasePrice, priceFromBase, isBaseAsset as isBase } from './format.js';
import { priceUnitLabel } from '../../shared/units.js';
import { assetLabel } from './asset-label.js';
const dagTekst = iso => new Intl.DateTimeFormat(localeTag(), { day: 'numeric', month: 'short', year: 'numeric' })
.format(new Date(`${iso}T00:00:00Z`));
const tijdTekst = iso => new Intl.DateTimeFormat(localeTag(), { hour: '2-digit', minute: '2-digit' }).format(new Date(iso));
const pct = v => `${v > 0 ? '+' : ''}${v.toLocaleString(localeTag(), { maximumFractionDigits: 2 })}%`;
const STERKTE = { close: 'high', manual: 'high', live: 'high', spot: 'lower', carried: 'low' };
export function originLines(origin, asset) {
if (!origin || origin.fiatPrice == null || isBase(asset)) return [];
const prijs = fmtBasePrice(priceFromBase(origin.fiatPrice, asset));
const eenheid = asset ? priceUnitLabel(asset) : null;
const bron = origin.source || t('priceOrigin.someSource');
const regels = [eenheid
? t('priceOrigin.pricePer', { prijs, eenheid, dag: dagTekst(origin.day) })
: t('priceOrigin.price', { prijs, dag: dagTekst(origin.day) })];
if (origin.kind === 'close') regels.push(t('priceOrigin.close', { bron }));
else if (origin.kind === 'manual') regels.push(t('priceOrigin.manual'));
else if (origin.kind === 'live') regels.push(origin.source ? t('priceOrigin.liveFrom', { bron }) : t('priceOrigin.live'));
else if (origin.kind === 'spot') {
regels.push(origin.moment ? t('priceOrigin.spotAt', { tijd: tijdTekst(origin.moment), bron }) : t('priceOrigin.spot', { bron }));
const d = origin.deviation;
const kanten = d ? [d.before != null ? t('priceOrigin.devBefore', { pct: pct(d.before) }) : null,
d.after != null ? t('priceOrigin.devAfter', { pct: pct(d.after) }) : null].filter(Boolean) : [];
if (kanten.length) regels.push(kanten.join(', '));
} else if (origin.kind === 'carried') {
regels.push(t('priceOrigin.carried', { dag: dagTekst(origin.sourceDay || origin.day), n: origin.ageDays, bron }));
}
const sterkte = STERKTE[origin.kind];
if (sterkte) regels.push(t('priceOrigin.strength', { sterkte: t(`priceOrigin.strength_${sterkte}`) }));
return regels;
}
export function originHtml(origin, asset, voor = []) {
return knopHtml([...voor, ...originLines(origin, asset)]);
}
const RANG = { high: 3, lower: 2, low: 1 };
const rang = o => RANG[STERKTE[o.kind]] ?? 0;
export function originSummaryLines(items) {
return samenvattingVan(items).regels;
}
function samenvattingVan(items) {
const assetSleutel = x => (x.asset ? x.asset.id ?? x.asset.symbol : '?');
const uniek = new Map();
const zonder = new Map();
for (const x of items || []) {
if (!x || isBase(x.asset)) continue;
if (!x.origin || x.origin.fiatPrice == null) { if (!zonder.has(assetSleutel(x))) zonder.set(assetSleutel(x), x); continue; }
const sleutel = `${assetSleutel(x)}|${x.origin.day}`;
if (!uniek.has(sleutel)) uniek.set(sleutel, x);
}
const met = [...uniek.values()];
if (!met.length && !zonder.size) return { regels: [], onvolledig: false };
if (zonder.size) {
const eigen = met.length === 1
? originLines(met[0].origin, met[0].asset).map((r, i) => (i === 0 ? `${assetLabel(met[0].asset)}: ${r}` : r))
: met.length ? samenvatting(met) : [];
const zonderSterkte = eigen.length ? eigen.slice(0, -1) : [];
const namen = [...zonder.values()].map(x => assetLabel(x.asset)).join(', ');
return { onvolledig: true, regels: [...zonderSterkte, t('priceOrigin.sumMissing', { assets: namen }),
t('priceOrigin.strength', { sterkte: t('priceOrigin.strength_missing') })] };
}
return { onvolledig: false, regels: met.length === 1 ? originLines(met[0].origin, met[0].asset) : samenvatting(met) };
}
function samenvatting(met) {
const telling = new Map();
for (const { origin } of met) telling.set(origin.kind, (telling.get(origin.kind) || 0) + 1);
const soorten = ['close', 'manual', 'live', 'spot', 'carried'].filter(k => telling.has(k))
.map(k => { const n = telling.get(k); return `${n} ${t(`priceHistory.kindCount_${k}${n === 1 ? 'One' : 'Many'}`)}`; });
const zwakste = met.reduce((a, b) => (rang(b.origin) < rang(a.origin)
|| (rang(b.origin) === rang(a.origin) && (b.origin.ageDays || 0) > (a.origin.ageDays || 0)) ? b : a));
const regels = [t('priceOrigin.sumHead', { n: met.length }), soorten.join(', ')];
if (STERKTE[zwakste.origin.kind] !== 'high') {
const zin = originLines(zwakste.origin, zwakste.asset)[1] || '';
regels.push(t('priceOrigin.sumWeakest', { asset: assetLabel(zwakste.asset), zin: zin.charAt(0).toLowerCase() + zin.slice(1) }));
}
regels.push(t('priceOrigin.strength', { sterkte: t(`priceOrigin.strength_${STERKTE[zwakste.origin.kind]}`) }));
return regels;
}
export function originsHtml(items, voor = []) {
const { regels, onvolledig } = samenvattingVan(items);
return knopHtml([...voor, ...regels], { warn: onvolledig });
}
export function warningHtml(regels) {
return knopHtml(regels, { warn: true });
}
const BOEKING_STERKTE = { paid: 'high', market_moment: 'high', market_day: 'lower' };
export function bookingOriginLines(b, asset, dayOrigin = null) {
if (!b || b.priceOrigin === 'none' || b.fiatPrice == null || isBase(asset)) return [];
const prijs = fmtBasePrice(priceFromBase(b.fiatPrice, asset));
const eenheid = asset ? priceUnitLabel(asset) : null;
const dag = dagTekst(String(b.occurredAt).slice(0, 10));
const regels = [eenheid ? t('priceOrigin.bookingPricePer', { prijs, eenheid }) : t('priceOrigin.bookingPrice', { prijs })];
if (b.priceOrigin === 'paid') regels.push(t('priceOrigin.bookingPaid'));
else if (b.priceOrigin === 'market_moment') regels.push(t('priceOrigin.bookingMoment'));
else if (b.priceOrigin === 'market_day') {
const zin = dayOrigin ? originLines(dayOrigin, asset)[1] : null;
regels.push(zin ? t('priceOrigin.bookingDayFrom', { dag, zin: zin.charAt(0).toLowerCase() + zin.slice(1) })
: t('priceOrigin.bookingDay', { dag }));
regels.push(t('priceOrigin.bookingDayNote'));
} else {
regels.push(t('priceOrigin.bookingUnknown'));
}
const sterkte = b.priceOrigin === 'market_day' && dayOrigin && dayOrigin.kind === 'carried'
? 'low' : (BOEKING_STERKTE[b.priceOrigin] || 'unknown');
regels.push(t('priceOrigin.strength', { sterkte: t(`priceOrigin.strength_${sterkte}`) }));
return regels;
}
export function bookingOriginHtml(b, asset, dayOrigin = null) {
return knopHtml(bookingOriginLines(b, asset, dayOrigin));
}
export function infoLinesHtml(regels) {
return knopHtml(regels);
}
function knopHtml(regels, { warn = false } = {}) {
if (!regels.length) return '';
return `<button type="button" class="po-btn${warn ? ' is-warn' : ''}" title="" data-origin-lines="${esc(JSON.stringify(regels))}"`
+ ` aria-label="${esc(regels.join('. '))}">${INFO}</button>`;
}
let openFor = null;
function toon(btn) {
if (openFor === btn) return;
let regels = [];
try { regels = JSON.parse(btn.dataset.originLines || '[]'); } catch { regels = []; }
if (!regels.length) return;
const pop = openPopover(btn, { className: 'po-pop', onClose: () => { openFor = null; } });
pop.el.setAttribute('role', 'tooltip');
pop.el.innerHTML = regels.map((r, i) => `<div class="${i === 0 ? 'po-head' : 'po-line'}">${esc(r)}</div>`).join('');
pop.position();
openFor = btn;
}
function verberg(btn) { if (openFor === btn) closePopover(); }
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
const btnVan = e => (e.target && e.target.closest ? e.target.closest('.po-btn') : null);
document.addEventListener('mouseover', e => { const b = btnVan(e); if (b) toon(b); });
document.addEventListener('mouseout', e => {
const b = btnVan(e);
if (b && !(e.relatedTarget && b.contains(e.relatedTarget))) verberg(b);
});
document.addEventListener('focusin', e => { const b = btnVan(e); if (b) toon(b); });
document.addEventListener('focusout', e => { const b = btnVan(e); if (b) verberg(b); });
document.addEventListener('click', e => {
const b = btnVan(e);
if (!b) return;
e.stopPropagation();
toon(b);
}, true);
}
+24
View File
@@ -0,0 +1,24 @@
// Generated, do not edit. sha256:0357e7318367558ea1c4f3c06bae817f3ef795f556618cac3bc184f31a478f4c
import qrcode from '../vendor/qrcode/qrcode.mjs';
import { esc } from './escape.js';
const ALPHANUMERIC = /^[0-9A-Z $%*+\-./:]*$/;
export const invoiceQrText = (paymentRequest) => `LIGHTNING:${String(paymentRequest).toUpperCase()}`;
export function qrMatrix(text) {
const qr = qrcode(0, 'M');
qr.addData(text, ALPHANUMERIC.test(text) ? 'Alphanumeric' : 'Byte');
qr.make();
const n = qr.getModuleCount();
return Array.from({ length: n }, (_, r) => Array.from({ length: n }, (_, c) => qr.isDark(r, c)));
}
export function qrSvg(text, { quiet = 4, label = '', px = 240 } = {}) {
const m = qrMatrix(text);
const size = m.length + quiet * 2;
let d = '';
m.forEach((row, r) => row.forEach((dark, c) => { if (dark) d += `M${c + quiet} ${r + quiet}h1v1h-1z`; }));
return `<svg class="qr" width="${px}" height="${px}" viewBox="0 0 ${size} ${size}" role="img"${label ? ` aria-label="${esc(label)}"` : ''} shape-rendering="crispEdges">`
+ `<rect width="${size}" height="${size}" fill="#fff"/><path d="${d}" fill="#000"/></svg>`;
}
+81
View File
@@ -0,0 +1,81 @@
// Generated, do not edit. sha256:f30cb8310951acef1b77d15dedcb9471def9f3c4d10c55de27cbd826be0db541
import { t, localeTag } from '../i18n/index.js';
import { withSpot } from './sparkline.js';
const MS_PER_DAY = 86400000;
export const RANGE_DAYS = Object.freeze({ week: 7, month: 30, year: 365 });
export const SPOT_RANGE_DAYS = Object.freeze({ day: 1, ...RANGE_DAYS });
export function intradayStart(grid, nowMs = Date.now()) {
const today = Math.floor(nowMs / MS_PER_DAY) * MS_PER_DAY;
const closes = Array.isArray(grid) ? grid.filter(p => p[0] < today) : [];
return closes.length ? closes[closes.length - 1][0] + MS_PER_DAY : today;
}
export function rangeSeries(grid, range, spot, nowMs = Date.now(), intraday = null) {
if (range === 'day') {
const two = withSpot(grid, spot, nowMs).slice(-2);
if (two.length < 2 || !Number.isFinite(spot) || !Array.isArray(intraday) || !intraday.length) return two;
const start = intradayStart(grid, nowMs);
const inner = intraday.filter(([t, p]) => t > start && t < nowMs && Number.isFinite(p));
return inner.length ? [[start, two[0][1]], ...inner, [nowMs, spot]] : two;
}
const cutoff = nowMs - (SPOT_RANGE_DAYS[range] ?? RANGE_DAYS.week) * MS_PER_DAY;
const slice = grid.filter(p => p[0] >= cutoff);
return withSpot(slice.length >= 2 ? slice : grid, spot, nowMs);
}
export function rangePointLabel(range, priceText, ms, source = null) {
const moment = !Number.isFinite(ms) ? null : range === 'day'
? new Date(ms).toLocaleString(localeTag(), { ...DATUM, hour: '2-digit', minute: '2-digit' })
: utcDay(ms);
return [priceText, moment, source].filter(Boolean).join('\n');
}
const DATUM = { day: 'numeric', month: 'short', year: 'numeric' };
const utcDay = ms => new Date(ms).toLocaleDateString(localeTag(), { ...DATUM, timeZone: 'UTC' });
export function namedDaySources(byDay, providers) {
const names = new Map((providers || []).map(p => [p.id, p.name]));
const out = new Map();
for (const [ms, { providerId, sourceName }] of byDay || []) {
const name = names.get(providerId);
if (name) out.set(ms, sourceName ? t('range.pointVia', { name, source: sourceName }) : name);
}
return out;
}
export function rangePointSources(points, { range, spot = null, nowMs = Date.now(), live = null, intraday = null, intradayAt = null, days = new Map() } = {}) {
const out = new Map();
if (!Array.isArray(points) || !points.length) return out;
const today = Math.floor(nowMs / MS_PER_DAY) * MS_PER_DAY;
const own = [...days.keys()].sort((a, b) => a - b);
const last = points.length - 1;
points.forEach(([ms], i) => {
let label = null;
if (i === last && Number.isFinite(spot) && (ms === nowMs || ms === today)) label = live;
else if (range === 'day' && i > 0 && i < last) label = intradayAt?.get(ms) ?? intraday;
else if (days.has(ms)) label = days.get(ms);
else {
const from = own.filter(d => d < ms).pop();
if (from != null && days.get(from)) label = t('range.pointCarried', { bron: days.get(from), dag: utcDay(from) });
}
if (label) out.set(ms, label);
});
return out;
}
export const rangeLabel = r => t('range.' + r);
export const rangeCycleButton = range =>
`<button type="button" class="btn btn-ghost btn-xs range-cycle-btn" title="${t('range.cycle')}" aria-label="${t('range.cycle')}">${rangeLabel(range)}</button>`;
export function wireRangeCycle(wrap, getRange, onChange, ranges = RANGE_DAYS) {
wrap.onclick = e => {
if (!e.target.closest('.range-cycle-btn')) return;
const order = Object.keys(ranges);
onChange(order[(order.indexOf(getRange()) + 1) % order.length]);
};
}
+8
View File
@@ -0,0 +1,8 @@
// Generated, do not edit. sha256:e6ada8471a98e1a9a9c718825aa1e371ce7d568ba6108b0249aab352b6a55e48
export function createRenderTurns() {
let newest = 0;
return {
take: () => ++newest,
isCurrent: (turn) => turn === newest,
};
}
+25
View File
@@ -0,0 +1,25 @@
// Generated, do not edit. sha256:8c2d29dc1ab4de132603fd0dc064664fee50070f3e3eb2720a5819c260fc1066
import { CONFIG } from '../../shared/config.js';
export function setRowFocus(table, id) {
if (!table || id == null) return;
try {
sessionStorage.setItem(CONFIG.FOCUS.ROW, JSON.stringify({ table, id, at: Date.now() }));
} catch { }
}
export function takeRowFocus(table) {
let raw;
try { raw = sessionStorage.getItem(CONFIG.FOCUS.ROW); } catch { return null; }
if (!raw) return null;
let data;
try { data = JSON.parse(raw); } catch { remove(); return null; }
if (!data || data.table !== table) return null;
remove();
if (!Number.isFinite(data.at) || Date.now() - data.at > CONFIG.FOCUS.ROW_TTL_MS) return null;
return data.id ?? null;
}
function remove() {
try { sessionStorage.removeItem(CONFIG.FOCUS.ROW); } catch { }
}
+58
View File
@@ -0,0 +1,58 @@
// Generated, do not edit. sha256:e2e2ad5112c13991dd020e8231ee0569ddfef89365ad659158d7e3d71cc43739
import { timerSetting } from '../app-settings.js';
import { CONFIG } from '../../shared/config.js';
import { pollChanges } from '../changes.js';
const TASKS = [
{
key: 'spot_refresh',
prefField: 'spot_refresh_seconds',
run: () => document.dispatchEvent(new CustomEvent('tick:spot')),
},
{
key: 'bus_poll',
prefField: 'bus_poll_seconds',
run: () => pollChanges(),
},
];
const handles = new Map();
function intervalSecondsFor(task) {
const s = timerSetting(task.prefField);
return Number.isInteger(s) ? s : CONFIG.TIMERS.OFF_SECONDS;
}
function stop(task) {
const h = handles.get(task.key);
if (h !== undefined) { clearTimeout(h); handles.delete(task.key); }
}
function start(task) {
stop(task);
if (intervalSecondsFor(task) >= CONFIG.TIMERS.OFF_SECONDS) return;
const tick = async () => {
if (document.visibilityState !== 'hidden') {
try { await task.run(); } catch (e) { console.error('[scheduler]', task.key, e); }
}
const next = intervalSecondsFor(task);
if (next >= CONFIG.TIMERS.OFF_SECONDS) { handles.delete(task.key); return; }
handles.set(task.key, setTimeout(tick, next * 1000));
};
const seconds = intervalSecondsFor(task);
handles.set(task.key, setTimeout(tick, seconds * 1000));
}
function startAll() { for (const task of TASKS) start(task); }
export function initScheduler() {
startAll();
document.addEventListener('settings-changed', startAll);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') return;
for (const task of TASKS) {
if (!handles.has(task.key)) continue;
try { task.run(); } catch (e) { console.error('[scheduler]', task.key, e); }
}
});
}
+4
View File
@@ -0,0 +1,4 @@
// Generated, do not edit. sha256:0076d8366cfc258ec98999c73e935ac15cfca0066278d4c19d6490ed44c8437b
import { CHEVRON } from './icons.js';
export const selectLabel = selectHtml => `<span class="select-label">${selectHtml}${CHEVRON}</span>`;
+61
View File
@@ -0,0 +1,61 @@
// Generated, do not edit. sha256:b9399a3f08d3b6cb92193bf072e6f03992ddd419185b08d9e50b633d21567d88
import { configureDialog } from './dialog.js';
import { configureTabs } from './tabs.js';
import { configureFilter } from './filter.js';
import { configureLoading } from './loading.js';
import { configureTable } from './table.js';
import { openUnitConvert } from './unit-convert.js';
import { hasSubunit } from './format.js';
import { SWAP } from './icons.js';
import { appSetting } from '../app-settings.js';
import { renderErrorPanel } from './error-panel.js';
import { isBlurred, unblur } from './blur.js';
import { CLOSE, EYE_OFF } from './icons.js';
import { t } from '../i18n/index.js';
export function mountBlurEye(d, done) {
const title = d.querySelector('.dialog-title');
if (!title) return;
const sync = e => {
const on = e?.detail ? e.detail.on : isBlurred();
const eye = title.querySelector('.dialog-eye');
if (!on) { eye?.remove(); return; }
if (eye) return;
const b = document.createElement('button');
b.type = 'button';
b.className = 'dialog-eye';
b.title = t('blur.showAmounts');
b.setAttribute('aria-label', t('blur.showAmounts'));
b.innerHTML = EYE_OFF;
b.addEventListener('click', () => unblur());
title.appendChild(b);
};
sync();
document.addEventListener('blur-changed', sync);
done.finally(() => document.removeEventListener('blur-changed', sync));
}
export function configureSharedUi() {
configureDialog({
labels: {
ok: t('dialog.ok'), cancel: t('dialog.cancel'), close: t('dialog.close'), next: t('dialog.next'),
back: t('dialog.back'), finish: t('dialog.finish'), skip: t('dialog.skip'),
},
closeIcon: CLOSE,
onDetail: mountBlurEye,
});
configureTabs({ onError: renderErrorPanel });
configureFilter({ labels: { all: t('filter.all'), none: t('filter.none') } });
configureLoading({ labels: { loading: t('common.loading'), loadError: t('common.loadError') } });
configureTable({
labels: {
new: t('table.new'), save: t('table.save'), cancel: t('table.cancel'), none: t('table.none'),
search: t('table.search'), count: t('table.count'), edit: t('table.edit'),
},
convert: { canConvert: hasSubunit, open: openUnitConvert, icon: SWAP },
repeatKey: () => appSetting('repeat_entry_key'),
});
}
configureSharedUi();
+13
View File
@@ -0,0 +1,13 @@
// Generated, do not edit. sha256:c4f15e69aa0e49c8fbb2c22a84f818ac3c22099c20d6b44edd9364730bb3cf39
import { t } from '../i18n/index.js';
const SCRIPT_BADGE = { p2tr: 'Taproot', p2wpkh: 'SegWit', 'p2sh-p2wpkh': 'Nested', p2pkh: 'Legacy' };
export function sourceBadgeText(s) {
switch (s?.kind) {
case 'xpub': return { label: SCRIPT_BADGE[s.scriptType] || 'xpub', title: null };
case 'nwc': return { label: t('tracking.badgeNwc'), title: t('tracking.badgeNwcHint') };
case 'lnd': return { label: t('tracking.badgeLnd'), title: t('tracking.badgeLndHint') };
default: return { label: t('accounts.address'), title: null };
}
}
+147
View File
@@ -0,0 +1,147 @@
// Generated, do not edit. sha256:e64fde93e62c826f1ddca6217aab270e6309de4394ce5cb0e258a0e0fdb72d5c
import { t } from '../i18n/index.js';
import { listPriceSource, setPriceSourceRank, clearPriceSourceCell, confirmPriceSourceCell } from '../data/price-source.js';
import { listProviders, getProvider } from '../data/providers.js';
import { rateSourceFor } from '../price/providers.js';
import { serviceCoverage } from '../data/market.js';
import { SOURCE_CAPABILITIES, SOURCE_ENDPOINTS } from '../../shared/config.js';
import { baseCurrency, appSetting } from '../app-settings.js';
import { assetLabelHtml } from './asset-label.js';
import { esc } from './escape.js';
import * as dialog from './dialog.js';
const endpointsUnder = (p, cap) => (p.endpoints || []).filter(e => SOURCE_ENDPOINTS[cap].includes(e.capability));
const answers = (p, cap) => endpointsUnder(p, cap).length > 0;
function quoteOf(p, cap) {
const e = endpointsUnder(p, cap).find(x => x.quoteCurrency && x.quoteCurrency !== baseCurrency());
return e ? e.quoteCurrency : null;
}
const currenciesOf = p => [...new Set(SOURCE_CAPABILITIES.map(c => quoteOf(p, c)).filter(Boolean))];
export async function loadMatrixContext() {
const alle = (await Promise.all((await listProviders()).map(p => getProvider(p.key)))).filter(Boolean);
const providers = alle.filter(p => !p.isSystem);
const cell = new Map();
for (const r of await listPriceSource()) {
if (r.rank == null) continue;
cell.set(`${r.assetId}|${r.capability}|${r.providerId}`,
{ rank: r.rank, proposed: r.status === 'proposed', own: r.status === 'manual' });
}
const rateSource = new Map();
for (const p of providers) {
for (const c of currenciesOf(p)) if (!rateSource.has(c)) rateSource.set(c, await rateSourceFor(c));
}
return { alle, providers, cell, rateSource, service: await serviceContext() };
}
async function serviceContext() {
let mode = 'off';
try { mode = appSetting('market_spot'); } catch { }
if (mode === 'off') return { mode, covered: new Set() };
try {
const r = await serviceCoverage();
return { mode, covered: new Set(r.status === 'ok' ? r.assetIds : []) };
} catch { return { mode, covered: new Set() }; }
}
export function serviceOrder(chosen, mode) {
const byRank = [...chosen].sort((x, y) => x.rank - y.rank);
const order = mode === 'first'
? [...byRank.filter(c => c.own), 'service', ...byRank.filter(c => !c.own)]
: [...byRank, 'service'];
const ranks = new Map();
let service = null;
order.forEach((c, i) => { if (c === 'service') service = i + 1; else ranks.set(c.providerId, i + 1); });
return { ranks, service };
}
const marker = (ctx, v) => `<span class="mx-cur${ctx.rateSource.get(v) ? '' : ' t-neg'}">${esc(v)}</span>`;
const offTitle = p => (p.isActive ? '' : ` title="${esc(t('integrations.inactiveNoEffect'))}"`);
function kopVan(a, stand, title = true) {
if (!title && !stand) return '';
return `<div class="mx-card-head">
${title ? `<div class="wealth-head"><strong>${assetLabelHtml(a)}</strong><span class="badge">${esc(t('enums.asset_kind.' + a.kind))}</span></div>` : ''}
${stand ? `<div class="mx-stand t-caption">${esc(stand)}</div>` : ''}
</div>`;
}
export function matrixCardHtml(a, ctx, { title = true } = {}) {
const cols = ctx.providers.filter(p => (p.coverage || []).includes(a.symbol));
const svc = ctx.service && ctx.service.mode !== 'off' && ctx.service.covered.has(a.id);
if (!cols.length && !svc) {
const systeem = ctx.alle.find(p => p.isSystem && (p.coverage || []).includes(a.symbol));
const tekst = systeem ? esc(t('integrations.systemSource').replace('{naam}', systeem.name)) : esc(t('integrations.noCoverage'));
return `<div class="card mx-card">${kopVan(a, '', title)}<div class="mx-empty">${tekst}</div></div>`;
}
const svcTitle = svc ? esc(t('integrations.mxServiceHint_' + ctx.service.mode)) : '';
const naamKop = `<tr><th class="mx-lab"></th>${cols.map(p =>
`<th class="mx-col${p.isActive ? '' : ' is-off'}" title="${esc(p.isActive ? p.name : `${p.name}: ${t('integrations.inactiveNoEffect')}`)}"><span class="mx-name">${esc(p.name)}</span></th>`).join('')}${
svc ? `<th class="mx-col" title="${svcTitle}"><span class="mx-name">Atreides Services</span></th>` : ''}</tr>`;
const muntKop = `<tr class="mx-cur-row"><th class="mx-lab"></th>${cols.map(p =>
`<th>${currenciesOf(p).map(v => marker(ctx, v)).join(' ')}</th>`).join('')}${svc ? '<th></th>' : ''}</tr>`;
const head = naamKop + muntKop;
const rank = (c, p, shown = c.rank) =>
`<span class="mx-rank mx-rank-${Math.min(shown, 2)}${c.proposed ? ' is-proposed' : ''}${p.isActive ? '' : ' is-off'}"${offTitle(p)}>${shown}</span>`;
let open = 0, voorstellen = 0;
const leeg = [];
const rows = SOURCE_CAPABILITIES.filter(cap => cols.some(p => answers(p, cap)) || (svc && cap === 'spot'));
const body = rows.map(cap => {
const metDienst = svc && cap === 'spot';
const volgorde = metDienst
? serviceOrder(cols.map(p => ({ providerId: p.id, ...ctx.cell.get(`${a.id}|${cap}|${p.id}`) }))
.filter(c => c.rank != null), ctx.service.mode)
: null;
if (!metDienst && !cols.some(p => ctx.cell.has(`${a.id}|${cap}|${p.id}`))) leeg.push(t('integrations.sourceCap.' + cap));
const dienstCel = svc
? (metDienst
? `<td class="mx-svc" title="${svcTitle}"><span class="mx-rank mx-rank-${Math.min(volgorde.service, 2)} is-service">${volgorde.service}</span></td>`
: `<td class="mx-na"></td>`)
: '';
const cells = cols.map(p => {
if (!answers(p, cap)) return `<td class="mx-na"></td>`;
const c = ctx.cell.get(`${a.id}|${cap}|${p.id}`);
const valuta = quoteOf(p, cap);
if (valuta && !ctx.rateSource.get(valuta) && !c) {
return `<td class="mx-na" title="${esc(t('integrations.noRateSourceHint', { valuta }))}"></td>`;
}
if (!c) open++;
else if (c.proposed) voorstellen++;
const inner = c ? rank(c, p, volgorde ? volgorde.ranks.get(p.id) : c.rank) : `<span class="mx-pick">+</span>`;
return `<td class="mx-cell" data-a="${a.id}" data-cap="${cap}" data-p="${p.id}" data-has="${c ? 1 : 0}" data-proposed="${c && c.proposed ? 1 : 0}">${inner}</td>`;
}).join('');
return `<tr><th class="mx-lab">${esc(t('integrations.sourceCap.' + cap))}</th>${cells}${dienstCel}</tr>`;
}).join('');
const stand = [
leeg.length ? t('integrations.mxToSetUp', { wat: leeg.join(' en ') }) : '',
voorstellen ? t(voorstellen === 1 ? 'integrations.mxProposalsOne' : 'integrations.mxProposalsMany', { n: voorstellen }) : '',
open ? t(open === 1 ? 'integrations.mxOpenOne' : 'integrations.mxOpenMany', { n: open }) : t('integrations.mxAllChosen'),
].filter(Boolean).join(' · ');
return `<div class="card mx-card">${kopVan(a, stand.charAt(0).toUpperCase() + stand.slice(1), title)}<div class="mx-wrap"><table class="mx-table"><thead>${head}</thead><tbody>${body}</tbody></table></div></div>`;
}
export function wireMatrix(host, redraw) {
host.querySelectorAll('.mx-cell').forEach(td => td.onclick = async () => {
const d = td.dataset;
const a = Number(d.a), p = Number(d.p);
try {
if (d.has === '1' && d.proposed === '1') await confirmPriceSourceCell(a, d.cap, p);
else if (d.has === '1') await clearPriceSourceCell(a, d.cap, p);
else await setPriceSourceRank(a, d.cap, p);
document.dispatchEvent(new CustomEvent('providers-changed'));
} catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
await redraw();
});
}
export function matrixLegendRows(ctx) {
const uit = ctx.providers.some(p => !p.isActive)
? [{ sample: '<span class="mx-rank mx-rank-1 is-off" style="--mx-rank:1.1rem">1</span>', text: esc(t('integrations.inactiveHint')) }]
: [];
const munten = [...ctx.rateSource].map(([v, via]) => ({
sample: marker(ctx, v),
text: esc(via ? t('integrations.convertedVia', { naam: via.name, valuta: v, munt: baseCurrency() }) : t('integrations.noRateSource', { valuta: v })),
}));
return [...uit, ...munten];
}
+214
View File
@@ -0,0 +1,214 @@
// Generated, do not edit. sha256:eb749d1dca35ea9b39abff8cba70a365f5bed2b3d725946f3e7eb2d00e35dd60
import { esc } from './escape.js';
import { fmtPct } from './format.js';
let uid = 0;
const VIEW_W = 100, VIEW_H = 100, PAD_X = 0, PAD_Y = 3;
const DAY_MS = 86400000;
const GRAD_FADE_AT = 0.85;
const SNAP_PX = 10;
export function labelLeft(x, labelW, chartW, gap = 4) {
const half = labelW / 2 + gap;
if (half * 2 >= chartW) return chartW / 2;
return Math.min(chartW - half, Math.max(half, x));
}
export function magnetischeIndex(punten, markeringen, fx, tolerantie) {
let bestI = 0, bestD = Infinity;
punten.forEach((p, i) => { const d = Math.abs(p.fx - fx); if (d < bestD) { bestD = d; bestI = i; } });
let markI = -1, markD = Infinity;
for (const i of markeringen) {
if (!punten[i]) continue;
const d = Math.abs(punten[i].fx - fx);
if (d < markD) { markD = d; markI = i; }
}
return (markI >= 0 && markD <= tolerantie) ? markI : bestI;
}
export function trapPunten(punten) {
const uit = [];
for (let i = 0; i < punten.length; i++) {
if (i > 0 && Math.abs(punten[i - 1].py - punten[i].py) >= 0.01) {
uit.push({ px: punten[i].px, py: punten[i - 1].py });
}
uit.push(punten[i]);
}
return uit;
}
export function smoothPath(punten) {
const p = punten;
const n = p.length;
if (!n) return '';
const f = v => v.toFixed(2);
if (n < 3) return `M${f(p[0].px)},${f(p[0].py)}` + (n === 2 ? ` L${f(p[1].px)},${f(p[1].py)}` : '');
const h = [], s = [];
for (let i = 0; i < n - 1; i++) {
h.push(p[i + 1].px - p[i].px);
s.push(h[i] ? (p[i + 1].py - p[i].py) / h[i] : 0);
}
const m = [s[0]];
for (let i = 1; i < n - 1; i++) {
if (s[i - 1] * s[i] <= 0) { m.push(0); continue; }
const w1 = 2 * h[i] + h[i - 1], w2 = h[i] + 2 * h[i - 1];
m.push((w1 + w2) / (w1 / s[i - 1] + w2 / s[i]));
}
m.push(s[n - 2]);
let d = `M${f(p[0].px)},${f(p[0].py)}`;
for (let i = 0; i < n - 1; i++) {
const t = h[i] / 3;
d += ` C${f(p[i].px + t)},${f(p[i].py + m[i] * t)} ${f(p[i + 1].px - t)},${f(p[i + 1].py - m[i + 1] * t)} ${f(p[i + 1].px)},${f(p[i + 1].py)}`;
}
return d;
}
export function mountSparkline(host, points, { tone = 'accent', formatValue = String, markers = [], vergelijk = null,
snap = [], onPick = null, yRange = null, smooth = true } = {}) {
host.innerHTML = '';
if (!points || points.length < 2) return;
const tweede = Array.isArray(vergelijk) && vergelijk.length === points.length ? vergelijk : null;
const ys = points.map(p => p[1]).concat(tweede ? tweede.map(p => p[1]) : []);
const minX = points[0][0], maxX = points[points.length - 1][0];
const minY = yRange ? yRange[0] : Math.min(...ys), maxY = yRange ? yRange[1] : Math.max(...ys);
const spanX = (maxX - minX) || 1, spanY = (maxY - minY) || 1;
const pts = points.map(([x, y]) => {
const px = PAD_X + ((x - minX) / spanX) * (VIEW_W - 2 * PAD_X);
const py = VIEW_H - PAD_Y - ((y - minY) / spanY) * (VIEW_H - 2 * PAD_Y);
return { fx: px / VIEW_W, fy: py / VIEW_H, px, py, price: y, x };
});
const cmpPts2 = tweede ? tweede.map(([x, y]) => {
const px = PAD_X + ((x - minX) / spanX) * (VIEW_W - 2 * PAD_X);
const py = VIEW_H - PAD_Y - ((y - minY) / spanY) * (VIEW_H - 2 * PAD_Y);
return { fx: px / VIEW_W, fy: py / VIEW_H, px, py };
}) : null;
const markPts = cmpPts2 || pts;
const markIdx = [];
const gezien = new Set();
let markHtml = '';
for (const m of markers) {
const n = dichtstbij(pts, m?.x);
const variant = String(m?.variant || '').replace(/[^a-z0-9-]/gi, '');
const label = m?.label == null ? '' : String(m.label);
const key = m?.key == null ? '' : String(m.key).replace(/[^a-z0-9_:-]/gi, '');
const sleutel = `${variant}|${n}`;
if (n < 0 || gezien.has(sleutel)) continue;
gezien.add(sleutel);
markIdx.push(n);
markHtml += `<div class="spark-mini-mark${variant ? ` is-${variant}` : ''}"${key ? ` data-mark="${key}"` : ''}`
+ (label ? ` title="${esc(label)}"` : '')
+ ` style="--spark-x:${(markPts[n].fx * 100).toFixed(2)}%;top:${(markPts[n].fy * 100).toFixed(2)}%"></div>`;
}
for (const x of snap) { const n = dichtstbij(pts, x); if (n >= 0) markIdx.push(n); }
const linePts = pts.map(p => `${p.px.toFixed(2)},${p.py.toFixed(2)}`).join(' ');
const cmpPts = cmpPts2
? trapPunten(cmpPts2).map(p => `${p.px.toFixed(2)},${p.py.toFixed(2)}`).join(' ')
: '';
const areaPts = `${pts[0].px.toFixed(2)},${VIEW_H} ${linePts} ${pts[pts.length - 1].px.toFixed(2)},${VIEW_H}`;
const gid = `spk-grad-${++uid}`;
const curve = smooth ? smoothPath(pts) : '';
const lineAttrs = `fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"`;
const areaSvg = smooth
? `<path class="spark-mini-area" d="M${pts[0].px.toFixed(2)},${VIEW_H} L${curve.slice(1)} L${pts[pts.length - 1].px.toFixed(2)},${VIEW_H} Z" fill="url(#${gid})" stroke="none"/>`
: `<polygon class="spark-mini-area" points="${areaPts}" fill="url(#${gid})" stroke="none"/>`;
const lineSvg = smooth
? `<path class="spark-mini-line" d="${curve}" ${lineAttrs}/>`
: `<polyline class="spark-mini-line" points="${linePts}" ${lineAttrs}/>`;
const root = document.createElement('div');
root.className = `spark-mini is-${tone}${onPick ? ' is-pickable' : ''}`;
root.innerHTML = `
<svg class="spark-mini-svg" viewBox="0 0 ${VIEW_W} ${VIEW_H}" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1">
<stop class="spark-grad-top" offset="0"/>
<stop class="spark-grad-bot" offset="${GRAD_FADE_AT}"/>
</linearGradient>
</defs>
${areaSvg}
${cmpPts ? `<polyline class="spark-mini-cmp" points="${cmpPts}" fill="none" stroke="currentColor" stroke-width="1"
stroke-dasharray="3 3" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>` : ''}
${lineSvg}
</svg>
<div class="spark-mini-clip">
${markHtml}
<div class="spark-mini-vline" hidden></div>
<div class="spark-mini-dot" hidden></div>
</div>
<div class="spark-mini-label" hidden></div>`;
host.appendChild(root);
const vline = root.querySelector('.spark-mini-vline');
const dot = root.querySelector('.spark-mini-dot');
const label = root.querySelector('.spark-mini-label');
function indexAt(e) {
const rect = root.getBoundingClientRect();
if (!rect.width) return -1;
const fx = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
return magnetischeIndex(pts, markIdx, fx, SNAP_PX / rect.width);
}
function show(e) {
const bestI = indexAt(e);
if (bestI < 0) return;
const best = pts[bestI];
const leftPct = best.fx * 100, topPct = best.fy * 100;
vline.style.left = `${leftPct}%`;
dot.style.setProperty('--spark-x', `${leftPct}%`);
dot.style.top = `${topPct}%`;
label.textContent = formatValue(best.price, best.x, tweede ? tweede[bestI]?.[1] : undefined);
label.classList.toggle('is-below', best.fy < 0.5);
vline.hidden = dot.hidden = label.hidden = false;
const rw = root.clientWidth;
label.style.left = rw
? `${labelLeft(best.fx * rw, label.offsetWidth, rw)}px`
: `${Math.min(88, Math.max(12, leftPct))}%`;
label.style.top = `${topPct}%`;
}
function hide() { vline.hidden = dot.hidden = label.hidden = true; }
root.addEventListener('pointermove', show);
root.addEventListener('pointerdown', show);
root.addEventListener('pointerleave', hide);
root.addEventListener('pointercancel', hide);
if (onPick) root.addEventListener('click', e => { const i = indexAt(e); if (i >= 0) onPick(pts[i].x, i); });
}
function dichtstbij(pts, x) {
let best = -1, bestD = Infinity;
for (let i = 0; i < pts.length; i++) {
const d = Math.abs(pts[i].x - x);
if (d < bestD) { bestD = d; best = i; }
}
return best;
}
export function withSpot(points, price, nowMs = Date.now()) {
if (!Array.isArray(points) || !points.length) return points;
if (!Number.isFinite(price) || price <= 0) return points;
const vandaag = Math.floor(nowMs / DAY_MS) * DAY_MS;
const laatste = points[points.length - 1][0];
if (laatste > vandaag) return points;
if (laatste === vandaag) return [...points.slice(0, -1), [vandaag, price]];
return [...points, [vandaag, price]];
}
export const sliceTone = slice => (slice[slice.length - 1][1] >= slice[0][1] ? 'up' : 'down');
export function changePctHtml(slice, suffixHtml = '', { fine = false, signs = 'negative' } = {}) {
if (!slice || slice.length < 2) return '';
const first = slice[0][1], last = slice[slice.length - 1][1];
if (!(first > 0)) return '';
const change = (last - first) / first;
return `<span class="spark-change delta ${change >= 0 ? 't-pos' : 't-neg'}">${fmtPct(change, { fine, signs })}${suffixHtml}</span>`;
}
+97
View File
@@ -0,0 +1,97 @@
// Generated, do not edit. sha256:a433ae61cf1d51dd5a396f13922a2aaa329bfea96e00b35d9456a671a84c5e04
import { CONFIG } from '../../shared/config.js';
import { t } from '../i18n/index.js';
const bootT0 = performance.now();
(function splashGateFallback() {
const html = document.documentElement;
if (html.dataset.splash === 'on') return;
try {
const KEY = 'splash_last_shown';
const d = new Date();
const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const force = location.search.includes('splash') || location.hash.includes('splash');
if (force || localStorage.getItem(KEY) !== today) {
console.warn('[splash] boot-gate.js draaide niet (niet geladen of oude index.html uit de cache?); vangnet in ui/splash.js toont de splash.');
localStorage.setItem(KEY, today);
html.dataset.splash = 'on';
}
} catch { html.dataset.splash = 'on'; }
})();
let typeTimer = 0;
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function fadeOut(splash) {
splash.style.transitionDuration = `${CONFIG.SPLASH.FADE_MS}ms`;
splash.classList.add('is-done');
setTimeout(() => reset(splash), CONFIG.SPLASH.FADE_MS + 50);
}
function reset(splash) {
clearTimeout(typeTimer);
delete document.documentElement.dataset.splash;
splash.classList.remove('is-done', 'is-manual', 'is-typed');
splash.style.transitionDuration = '';
const out = splash.querySelector('.splash-quote-text');
if (out) out.textContent = '';
}
export function finishSplash() {
const splash = document.getElementById('splash');
if (!splash || document.documentElement.dataset.splash !== 'on') return;
const wait = Math.max(0, CONFIG.SPLASH.MIN_MS - (performance.now() - bootT0));
setTimeout(() => fadeOut(splash), wait);
}
function showManualSplash() {
const splash = document.getElementById('splash');
if (!splash || document.documentElement.dataset.splash === 'on') return;
splash.classList.add('is-manual');
document.documentElement.dataset.splash = 'on';
const out = splash.querySelector('.splash-quote-text');
if (!out) console.warn('[splash] Citaat-element ontbreekt (oude index.html uit de cache?); één keer hard verversen.');
const text = t('splash.quote');
const done = () => splash.classList.add('is-typed');
if (!out) {
} else if (reducedMotion()) {
out.textContent = text;
done();
} else {
let i = 0;
const step = () => {
out.textContent = text.slice(0, ++i);
if (i >= text.length) return done();
const pause = CONFIG.SPLASH.QUOTE_CHAR_MS
+ (text[i - 1] === ' ' ? CONFIG.SPLASH.QUOTE_WORD_PAUSE_MS : 0);
typeTimer = setTimeout(step, pause);
};
typeTimer = setTimeout(step, CONFIG.SPLASH.QUOTE_START_MS);
}
const ctl = new AbortController();
const close = () => { ctl.abort(); fadeOut(splash); };
splash.addEventListener('click', close, { signal: ctl.signal });
window.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); }, { signal: ctl.signal });
}
export function splashMarkHtml() {
return `<div class="splash-mini" aria-hidden="true">
<div class="splash-band">
<div class="splash-wing splash-wing--l"><span></span><span></span><span></span></div>
<div class="splash-logo"></div>
<div class="splash-wing splash-wing--r"><span></span><span></span><span></span></div>
</div>
<div class="splash-title">${t('app.name')}</div>
</div>`;
}
export function initSplashReplay() {
for (const el of document.querySelectorAll('.sidebar-logo, .mobile-topbar-logo')) {
el.title = t('splash.logoHint');
el.addEventListener('click', showManualSplash);
}
}
+179
View File
@@ -0,0 +1,179 @@
// Generated, do not edit. sha256:ab0e4307745aa40cfdb48df36e50ccead564b09ed673bdaf465f9ded7d5651dd
import { listProviders, getProvider } from '../data/providers.js';
import { t } from '../i18n/index.js';
import { CONFIG, TARGETS, targetHash } from '../../shared/config.js';
import { appSetting } from '../app-settings.js';
import { initPrivacyBlur } from './blur.js';
import { generalAttention, attentionHasProblem, attentionDimmed } from './attention.js';
import { openPopover, closePopover } from './popover.js';
import { esc } from './escape.js';
const FADE_OK_MS = 2600;
const FADE_FAIL_MS = 6000;
const LABEL_MS = 2600;
const MAX_COLS = 6;
const MAX_ROWS = 3;
const MOBILE_MQ = '(max-width: 768px)';
let rootEl = null;
let dotsEl = null;
let labelEls = [];
let dotTimers = new Map();
let labelSlots = [];
let dotKeys = '';
let attnEl = null;
let labelActive = 0;
let attnMenuOpen = false;
async function callableProviders() {
const rijen = await listProviders();
const configs = await Promise.all(rijen.map(r => getProvider(r.key)));
return configs.filter(p => p && p.isActive && ((!p.isSystem && p.endpoints.length > 0) || isAskedService(p)));
}
function isAskedService(p) {
if (p.key !== CONFIG.MARKET_SERVICE.PROVIDER_KEY) return false;
try { return appSetting('market_spot') !== 'off'; } catch { return false; }
}
function rowsFor(n) {
if (n <= 1) return 1;
if (n <= MAX_COLS * 2) return 2;
return MAX_ROWS;
}
async function syncDots() {
const provs = await callableProviders();
const keys = provs.map(p => p.key).join('|');
if (keys === dotKeys) return;
dotKeys = keys;
const rows = rowsFor(provs.length);
const cols = Math.max(1, Math.ceil(provs.length / rows));
dotsEl.style.setProperty('--sb-rows', String(rows));
rootEl.style.setProperty('--sb-vak', `max(2.1rem, ${cols * 7 + (cols - 1) * 5}px)`);
dotsEl.innerHTML = provs.map(p =>
`<button type="button" class="statusbar-dot" tabindex="-1" data-provider-key="${esc(p.key)}" title="${esc(p.name)}" aria-label="${esc(p.name)}"></button>`
).join('');
dotTimers.clear();
}
function lightDot(key, ok) {
const dot = dotsEl.querySelector(`[data-provider-key="${CSS.escape(key)}"]`);
if (!dot) return;
dot.classList.remove('is-active', 'is-ok', 'is-fail');
void dot.offsetWidth;
dot.classList.add('is-active', ok ? 'is-ok' : 'is-fail');
const prev = dotTimers.get(key);
if (prev) clearTimeout(prev);
dotTimers.set(key, setTimeout(() => dot.classList.remove('is-active', 'is-ok', 'is-fail'),
ok ? FADE_OK_MS : FADE_FAIL_MS));
}
function showSymbol(sym) {
if (!sym) return;
let slot = labelSlots.find(s => s.ts === 0);
if (!slot) slot = labelSlots[0].ts <= labelSlots[1].ts ? labelSlots[0] : labelSlots[1];
slot.ts = Date.now();
slot.el.textContent = sym;
slot.el.classList.remove('is-flash');
void slot.el.offsetWidth;
slot.el.classList.add('is-flash');
labelActive++;
syncAttention();
if (slot.timer) clearTimeout(slot.timer);
slot.timer = setTimeout(() => {
slot.el.classList.remove('is-flash'); slot.el.textContent = ''; slot.ts = 0;
labelActive = Math.max(0, labelActive - 1);
syncAttention();
}, LABEL_MS);
}
function syncAttention() {
if (!attnEl) return;
const items = generalAttention();
const show = items.length > 0 && labelActive === 0;
const dim = attentionDimmed(items);
attnEl.textContent = items.length >= 2 ? String(items.length) : '!';
attnEl.classList.toggle('is-shown', show);
attnEl.classList.toggle('is-count', items.length >= 2);
attnEl.classList.toggle('is-dim', dim);
attnEl.classList.toggle('is-problem', attentionHasProblem(items));
attnEl.setAttribute('aria-hidden', show ? 'false' : 'true');
attnEl.tabIndex = show ? 0 : -1;
if (!show && attnMenuOpen) closePopover();
}
function openAttentionMenu() {
if (attnMenuOpen) return;
const items = generalAttention();
if (!items.length) return;
const { el, position } = openPopover(attnEl, {
className: 'popover attn-menu',
onClose: () => { attnMenuOpen = false; },
});
attnMenuOpen = true;
el.innerHTML =
`<div class="t-label t-label--ter attn-menu-title">${esc(t('attention.title'))}</div>` +
items.map(a => `<button type="button" class="attn-menu-item" data-hash="${esc(a.hash || '')}">${esc(a.label)}</button>`).join('');
position();
el.addEventListener('click', (e) => {
const b = e.target.closest('[data-hash]');
if (!b) return;
closePopover();
if (b.dataset.hash) location.hash = b.dataset.hash;
});
}
function place(mq) {
const slot = document.getElementById(mq.matches ? 'sb-slot-mobile' : 'sb-slot-desktop');
if (slot && rootEl.parentElement !== slot) slot.appendChild(rootEl);
}
export function initStatusBar() {
if (rootEl) return;
rootEl = document.createElement('div');
rootEl.className = 'statusbar';
rootEl.setAttribute('role', 'group');
rootEl.setAttribute('aria-label', t('integrations.indicatorHint'));
rootEl.innerHTML =
'<div class="statusbar-dots"></div>' +
'<span class="statusbar-div" aria-hidden="true"></span>' +
'<div class="statusbar-labels"><span class="statusbar-label"></span><span class="statusbar-label"></span>' +
'<button type="button" class="statusbar-attn" aria-hidden="true" tabindex="-1" aria-label="' + esc(t('attention.title')) + '">!</button>' +
'</div>' +
'<span class="statusbar-div" aria-hidden="true"></span>' +
'<div class="statusbar-blur"></div>';
dotsEl = rootEl.querySelector('.statusbar-dots');
labelEls = [...rootEl.querySelectorAll('.statusbar-label')];
labelSlots = labelEls.map(el => ({ el, ts: 0, timer: null }));
attnEl = rootEl.querySelector('.statusbar-attn');
attnEl.addEventListener('click', openAttentionMenu);
initPrivacyBlur(rootEl.querySelector('.statusbar-blur'));
syncDots();
dotsEl.addEventListener('click', (e) => {
const dot = e.target.closest('[data-provider-key]');
if (!dot) return;
try { sessionStorage.setItem('integrations.focus', dot.dataset.providerKey); } catch { }
location.hash = targetHash(TARGETS.INTEGRATIONS);
});
document.addEventListener('api-call', (e) => {
const d = e.detail || {};
if (d.provider_key) lightDot(d.provider_key, !!d.ok);
if (d.symbol) showSymbol(d.symbol);
});
document.addEventListener('providers-changed', syncDots);
window.addEventListener('hashchange', syncDots);
document.addEventListener('attention-changed', syncAttention);
syncAttention();
const mq = window.matchMedia(MOBILE_MQ);
mq.addEventListener('change', () => place(mq));
place(mq);
}
+24
View File
@@ -0,0 +1,24 @@
// Generated, do not edit. sha256:b5a4abd8ddb07f0a2874418e2afd4b67b87a2f07cb6b67fe7d38692911fdc843
export function seedValues(columns, row, newDefaults) {
const v = {};
for (const c of columns) {
if (!c.edit) continue;
v[c.key] = c.edit.value ? c.edit.value(row) : (row ? row[c.key] : undefined);
}
if (!row && newDefaults) Object.assign(v, newDefaults);
return v;
}
export function normalizeOptions(list) {
return (list || []).map(x => ((typeof x === 'object') ? x : { value: x, label: x }));
}
export async function loadOptions(columns, vals, cache) {
cache.clear();
for (const col of columns) {
if (!col.edit || col.edit.options === undefined) continue;
const o = typeof col.edit.options === 'function' ? await col.edit.options(vals) : col.edit.options;
cache.set(col.key, normalizeOptions(o));
}
return cache;
}
+67
View File
@@ -0,0 +1,67 @@
// Generated, do not edit. sha256:374bda270315c76a934d2540ed9d4724c841659867d96ba0c265c5a8c61bbca3
import { esc } from './escape.js';
import { cellClassAttr } from './table-markup.js';
export function editInputHtml(col, vals, { options = [], convert = null } = {}) {
const e = col.edit;
const cur = vals[col.key];
if (e.render) return e.render(vals);
const react = e.reactive ? ' data-reactive="1"' : '';
const extra = e.attrs ? ' ' + Object.entries(e.attrs).map(([k, v]) => `${k}="${esc(v)}"`).join(' ') : '';
const phVal = typeof e.placeholder === 'function' ? e.placeholder(vals) : e.placeholder;
const ph = phVal ? ` placeholder="${esc(phVal)}"` : '';
if (e.type === 'select') {
const opts = options.map(o =>
`<option value="${esc(o.value)}"${String(o.value) === String(cur) ? ' selected' : ''}>${esc(o.label)}</option>`).join('');
return `<select class="input" data-field="${col.key}"${react}${extra}>${opts}</select>`;
}
if (e.type === 'checkbox') {
return `<label class="toggle-switch"><input type="checkbox" data-field="${col.key}"${react}${extra}${cur ? ' checked' : ''}><span class="toggle-track"></span></label>`;
}
const val = cur == null ? '' : esc(cur);
if (e.type === 'textarea') {
return `<textarea class="input tbl-textarea" data-field="${col.key}"${react}${extra}${ph} rows="2">${val}</textarea>`;
}
const type = e.type === 'number' ? 'number' : (e.type === 'date' ? 'date' : (e.type === 'time' ? 'time' : 'text'));
const inputHtml = `<input class="input" type="${type}" data-field="${col.key}"${react}${extra}${ph} value="${val}">`;
if (!e.convert || !convert || !convert.canConvert(e.convert(vals))) return inputHtml;
const cvLabel = esc(convert.label || '');
return `<div class="conv-field">${inputHtml}<button type="button" class="conv-trigger" data-convert="${col.key}" title="${cvLabel}" aria-label="${cvLabel}">${convert.icon || ''}</button></div>`;
}
export function editCellHtml(col, vals, opts) {
const vis = !col.edit.visible || col.edit.visible(vals);
if (col.edit.expand) return `<td${cellClassAttr(col)}></td>`;
if (!vis) return `<td${cellClassAttr(col)}></td>`;
return `<td${cellClassAttr(col)} data-label="${esc(col.label || '')}">${editInputHtml(col, vals, opts)}</td>`;
}
export function expandRowHtml({ editableCols, vals, error, colCount, optsFor, actions = '' }) {
const fields = editableCols
.filter(c => c.edit.expand && (!c.edit.visible || c.edit.visible(vals)))
.map(c => {
const full = c.edit.type === 'textarea' || c.edit.full;
return `<div class="form-group edit-expand-field${full ? ' is-full' : ''}">
<label class="form-label">${esc(c.label)}</label>${editInputHtml(c, vals, optsFor(c))}
</div>`;
}).join('');
const err = error ? `<div class="form-error edit-expand-error">${esc(error)}</div>` : '';
if (!fields && !err && !actions) return '';
const fieldsHtml = fields ? `<div class="flex gap-md edit-expand-fields">${fields}</div>` : '';
return `<tr class="edit-expand-row"><td colspan="${colCount}">
${fieldsHtml}${err}${actions}
</td></tr>`;
}
export function captureValues(scope, editableCols, vals) {
for (const c of editableCols) {
if (c.edit.read) {
vals[c.key] = c.edit.read(scope.querySelector(`[data-field="${c.key}"]`));
continue;
}
const node = scope.querySelector(`[data-field="${c.key}"]`);
if (!node) continue;
vals[c.key] = node.type === 'checkbox' ? node.checked : node.value;
}
return vals;
}
+46
View File
@@ -0,0 +1,46 @@
// Generated, do not edit. sha256:6afcbeb7660ad5dcdb3d575aeb4495b0e547e5f54ce5b5f18abe560cc6fb7b7e
const FOCUS_ANCHORS = ['data-field', 'data-convert', 'data-save', 'data-cancel'];
const SELECTION_TYPES = new Set(['text', 'search', 'url', 'tel', 'password']);
function hasSelection(node) {
if (!node) return false;
if (node.tagName === 'TEXTAREA') return true;
return node.tagName === 'INPUT' && SELECTION_TYPES.has(node.type);
}
function focusAnchor(node) {
for (const attr of FOCUS_ANCHORS) {
const host = node.closest(`[${attr}]`);
if (!host) continue;
const value = host.getAttribute(attr);
return { sel: value ? `[${attr}="${value}"]` : `[${attr}]`, node: host };
}
return null;
}
export function captureFocus(el, editing) {
if (!editing) return null;
const active = document.activeElement;
if (!active || !el.contains(active)) return null;
const anchor = focusAnchor(active);
if (!anchor) return null;
if (!hasSelection(anchor.node)) return { sel: anchor.sel, start: null, end: null };
return { sel: anchor.sel, start: anchor.node.selectionStart, end: anchor.node.selectionEnd };
}
export function restoreFocus(el, snap) {
if (!snap) return;
const field = el.querySelector(snap.sel);
if (!field || typeof field.focus !== 'function') return;
field.focus({ preventScroll: true });
if (snap.start == null || !hasSelection(field)) return;
try { field.setSelectionRange(snap.start, snap.end); } catch { }
}
const FOCUSABLE = 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled])';
export function focusFirstField(el) {
const row = el.querySelector('[data-edit-row]');
if (!row) return;
const field = row.querySelector(FOCUSABLE) || el.querySelector(`.edit-expand-row ${FOCUSABLE}`);
if (field) field.focus();
}
+57
View File
@@ -0,0 +1,57 @@
// Generated, do not edit. sha256:50118a60294dbc8f28d9357f89a87533bb1550041305794ac6ceb8f28c573fcc
import { compare } from './table-rows.js';
const SEP = '\u0000';
export function groupSections(rows, { columns, groupKey, groupKey2 = null, groupKey3 = null, sort = null, collapsed = null }) {
const col = columns.find(c => c.key === groupKey);
const col2 = groupKey2 ? columns.find(c => c.key === groupKey2) : null;
const col3 = groupKey3 ? columns.find(c => c.key === groupKey3) : null;
const orderOf = (c, key) => ((sort && sort.key === key) ? sort.dir : (c.group.order || 'asc'));
const order = orderOf(col, groupKey);
const order2 = col2 ? orderOf(col2, groupKey2) : 'asc';
const order3 = col3 ? orderOf(col3, groupKey3) : 'asc';
const buckets = new Map();
for (const r of rows) {
const v1 = col.group.value(r);
const v2 = col2 ? col2.group.value(r) : null;
const v3 = col3 ? col3.group.value(r) : null;
const key = [String(v1), col2 ? String(v2) : '', col3 ? String(v3) : ''].join(SEP);
if (!buckets.has(key)) buckets.set(key, { v1, v2, v3, items: [] });
buckets.get(key).items.push(r);
}
const entries = [...buckets.values()].sort((a, b) => {
const c1 = compare(a.v1, b.v1, order);
if (c1 !== 0 || !col2) return c1;
const c2 = compare(a.v2, b.v2, order2);
return (c2 !== 0 || !col3) ? c2 : compare(a.v3, b.v3, order3);
});
let itemsByV1 = null;
if (collapsed) {
itemsByV1 = new Map();
for (const e of entries) {
const tk = String(e.v1);
if (!itemsByV1.has(tk)) itemsByV1.set(tk, []);
itemsByV1.get(tk).push(...e.items);
}
}
const shownCollapsed = new Set();
const out = [];
for (const { v1, v2, v3, items } of entries) {
const token = String(v1);
const l1 = col.group.label ? col.group.label(v1) : v1;
if (collapsed && collapsed.has(token)) {
if (shownCollapsed.has(token)) continue;
shownCollapsed.add(token);
out.push({ token, value: v1, label: l1, items: itemsByV1.get(token) || items, collapsed: true });
continue;
}
let label = l1;
if (col2) label += ` - ${col2.group.label ? col2.group.label(v2) : v2}`;
if (col3) label += ` - ${col3.group.label ? col3.group.label(v3) : v3}`;
out.push({ token, value: v1, label, items, collapsed: false });
}
return out;
}
+112
View File
@@ -0,0 +1,112 @@
// Generated, do not edit. sha256:a4f2c112e439e3cff424dfd351eedcec45c341af567633d67ac86c751e1e4999
import { esc } from './escape.js';
export function cellClasses(col) {
const classes = [];
if (col.align === 'num') classes.push('num');
else if (col.align === 'center') classes.push('center');
if (col.text) classes.push('text');
return classes;
}
export function cellClassAttr(col) {
const classes = cellClasses(col);
return classes.length ? ` class="${classes.join(' ')}"` : '';
}
export function cellHtml(col, row) {
const raw = col.render ? col.render(row) : esc(row[col.key] ?? '');
const content = col.text ? `<div class="cell-clamp">${raw}</div>` : raw;
return `<td${cellClassAttr(col)} data-label="${esc(col.label || '')}">${content}</td>`;
}
export function expCellHtml(hasExpansion, isOpen, chevron) {
if (!hasExpansion) return '';
return `<td class="exp-cell"><button type="button" class="chev-btn${isOpen ? ' is-open is-accent' : ''}" tabindex="-1" aria-hidden="true">${chevron}</button></td>`;
}
export function headRowHtml({ columns, sort, hasExpansion, hasActions }) {
const ths = columns.map(col => {
const classes = cellClasses(col);
const label = esc(col.label || '');
if (!col.sortable) return `<th${cellClassAttr(col)}>${label}</th>`;
const active = sort && sort.key === col.key;
const arrow = active ? `<span class="sort-arrow" aria-hidden="true">${sort.dir === 'asc' ? '▲' : '▼'}</span>` : '';
const ariaSort = active ? ` aria-sort="${sort.dir === 'asc' ? 'ascending' : 'descending'}"` : '';
return `<th class="${[...classes, 'th-sort'].join(' ')}" data-sort="${esc(col.key)}" tabindex="0"${ariaSort}>${label}${arrow}</th>`;
}).join('');
const expTh = hasExpansion ? '<th class="exp-cell"></th>' : '';
return `<tr>${expTh}${ths}${hasActions ? '<th class="actions-cell"></th>' : ''}</tr>`;
}
const PLUS = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
export function controlsHtml({ cfg, labels, canCreate, groupCols, groupKey, groupKey2, groupKey3, search, editing }) {
const dis = editing ? ' disabled' : '';
const newLabel = esc(cfg.newLabel || labels.new);
const newBtn = canCreate
? `<button class="btn btn-new" data-tbl-new title="${newLabel}" aria-label="${newLabel}"${dis}>${PLUS}</button>`
: '';
const noteText = typeof cfg.toolbarNote === 'function' ? cfg.toolbarNote() : cfg.toolbarNote;
const note = noteText ? `<span class="tbl-note">${esc(noteText)}</span>` : '';
const toolbarBtns = (cfg.toolbarActions || []).map(a => a.icon
? `<button class="btn btn-new" data-tbl-act="${esc(a.id)}" title="${esc(a.title || a.label)}" aria-label="${esc(a.title || a.label)}"${dis}>${a.icon}</button>`
: `<button class="btn btn-ghost" data-tbl-act="${esc(a.id)}" title="${esc(a.title || a.label)}"${dis}>${esc(a.label)}</button>`).join('');
let group = '';
if (groupCols.length && cfg.groupChoice !== false) {
const noneText = esc(cfg.noneLabel || labels.none);
const aria = ` aria-label="${esc(cfg.groupLabel || cfg.noneLabel || labels.none)}"`;
const none = `<option value="">${noneText}</option>`;
const opt = (c, chosen) => `<option value="${esc(c.key)}"${c.key === chosen ? ' selected' : ''}>${esc(c.label)}</option>`;
const select = (level, cols, chosen) =>
`<select class="input tbl-group-select" data-tbl-group="${level}"${aria}${dis}>${none}${cols.map(c => opt(c, chosen)).join('')}</select>`;
group = select(1, groupCols, groupKey);
if (groupKey) {
group += select(2, groupCols.filter(c => c.key !== groupKey), groupKey2);
if (groupKey2) {
const cols3 = groupCols.filter(c => c.key !== groupKey && c.key !== groupKey2);
if (cols3.length) group += select(3, cols3, groupKey3);
}
}
}
const searchText = esc(cfg.searchLabel || labels.search);
const searchBox = `<input class="input tbl-search" data-tbl-search type="search" placeholder="${searchText}" aria-label="${searchText}" value="${esc(search)}"${dis}>`;
const filterSlot = groupKey && cfg.groupChoice !== false && cfg.columns.some(c => c.key === groupKey)
? '<div class="tbl-filter" data-gfilter></div>' : '';
const countBox = `<div class="tbl-count" title="${esc(cfg.countLabel || labels.count)}">${cfg.rows.length}</div>`;
return `<div class="table-controls">${newBtn}${note}${toolbarBtns}${group}${searchBox}${filterSlot}${countBox}</div>`;
}
const sameId = (a, b) => a != null && b != null && String(a) === String(b);
export function editActionsHtml({ cfg, labels }) {
return `<div class="row-actions edit-actions">
<button class="btn btn-primary btn-xs" data-save="1">${esc(cfg.saveLabel || labels.save)}</button>
<button class="btn btn-ghost btn-xs" data-cancel="1">${esc(cfg.cancelLabel || labels.cancel)}</button>
</div>`;
}
export function actionsCellHtml({ cfg, labels, row, idKey, isEditRow, editing, selectedId }) {
if (isEditRow || editing) return '<td class="actions-cell"></td>';
const id = row[idKey];
if (!sameId(id, selectedId)) return '<td class="actions-cell"></td>';
const eid = esc(id);
const extra = (cfg.extraActions || []).map(a =>
`<button class="btn btn-secondary btn-xs" data-action="${esc(a.key)}" data-id="${eid}">${esc(a.label)}</button>`).join('');
const edit = (cfg.onSave && (!cfg.rowEditable || cfg.rowEditable(row))) ? `<button class="btn btn-secondary btn-xs" data-edit="${eid}" title="${esc(labels.edit)}">✎</button>` : '';
const del = (cfg.onDelete && (!cfg.rowDeletable || cfg.rowDeletable(row))) ? `<button class="btn btn-danger btn-xs" data-del="${eid}">✕</button>` : '';
return `<td class="actions-cell"><div class="row-actions">${extra}${edit}${del}</div></td>`;
}
export function groupHeaderRowHtml({ token, label, count, summaryHtml = '', isCollapsed = false, collapsible, colCount, chevron }) {
const chev = collapsible ? `<button type="button" class="chev-btn is-accent${isCollapsed ? '' : ' is-open'}" tabindex="-1" aria-hidden="true">${chevron}</button>` : '';
const attrs = collapsible ? ` data-gcollapse="${esc(token)}" role="button" tabindex="0"` : '';
const sum = summaryHtml ? `<span class="group-summary">${summaryHtml}</span>` : '';
return `<tr class="group-row${isCollapsed ? ' is-collapsed' : ''}"><td colspan="${colCount}"><div class="group-header"${attrs}>${chev}<span>${esc(label)}</span><span class="group-count">${count}</span>${sum}</div></td></tr>`;
}
export function frameHtml({ controls, head, body, hasExpansion, title = '' }) {
const wrapClasses = hasExpansion ? ' sticky-sel has-exp' : '';
const titleHtml = title ? `<div class="tbl-title t-label">${esc(title)}</div>` : '';
return `<div class="table-frame"><div class="table-controls-bar">${titleHtml}${controls}</div><div class="table-wrap${wrapClasses}"><table><thead>${head}</thead><tbody>${body}</tbody></table></div></div>`;
}
+61
View File
@@ -0,0 +1,61 @@
// Generated, do not edit. sha256:6f5f28d079f7f6e64bd296677a36276bfb053415d4ab690712c66e663e2b15d6
export const valueOf = (col, row) => (col.sortValue ? col.sortValue(row) : row[col.key]);
export function compare(a, b, dir) {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
const r = (typeof a === 'number' && typeof b === 'number')
? a - b
: String(a).localeCompare(String(b), 'nl', { numeric: true });
return dir === 'desc' ? -r : r;
}
export function sortRows(rows, columns, sort) {
if (!sort) return rows.slice();
const col = columns.find(c => c.key === sort.key);
if (!col) return rows.slice();
return rows.slice().sort((x, y) => compare(valueOf(col, x), valueOf(col, y), sort.dir));
}
export function rowText(row, columns) {
return columns.map(c => {
const v = c.render ? c.render(row) : valueOf(c, row);
return String(v == null ? '' : v).replace(/<[^>]*>/g, ' ');
}).join(' ').toLowerCase();
}
export function filterRows(rows, columns, { search = '', groupKey = null, hidden = new Set() } = {}) {
const q = search.trim().toLowerCase();
let out = q ? rows.filter(r => rowText(r, columns).includes(q)) : rows;
if (groupKey && hidden.size) {
const col = columns.find(c => c.key === groupKey);
if (col) out = out.filter(r => !hidden.has(String(col.group.value(r))));
}
return out;
}
export function groupValues(rows, col, order = 'asc') {
const seen = new Map();
for (const r of rows) {
const v = col.group.value(r);
const tok = String(v);
if (!seen.has(tok)) seen.set(tok, v);
}
return [...seen.entries()]
.map(([token, value]) => ({ token, value, label: col.group.label ? col.group.label(value) : value }))
.sort((a, b) => compare(a.value, b.value, order));
}
export const toggleToken = (token, list = []) =>
(list.includes(token) ? list.filter(t => t !== token) : [...list, token]);
export function cycleGroupToken(token, hiddenList = [], collapsedList = []) {
const hidden = new Set(hiddenList);
const collapsed = new Set(collapsedList);
const shown = !hidden.has(token);
if (shown && !collapsed.has(token)) collapsed.add(token);
else if (shown) { collapsed.delete(token); hidden.add(token); }
else { hidden.delete(token); collapsed.delete(token); }
return { hidden: [...hidden], collapsed: [...collapsed] };
}
+48
View File
@@ -0,0 +1,48 @@
// Generated, do not edit. sha256:3da7d727d9cd395ad6f678ed5763ee67308c18ed602c6296f49806e3a2cada5f
export function initialState(cfg) {
const columnExists = key => !!key && cfg.columns.some(c => c.key === key);
const groupable = key => !!key && cfg.columns.some(c => c.key === key && c.group);
const usableSort = s => ((s && columnExists(s.key)) ? { ...s } : null);
return {
sort: usableSort(cfg.sort) || usableSort(cfg.defaultSort),
groupKey: groupable(cfg.group) ? cfg.group : null,
groupKey2: groupable(cfg.group2) ? cfg.group2 : null,
groupKey3: groupable(cfg.group3) ? cfg.group3 : null,
groupFilter: cfg.groupFilter ? { ...cfg.groupFilter } : {},
groupCollapsed: cfg.groupCollapsed ? { ...cfg.groupCollapsed } : {},
sticky: cfg.sticky ? { ...cfg.sticky } : null,
};
}
export function chooseGroup(keys, level, value) {
let { groupKey, groupKey2, groupKey3 } = keys;
const v = value || null;
if (level === 1) {
groupKey = v;
if (!groupKey) { groupKey2 = null; groupKey3 = null; }
else { if (groupKey2 === groupKey) groupKey2 = null; if (groupKey3 === groupKey) groupKey3 = null; }
} else if (level === 2) {
groupKey2 = v;
if (!groupKey2) groupKey3 = null;
else if (groupKey3 === groupKey2) groupKey3 = null;
} else if (level === 3) {
groupKey3 = v;
}
return { groupKey, groupKey2, groupKey3 };
}
export function revealGroup({ groupKey, groupFilter, groupCollapsed }, token) {
if (!groupKey) return { groupFilter, groupCollapsed, changed: false };
let changed = false;
const hidden = groupFilter[groupKey] || [];
if (hidden.includes(token)) {
groupFilter = { ...groupFilter, [groupKey]: hidden.filter(t => t !== token) };
changed = true;
}
const collapsed = groupCollapsed[groupKey] || [];
if (collapsed.includes(token)) {
groupCollapsed = { ...groupCollapsed, [groupKey]: collapsed.filter(t => t !== token) };
changed = true;
}
return { groupFilter, groupCollapsed, changed };
}
+26
View File
@@ -0,0 +1,26 @@
// Generated, do not edit. sha256:0727197b9fd0ac80c6a541e3be09548dc3b7f76576643552b5f17b4dbd19dd61
export function seedSticky({ columns, seeded, remembered, options }) {
const out = { ...seeded };
if (!remembered) return out;
for (const col of columns) {
if (!col.edit || !col.edit.sticky) continue;
if (!Object.prototype.hasOwnProperty.call(remembered, col.key)) continue;
const value = remembered[col.key];
if (value === undefined) continue;
const list = options && typeof options.get === 'function' ? options.get(col.key) : null;
if (list && !list.some(o => String(o.value) === String(value))) continue;
out[col.key] = value;
}
return out;
}
export function harvestSticky(columns, vals) {
const out = {};
if (!vals) return out;
for (const col of columns) {
if (!col.edit || !col.edit.sticky) continue;
if (vals[col.key] === undefined) continue;
out[col.key] = vals[col.key];
}
return out;
}
+542
View File
@@ -0,0 +1,542 @@
// Generated, do not edit. sha256:b90f82710740940a1caabe2a9b3167bc6ea309d53e7f934fd79a4afdb106eabc
import { openPopover, closePopover, isPopoverOpen } from './popover.js';
import { isDialogOpen } from './dialog.js';
import { create as createFilter } from './filter.js';
import { esc } from './escape.js';
import { seedSticky, harvestSticky } from './table-sticky.js';
import * as tableRows from './table-rows.js';
import { initialState, chooseGroup, revealGroup } from './table-state.js';
import { groupSections } from './table-groups.js';
import { seedValues, loadOptions } from './table-edit-values.js';
import { cellHtml, expCellHtml, headRowHtml, controlsHtml, actionsCellHtml, editActionsHtml, groupHeaderRowHtml, frameHtml } from './table-markup.js';
import { editCellHtml, expandRowHtml, captureValues } from './table-edit.js';
import { captureFocus as captureTableFocus, restoreFocus as restoreTableFocus, focusFirstField as focusFirstEditField } from './table-focus.js';
const CHEVRON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>';
const DEFAULT_LABELS = Object.freeze({
new: 'New', save: 'Save', cancel: 'Cancel', none: 'Ungrouped', search: 'Search…', count: 'Records', edit: 'Edit',
});
let labels = { ...DEFAULT_LABELS };
let convertHook = null;
let repeatKeyOf = () => 'ctrl_enter';
let storagePrefix = 'table.';
export function configureTable({ labels: l = null, convert = null, repeatKey = null, storagePrefix: prefix = null } = {}) {
labels = { ...DEFAULT_LABELS, ...(l || {}) };
convertHook = convert;
repeatKeyOf = typeof repeatKey === 'function' ? repeatKey : () => 'ctrl_enter';
storagePrefix = prefix || 'table.';
}
function readState(key) {
if (!key) return null;
try { return JSON.parse(globalThis.localStorage?.getItem(storagePrefix + key) ?? 'null'); } catch { return null; }
}
function writeState(key, state) {
if (!key) return;
try { globalThis.localStorage?.setItem(storagePrefix + key, JSON.stringify(state)); } catch { }
}
const sameId = (a, b) => a != null && b != null && String(a) === String(b);
const OWN_CLICK = 'button, a, input, select, textarea, label, summary';
const NEW = Symbol('new');
export function keyAction({ key, ctrl, tag, isButton, popoverOpen, dialogOpen, repeatKey }) {
const overlay = !!popoverOpen || !!dialogOpen;
if (key === 'Enter') {
if (overlay) return null;
if (isButton && !ctrl) return null;
if (tag === 'TEXTAREA' && !ctrl) return null;
const repeatOnBare = repeatKey === 'enter';
if (ctrl) return repeatOnBare ? 'save' : 'saveNext';
return repeatOnBare ? 'saveNext' : 'save';
}
if (key === 'Escape') return overlay ? null : 'cancel';
return null;
}
export function renderTable(el, cfg) {
const idKey = cfg.idKey || 'id';
const editableCols = cfg.columns.filter(c => c.edit);
const canCreate = () => !!cfg.onCreate
&& !(typeof cfg.createHidden === 'function' ? cfg.createHidden() : cfg.createHidden);
const hasActions = !!(cfg.onSave || cfg.onDelete || cfg.onCreate || (cfg.extraActions && cfg.extraActions.length));
const groupCols = cfg.columns.filter(c => c.group);
const hasExpansion = !!cfg.rowExpansion;
const stored = readState(cfg.stateKey);
const storedGroupFits = stored && cfg.columns.some(c => c.key === stored.group && c.group);
const start = stored ? {
...cfg,
sort: stored.sort ?? cfg.sort,
defaultSort: cfg.defaultSort ?? cfg.sort,
group: stored.group === null ? null : (storedGroupFits ? stored.group : cfg.group),
} : cfg;
let { sort, groupKey, groupKey2, groupKey3, groupFilter, groupCollapsed, sticky } = initialState(start);
const collapsible = !!cfg.collapsibleGroups;
let search = '';
let altCount = 0;
let selectedId = null;
let editId = null;
let editVals = null;
let editError = '';
let editTouched = new Set();
const editing = () => editId !== null;
function persistState() {
if (cfg.onStateChange) cfg.onStateChange({ group: groupKey, group2: groupKey2, group3: groupKey3, sort, groupFilter, groupCollapsed });
writeState(cfg.stateKey, { sort, group: groupKey });
}
const sortRows = rows => tableRows.sortRows(rows, cfg.columns, sort);
function hiddenTokens() { return new Set(groupKey ? (groupFilter[groupKey] || []) : []); }
function collapsedTokens() { return new Set(groupKey ? (groupCollapsed[groupKey] || []) : []); }
function toggleCollapsed(tok) {
if (!groupKey) return;
groupCollapsed = { ...groupCollapsed, [groupKey]: tableRows.toggleToken(tok, groupCollapsed[groupKey]) };
persistState(); draw();
}
function cycleGroup(tok) {
if (!groupKey) return;
const { hidden, collapsed } = tableRows.cycleGroupToken(tok, groupFilter[groupKey], groupCollapsed[groupKey]);
groupFilter = { ...groupFilter, [groupKey]: hidden };
groupCollapsed = { ...groupCollapsed, [groupKey]: collapsed };
persistState(); draw();
}
function distinctGroupVals(col) {
const order = (sort && sort.key === groupKey) ? sort.dir : (col.group.order || 'asc');
return tableRows.groupValues(cfg.rows, col, order);
}
function setGroupFilter(hiddenArr) { if (!groupKey) return; groupFilter = { ...groupFilter, [groupKey]: hiddenArr }; persistState(); draw(); }
function showGroup(tok) {
const r = revealGroup({ groupKey, groupFilter, groupCollapsed }, tok);
groupFilter = r.groupFilter;
groupCollapsed = r.groupCollapsed;
return r.changed;
}
function setGroupKeys(level, value) {
({ groupKey, groupKey2, groupKey3 } = chooseGroup({ groupKey, groupKey2, groupKey3 }, level, value));
}
function mountGroupFilter() {
const fh = el.querySelector('[data-gfilter]');
if (!fh) return;
const gc = cfg.columns.find(c => c.key === groupKey);
const vals = gc ? distinctGroupVals(gc) : [];
const hidden = hiddenTokens();
createFilter(fh, {
items: vals.map(v => ({ id: v.token, label: v.label })),
selected: new Set(vals.filter(v => !hidden.has(v.token)).map(v => v.token)),
className: 'fbar--tctl',
disabled: editing(),
collapsible,
collapsedIds: collapsible ? collapsedTokens() : null,
onCycle: collapsible ? cycleGroup : null,
onChange: sel => setGroupFilter(vals.map(v => v.token).filter(tok => !sel.has(tok))),
});
}
const filterRows = rows =>
tableRows.filterRows(rows, cfg.columns, { search, groupKey, hidden: hiddenTokens() });
const seedVals = row => seedValues(editableCols, row, cfg.newDefaults);
const optionCache = new Map();
const loadOptionLists = () => loadOptions(editableCols, editVals, optionCache);
function optionList(col) {
return optionCache.get(col.key) || [];
}
const cell = cellHtml;
const convert = convertHook ? { label: cfg.convertLabel || '', canConvert: convertHook.canConvert, icon: convertHook.icon } : null;
const inputOpts = col => ({ options: optionList(col), convert });
const editCell = col => editCellHtml(col, editVals, inputOpts(col));
function actionsCell(row, isEditRow) {
if (!hasActions) return '';
return actionsCellHtml({ cfg, labels, row, idKey, isEditRow, editing: editing(), selectedId });
}
const selectable = hasActions || hasExpansion;
const isEditRowOf = id => editId !== null && editId !== NEW && sameId(editId, id);
const colCount = () => cfg.columns.length + (hasActions ? 1 : 0) + (hasExpansion ? 1 : 0);
const expCell = isOpen => expCellHtml(hasExpansion, isOpen, CHEVRON);
const expandRow = () => expandRowHtml({ editableCols, vals: editVals, error: editError, colCount: colCount(), optsFor: inputOpts, actions: editActionsHtml({ cfg, labels }) });
function editRowHtml(row) {
const cells = cfg.columns.map(c => c.edit ? editCell(c) : cell(c, row || emptyRow())).join('');
const emptyExp = hasExpansion ? '<td class="exp-cell"></td>' : '';
return `<tr class="row-editing" data-edit-row="1">${emptyExp}${cells}${actionsCell(row, true)}</tr>` + expandRow();
}
function emptyRow() { const r = {}; for (const c of cfg.columns) r[c.key] = ''; return r; }
function dataRow(row) {
const id = row[idKey];
const alt = (altCount++ % 2 === 1);
if (isEditRowOf(id)) return editRowHtml(row);
const selected = !editing() && sameId(id, selectedId);
const classes = [];
if (cfg.rowClass) { const own = cfg.rowClass(row); if (own) classes.push(own); }
if (alt) classes.push('row-alt');
if (selected) classes.push('row-selected');
const selCls = classes.length ? ` class="${esc(classes.join(' '))}"` : '';
const expansion = (selected && cfg.rowExpansion)
? `<tr class="row-expansion" data-exp-id="${esc(id)}"><td colspan="${colCount()}">${cfg.rowExpansion(row)}</td></tr>` : '';
const keys = hasExpansion ? ` tabindex="0" aria-expanded="${!!expansion}"` : '';
return `<tr data-row-id="${esc(id)}"${selCls}${keys}>${expCell(!!expansion)}${cfg.columns.map(c => cell(c, row)).join('')}${actionsCell(row, false)}</tr>` + expansion;
}
const wrapTable = bodyHtml => frameHtml({
controls: controlsHtml({ cfg, labels, canCreate: canCreate(), groupCols, groupKey, groupKey2, groupKey3, search, editing: editing() }),
head: headRowHtml({ columns: cfg.columns, sort, hasExpansion, hasActions }),
body: bodyHtml,
hasExpansion,
title: cfg.title,
});
function bodyContentHtml() {
const rows = sortRows(filterRows(cfg.rows));
altCount = 0;
const newRowHtml = (editId === NEW) ? editRowHtml(null) : '';
if (rows.length === 0 && editId !== NEW) return `<tr class="tbl-empty-spacer"><td colspan="${colCount()}"></td></tr>`;
if (!groupKey) return newRowHtml + rows.map(dataRow).join('');
const sectionList = groupSections(rows, {
columns: cfg.columns, groupKey, groupKey2, groupKey3, sort,
collapsed: collapsible ? collapsedTokens() : null,
});
const groupHeaderRow = (token, label, count, summaryHtml, isCollapsed) =>
groupHeaderRowHtml({ token, label, count, summaryHtml, isCollapsed, collapsible, colCount: colCount(), chevron: CHEVRON });
const sections = sectionList.map(s => {
if (s.collapsed) {
const summary = cfg.groupSummary ? cfg.groupSummary(s.items, s.value) : '';
return groupHeaderRow(s.token, s.label, s.items.length, summary, true);
}
return groupHeaderRow(s.token, s.label, s.items.length, '', false) + s.items.map(dataRow).join('');
}).join('');
return newRowHtml + sections;
}
const headObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(entries => {
if (!el.isConnected) { headObserver.disconnect(); return; }
for (const { target } of entries) {
if (!target.isConnected) continue;
const wrap = target.closest('.table-wrap');
wrap?.style.setProperty('--sticky-row-top', target.offsetHeight + 'px');
if (wrap) measurePinnedRow(wrap);
}
});
function measurePinnedRow(wrap) {
if (!wrap.classList.contains('sticky-sel')) return;
const row = [...wrap.querySelectorAll('tr.row-selected')].find(tr => tr.closest('.table-wrap') === wrap);
if (!row) return;
row.style.setProperty('--row-w', row.offsetWidth + 'px');
for (const td of row.children) td.style.setProperty('--cell-x', (td.offsetLeft || 0) + 'px');
}
function draw() {
const prev = el.querySelector('.table-wrap');
const sTop = prev ? prev.scrollTop : 0;
const sLeft = prev ? prev.scrollLeft : 0;
const focus = captureFocus();
el.innerHTML = wrapTable(bodyContentHtml());
bind();
mountGroupFilter();
const next = el.querySelector('.table-wrap');
if (next) {
next.scrollTop = sTop; next.scrollLeft = sLeft;
const head = next.querySelector('thead');
headObserver?.disconnect();
if (head) {
next.style.setProperty('--sticky-row-top', head.offsetHeight + 'px');
headObserver?.observe(head);
}
measurePinnedRow(next);
}
titleTruncated();
mountDrawer();
restoreFocus(focus);
cfg.onDraw?.(el);
}
const captureFocus = () => captureTableFocus(el, editing());
const restoreFocus = snap => restoreTableFocus(el, snap);
function mountDrawer() {
if (selectedId == null || !cfg.onExpansionMount || !cfg.rowExpansion) return;
const tr = [...el.querySelectorAll('tr.row-expansion')].find(t => sameId(t.dataset.expId, selectedId));
const host = tr && tr.querySelector('td');
if (!host) return;
const row = cfg.rows.find(r => sameId(r[idKey], selectedId));
if (row) cfg.onExpansionMount(row, host);
}
function titleTruncated() {
for (const cel of el.querySelectorAll('.cell-clamp')) {
if (cel.scrollHeight > cel.clientHeight + 1) cel.title = cel.textContent.trim();
else cel.removeAttribute('title');
}
}
function updateBody() {
const tb = el.querySelector('tbody');
if (tb) tb.innerHTML = bodyContentHtml();
titleTruncated();
mountDrawer();
cfg.onDraw?.(el);
}
const captureVals = () => captureValues(el.querySelector('tbody') || el, editableCols, editVals);
async function startEdit(id) {
const row = cfg.rows.find(r => sameId(r[idKey], id));
editId = row ? row[idKey] : id; editVals = seedVals(row); editError = ''; selectedId = null; editTouched = new Set();
await loadOptionLists();
draw();
focusFirstField();
}
async function startNew(extra) {
editId = NEW; editVals = seedVals(null); if (extra) Object.assign(editVals, extra); editError = ''; selectedId = null; editTouched = new Set();
await loadOptionLists();
editVals = seedSticky({ columns: editableCols, seeded: editVals, remembered: sticky, options: optionCache });
draw();
focusFirstField();
}
const focusFirstField = () => focusFirstEditField(el);
function cancelEdit() { editId = null; editVals = null; editError = ''; draw(); }
function rememberSticky(harvested) {
sticky = harvested;
if (cfg.onStickyChange) cfg.onStickyChange(sticky);
}
let repeatPending = false;
async function commit(repeat) {
captureVals();
const wasNew = editId === NEW;
const doRepeat = !!repeat && wasNew && !!cfg.onCreate;
repeatPending = doRepeat;
const harvested = wasNew ? harvestSticky(editableCols, editVals) : null;
try {
if (wasNew) await cfg.onCreate(editVals);
else await cfg.onSave(editId, editVals);
if (harvested) rememberSticky(harvested);
editId = null; editVals = null; editError = '';
repeatPending = false;
if (doRepeat && canCreate()) await startNew();
else draw();
} catch (e) {
repeatPending = false;
editError = e.message || String(e);
draw();
}
}
function openConvert(triggerEl) {
const key = triggerEl.dataset.convert;
const col = cfg.columns.find(c => c.key === key);
if (!col || !col.edit || typeof col.edit.convert !== 'function') return;
captureVals();
const asset = col.edit.convert(editVals);
const input = el.querySelector(`[data-field="${key}"]`);
if (!asset || !convertHook || !convertHook.canConvert(asset) || !input) return;
if (typeof col.edit.onConvert === 'function') {
col.edit.onConvert(triggerEl, {
asset, input, vals: editVals,
setValue: v => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); },
setFields: obj => Object.assign(editVals, obj),
});
return;
}
convertHook.open(triggerEl, {
asset,
value: input.value,
onApply: v => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); },
});
}
function openNewMenu(anchor) {
const { el: pop, position } = openPopover(anchor, { className: 'popover new-menu' });
pop.innerHTML = cfg.newOptions.map((o, i) =>
`<button class="btn btn-ghost btn-sm new-menu-item" data-new-opt="${i}">${esc(o.label)}</button>`).join('');
position();
pop.addEventListener('click', e => {
const b = e.target.closest('[data-new-opt]');
if (!b) return;
const opt = cfg.newOptions[Number(b.dataset.newOpt)];
closePopover();
startNew(opt.defaults);
});
}
const idOf = text => { const r = cfg.rows.find(x => sameId(x[idKey], text)); return r ? r[idKey] : text; };
function sortOn(key) {
sort = sort && sort.key === key ? { key, dir: sort.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' };
persistState(); draw();
}
function toggleRow(idText) {
const id = idOf(idText);
selectedId = sameId(selectedId, id) ? null : id;
draw();
}
function bind() {
el.onclick = e => {
e.stopPropagation();
if (editing()) {
if (e.target.closest('[data-save]')) { commit(); return; }
if (e.target.closest('[data-cancel]')) { cancelEdit(); return; }
const cv = e.target.closest('[data-convert]');
if (cv) { openConvert(cv); return; }
return;
}
const th = e.target.closest('[data-sort]');
if (th) { sortOn(th.dataset.sort); return; }
const ed = e.target.closest('[data-edit]');
if (ed && cfg.onSave) { startEdit(idOf(ed.dataset.edit)); return; }
const dl = e.target.closest('[data-del]');
if (dl && cfg.onDelete) { cfg.onDelete(idOf(dl.dataset.del)); return; }
const act = e.target.closest('[data-action]');
if (act && cfg.onAction) { cfg.onAction(act.dataset.action, idOf(act.dataset.id)); return; }
const gt = e.target.closest('[data-gcollapse]');
if (gt) { toggleCollapsed(gt.dataset.gcollapse); return; }
const row = e.target.closest('tr[data-row-id]');
if (!row || !selectable) return;
const own = e.target.closest(OWN_CLICK);
if (own && row.contains(own) && !own.classList.contains('chev-btn')) return;
toggleRow(row.dataset.rowId);
};
const newBtn = el.querySelector('[data-tbl-new]');
if (newBtn) newBtn.onclick = () => {
if (editing()) return;
if (cfg.newOptions && cfg.newOptions.length) openNewMenu(newBtn);
else startNew();
};
for (const b of el.querySelectorAll('[data-tbl-act]')) {
if (!el.contains(b) || b.closest('.table-frame')?.parentElement !== el) continue;
const a = (cfg.toolbarActions || []).find(x => String(x.id) === b.dataset.tblAct);
if (a) b.onclick = () => { if (!editing() && cfg.onToolbarAction) cfg.onToolbarAction(a.id, b); };
}
const searchEl = el.querySelector('[data-tbl-search]');
if (searchEl) searchEl.oninput = () => { search = searchEl.value; updateBody(); };
el.oninput = e => {
const field = e.target && e.target.dataset && e.target.dataset.field;
if (editing() && field) editTouched.add(field);
};
el.onkeydown = e => {
if (!editing()) {
if (e.key !== 'Enter' && e.key !== ' ') return;
const th = e.target.closest?.('th[data-sort]');
if (th) { e.preventDefault(); e.stopPropagation(); sortOn(th.dataset.sort); return; }
if (hasExpansion && e.target.matches?.('tr[data-row-id]')) {
e.preventDefault(); e.stopPropagation(); toggleRow(e.target.dataset.rowId);
}
return;
}
const action = keyAction({
key: e.key,
ctrl: e.ctrlKey || e.metaKey,
tag: e.target.tagName,
isButton: !!e.target.closest('button'),
popoverOpen: isPopoverOpen(),
dialogOpen: isDialogOpen(e.target),
repeatKey: repeatKeyOf(),
});
if (!action) return;
e.preventDefault();
e.stopPropagation();
if (action === 'cancel') cancelEdit();
else commit(action === 'saveNext');
};
el.onchange = e => {
e.stopPropagation();
const level = Number(e.target.dataset && e.target.dataset.tblGroup) || 0;
if (level && !editing()) { setGroupKeys(level, e.target.value); persistState(); draw(); return; }
if (editing() && e.target.matches('[data-reactive]')) {
captureVals();
if (typeof cfg.onEditChange === 'function') {
const patch = cfg.onEditChange({ ...editVals }, e.target.dataset.field, new Set(editTouched));
if (patch) Object.assign(editVals, patch);
}
loadOptionLists().then(draw);
}
};
}
function clearHighlight() { selectedId = null; editId = null; editVals = null; editError = ''; draw(); }
function refresh(rows, opts) {
if (rows) cfg.rows = rows;
if (selectedId != null && !cfg.rows.some(r => sameId(r[idKey], selectedId))) selectedId = null;
if (repeatPending) return;
if (opts && opts.locate != null) {
search = '';
if (groupKey) {
const gc = cfg.columns.find(c => c.key === groupKey);
const newRow = gc && cfg.rows.find(r => sameId(r[idKey], opts.locate));
if (newRow && showGroup(String(gc.group.value(newRow)))) persistState();
}
selectedId = idOf(opts.locate);
editId = null; editVals = null; editError = '';
draw();
requestAnimationFrame(() => locateRow(opts.locate));
return;
}
draw();
}
const rowEl = id => [...el.querySelectorAll('tr[data-row-id]')].find(t => sameId(t.dataset.rowId, id));
function locateRow(id) {
const row = rowEl(id);
if (row) row.scrollIntoView({ block: 'center' });
}
function focusGroup(key, predicate) {
const col = cfg.columns.find(c => c.key === key && c.group);
if (!col) return false;
const row = cfg.rows.find(predicate);
if (!row) return false;
const tok = String(col.group.value(row));
setGroupKeys(1, key);
showGroup(tok);
search = '';
selectedId = null; editId = null; editVals = null; editError = '';
draw();
const headers = [...el.querySelectorAll('[data-gcollapse]')];
const header = headers.find(k => k.dataset.gcollapse === tok);
const target = header || rowEl(row[idKey]);
if (target) requestAnimationFrame(() => target.scrollIntoView({ block: 'center' }));
return true;
}
function focusRow(id) {
if (id == null) return false;
const row = cfg.rows.find(r => String(r[idKey]) === String(id));
if (!row) return false;
if (groupKey) {
const gc = cfg.columns.find(c => c.key === groupKey && c.group);
if (gc) showGroup(String(gc.group.value(row)));
}
search = '';
selectedId = row[idKey];
editId = null; editVals = null; editError = '';
draw();
requestAnimationFrame(() => locateRow(row[idKey]));
return true;
}
draw();
return { clearHighlight, refresh, focusGroup, focusRow };
}
+109
View File
@@ -0,0 +1,109 @@
// Generated, do not edit. sha256:09856719543cb019750495d6ca12c5afc9eb3cc35cdf6a20a0f887e365cc177f
import { esc } from './escape.js';
function plainError(panel, err, id) {
console.error(`[tabs] ${id || ''}`, err);
panel.innerHTML = `<div class="alert alert-error">${esc(err && err.message ? err.message : String(err))}</div>`;
}
let onError = plainError;
export function configureTabs({ onError: handler = null } = {}) {
onError = handler || plainError;
}
export function rowLayout(count, content, room) {
const rows = count > 1 && content > room ? 2 : 1;
return { rows, cols: Math.ceil(count / rows) };
}
export function create(host, opts = {}) {
if (!host) return null;
const { items = [], render, onChange, size = '', syncOn = [], barHost = null } = opts;
const list = items.filter(it => it && (!it.when || it.when()));
let current = opts.active;
if (!list.some(it => it.id === current)) current = list[0] && list[0].id;
const bar = document.createElement('div');
bar.className = 'tabset' + (size === 'lg' ? ' tabset--lg' : '');
bar.setAttribute('role', 'tablist');
const panel = document.createElement('div');
panel.setAttribute('role', 'tabpanel');
if (host._tabsCleanup) host._tabsCleanup();
host.innerHTML = '';
if (barHost) { barHost.innerHTML = ''; barHost.append(bar); host.append(panel); }
else host.append(bar, panel);
function drawBar() {
bar.innerHTML = list.map(it => {
const on = it.id === current;
const dot = typeof it.dot === 'function' ? (it.dot() || '') : (it.dot || '');
return `<button class="tabset-tab${on ? ' is-on' : ''}" role="tab" aria-selected="${on}" data-tab="${it.id}">${it.label}${dot}</button>`;
}).join('');
bar.querySelectorAll('[data-tab]').forEach(b => { b.onclick = () => select(b.dataset.tab); });
if (size === 'lg') fitRows();
}
function fitRows() {
if (!bar.isConnected) return;
bar.classList.remove('tabset--rows');
const box = bar.getBoundingClientRect();
const last = bar.children[bar.children.length - 1];
const reach = last ? last.getBoundingClientRect().right - box.left : 0;
const { rows, cols } = rowLayout(list.length, Math.round(reach), Math.round(box.width));
if (rows < 2) return;
bar.classList.add('tabset--rows');
bar.style.setProperty('--tabset-cols', String(cols));
}
let seenWidth = -1;
const resize = size === 'lg' && typeof ResizeObserver === 'function'
? new ResizeObserver(entries => {
if (!bar.isConnected) { cleanup(); return; }
const width = entries[entries.length - 1].contentRect.width;
if (width === seenWidth) return;
seenWidth = width;
fitRows();
})
: null;
function drawPanel() {
panel.innerHTML = '';
if (!render) return;
try {
const r = render(current, panel);
if (r && typeof r.then === 'function') r.catch(err => onError(panel, err, current));
} catch (err) { onError(panel, err, current); }
}
function select(id, force) {
if (!force && id === current) return;
if (!list.some(it => it.id === id)) return;
current = id;
drawBar();
drawPanel();
if (onChange) onChange(id);
}
const onSync = () => { if (!bar.isConnected) { cleanup(); return; } drawBar(); };
function cleanup() {
syncOn.forEach(ev => document.removeEventListener(ev, onSync));
if (resize) resize.disconnect();
if (host._tabsCleanup === cleanup) host._tabsCleanup = null;
}
syncOn.forEach(ev => document.addEventListener(ev, onSync));
if (resize) resize.observe(bar);
host._tabsCleanup = cleanup;
drawBar();
drawPanel();
return {
el: host, bar, panel,
get active() { return current; },
select: id => select(id),
refresh: () => drawBar(),
redraw: () => drawPanel(),
destroy: cleanup,
};
}
+13
View File
@@ -0,0 +1,13 @@
// Generated, do not edit. sha256:a87223ad75216e76cfa18c625050ed381897b003ba4c77f9b54956a8260e76fb
import { esc } from './escape.js';
import { CHECK_CIRCLE } from './icons.js';
export function taskCardHtml({ title, body = '', actions = '', className = '' }) {
const label = `<div class="t-label">${esc(title)}</div>`;
const head = actions ? `<div class="task-card-head">${label}${actions}</div>` : label;
return `<div class="card${className ? ` ${className}` : ''}">${head}${body}</div>`;
}
export function nothingPendingHtml() {
return `<div class="task-card-empty"><span class="task-card-empty-icon">${CHECK_CIRCLE}</span></div>`;
}
+63
View File
@@ -0,0 +1,63 @@
// Generated, do not edit. sha256:9537fc0b4eaa35092768e80eee626c20b2e7da8195e3aa3271037126f3d4cfa3
import { t } from '../i18n/index.js';
import { mainToSub, subToMain, hasSubunit, isMetal } from './format.js';
import { openPopover, closePopover } from './popover.js';
import { CONFIG } from '../../shared/config.js';
import { esc } from './escape.js';
export function openUnitConvert(anchorEl, { asset, value, onApply }) {
if (!hasSubunit(asset)) return;
const metal = isMetal(asset);
const dec = asset.decimals || 0;
const subField = asset.displayUnit === 'subunit';
const U = CONFIG.METALS.UNITS;
const round6 = x => Number(Number(x).toFixed(6));
const m2s = metal ? (m => round6(Number(m) * U[asset.unit] / U[asset.subunit])) : (m => mainToSub(m, dec));
const s2m = metal ? (s => round6(Number(s) * U[asset.subunit] / U[asset.unit])) : (s => subToMain(s, dec));
const seed = parseFloat(value);
let mainVal = '', subVal = '';
if (Number.isFinite(seed)) {
if (subField) { subVal = metal ? seed : Math.round(seed); mainVal = s2m(subVal); }
else { mainVal = seed; subVal = m2s(mainVal); }
}
const mainLabel = esc(asset.unit || t('convert.main'));
const subLabel = esc(asset.subunit || t('convert.sub'));
const subStep = metal ? 'any' : '1';
const { el, position } = openPopover(anchorEl, {
className: 'conv-pop',
onClose: () => { if (document.activeElement === document.body) anchorEl.focus(); },
});
el.innerHTML = `
<div class="t-label conv-head">${esc(t('convert.title'))}</div>
<div class="conv-row">
<label class="conv-label">${mainLabel}</label>
<input class="input" data-conv-main type="number" step="any" min="0" value="${mainVal}">
</div>
<div class="conv-row">
<label class="conv-label">${subLabel}</label>
<input class="input" data-conv-sub type="number" step="${subStep}" min="0" value="${subVal}">
</div>
<button class="btn btn-primary btn-sm conv-apply" data-conv-apply>${esc(t('convert.apply'))}</button>`;
position();
const mainEl = el.querySelector('[data-conv-main]');
const subEl = el.querySelector('[data-conv-sub]');
mainEl.oninput = () => { const m = parseFloat(mainEl.value); subEl.value = Number.isFinite(m) ? m2s(m) : ''; };
subEl.oninput = () => { const s = parseFloat(subEl.value); mainEl.value = Number.isFinite(s) ? s2m(s) : ''; };
const apply = () => {
const out = subField ? subEl.value : mainEl.value;
if (out === '') return;
onApply(String(out));
closePopover();
};
el.querySelector('[data-conv-apply]').onclick = apply;
el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); apply(); } });
(subField ? mainEl : subEl).focus();
}