Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<');
|
||||
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);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(); });
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Generated, do not edit. sha256:b6966cd87b9511be3b1677051299af34144f53d2923d0082003d5a66a5025eb3
|
||||
const HTML_ENTITIES = Object.freeze({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
});
|
||||
|
||||
export const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => HTML_ENTITIES[c]);
|
||||
@@ -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(() => { });
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>',
|
||||
});
|
||||
@@ -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>';
|
||||
@@ -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 || ''));
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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]);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Generated, do not edit. sha256:e6ada8471a98e1a9a9c718825aa1e371ce7d568ba6108b0249aab352b6a55e48
|
||||
export function createRenderTurns() {
|
||||
let newest = 0;
|
||||
return {
|
||||
take: () => ++newest,
|
||||
isCurrent: (turn) => turn === newest,
|
||||
};
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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>`;
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user