feat: unify card runtime and event-driven web ui
This commit is contained in:
parent
0edf8c3fef
commit
4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions
|
|
@ -1,336 +1,12 @@
|
|||
<style>
|
||||
.list-total-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: #4d392d;
|
||||
}
|
||||
|
||||
.list-total-card__labels,
|
||||
.list-total-card__row,
|
||||
.list-total-card__total {
|
||||
display: grid;
|
||||
grid-template-columns: 68px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-total-card__labels {
|
||||
color: rgba(77, 57, 45, 0.72);
|
||||
font: 700 0.62rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.list-total-card__rows {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.list-total-card__input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(92, 70, 55, 0.14);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #473429;
|
||||
padding: 4px 0;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.list-total-card__input::placeholder {
|
||||
color: rgba(77, 57, 45, 0.42);
|
||||
}
|
||||
|
||||
.list-total-card__value {
|
||||
font: 700 0.84rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.list-total-card__name {
|
||||
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.08;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.008em;
|
||||
}
|
||||
|
||||
.list-total-card__status {
|
||||
min-height: 0.9rem;
|
||||
color: #8e3023;
|
||||
font: 700 0.62rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.list-total-card__status[data-kind='ok'] {
|
||||
color: rgba(77, 57, 45, 0.5);
|
||||
}
|
||||
|
||||
.list-total-card__total {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(92, 70, 55, 0.18);
|
||||
color: #35271f;
|
||||
}
|
||||
|
||||
.list-total-card__total-label {
|
||||
font: 700 0.66rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.list-total-card__total-value {
|
||||
font: 700 0.98rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="list-total-card" data-list-total-card>
|
||||
<div class="list-total-card__labels">
|
||||
<div data-list-total-left-label>Value</div>
|
||||
<div data-list-total-right-label>Item</div>
|
||||
<div class="list-total-card-ui">
|
||||
<div class="list-total-card-ui__labels">
|
||||
<div>Value</div>
|
||||
<div>Item</div>
|
||||
</div>
|
||||
<div class="list-total-card__rows" data-list-total-rows></div>
|
||||
<div class="list-total-card__status" data-list-total-status></div>
|
||||
<div class="list-total-card__total">
|
||||
<div class="list-total-card__total-label" data-list-total-total-label>Total</div>
|
||||
<div class="list-total-card__total-value" data-list-total-total>0</div>
|
||||
<div class="list-total-card-ui__rows"></div>
|
||||
<div class="list-total-card-ui__status"></div>
|
||||
<div class="list-total-card-ui__total">
|
||||
<div class="list-total-card-ui__total-label">Total</div>
|
||||
<div class="list-total-card-ui__total-value">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const script = document.currentScript;
|
||||
const root = script?.closest('[data-nanobot-card-root]');
|
||||
const state = window.__nanobotGetCardState?.(script) || {};
|
||||
if (!(root instanceof HTMLElement)) return;
|
||||
|
||||
const cardId = String(root.dataset.cardId || '').trim();
|
||||
const rowsEl = root.querySelector('[data-list-total-rows]');
|
||||
const statusEl = root.querySelector('[data-list-total-status]');
|
||||
const totalEl = root.querySelector('[data-list-total-total]');
|
||||
const totalLabelEl = root.querySelector('[data-list-total-total-label]');
|
||||
const leftLabelEl = root.querySelector('[data-list-total-left-label]');
|
||||
const rightLabelEl = root.querySelector('[data-list-total-right-label]');
|
||||
if (
|
||||
!(rowsEl instanceof HTMLElement) ||
|
||||
!(statusEl instanceof HTMLElement) ||
|
||||
!(totalEl instanceof HTMLElement) ||
|
||||
!(totalLabelEl instanceof HTMLElement) ||
|
||||
!(leftLabelEl instanceof HTMLElement) ||
|
||||
!(rightLabelEl instanceof HTMLElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxDigits = Math.max(
|
||||
1,
|
||||
Math.min(4, Number.isFinite(Number(state.max_digits)) ? Number(state.max_digits) : 4),
|
||||
);
|
||||
const totalSuffix = String(state.total_suffix || '').trim();
|
||||
const leftLabel = String(state.left_label || 'Value').trim() || 'Value';
|
||||
const rightLabel = String(state.right_label || 'Item').trim() || 'Item';
|
||||
const totalLabel = String(state.total_label || 'Total').trim() || 'Total';
|
||||
|
||||
leftLabelEl.textContent = leftLabel;
|
||||
rightLabelEl.textContent = rightLabel;
|
||||
totalLabelEl.textContent = totalLabel;
|
||||
|
||||
function sanitizeValue(raw) {
|
||||
return String(raw || '').replace(/\D+/g, '').slice(0, maxDigits);
|
||||
}
|
||||
|
||||
function sanitizeName(raw) {
|
||||
return String(raw || '').replace(/\s+/g, ' ').trimStart();
|
||||
}
|
||||
|
||||
function normalizeRows(raw) {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((row) => row && typeof row === 'object' && !Array.isArray(row))
|
||||
.map((row) => ({
|
||||
value: sanitizeValue(row.value),
|
||||
name: sanitizeName(row.name),
|
||||
}));
|
||||
}
|
||||
|
||||
function isBlankRow(row) {
|
||||
return !row || (!String(row.value || '').trim() && !String(row.name || '').trim());
|
||||
}
|
||||
|
||||
function ensureTrailingBlankRow(items) {
|
||||
const next = items.map((row) => ({
|
||||
value: sanitizeValue(row.value),
|
||||
name: sanitizeName(row.name),
|
||||
}));
|
||||
if (!next.length || !isBlankRow(next[next.length - 1])) {
|
||||
next.push({ value: '', name: '' });
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function persistedRows() {
|
||||
return rows
|
||||
.filter((row) => !isBlankRow(row))
|
||||
.map((row) => ({
|
||||
value: sanitizeValue(row.value),
|
||||
name: sanitizeName(row.name),
|
||||
}));
|
||||
}
|
||||
|
||||
function computeTotal() {
|
||||
return persistedRows().reduce((sum, row) => sum + (Number.parseInt(row.value, 10) || 0), 0);
|
||||
}
|
||||
|
||||
function updateTotal() {
|
||||
const total = computeTotal();
|
||||
totalEl.textContent = `${total.toLocaleString()}${totalSuffix ? totalSuffix : ''}`;
|
||||
window.__nanobotSetCardLiveContent?.(script, {
|
||||
kind: 'list_total',
|
||||
item_count: persistedRows().length,
|
||||
total,
|
||||
total_suffix: totalSuffix || null,
|
||||
score: persistedRows().length ? 24 : 16,
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
statusEl.textContent = text || '';
|
||||
statusEl.dataset.kind = kind || '';
|
||||
}
|
||||
|
||||
let rows = ensureTrailingBlankRow(normalizeRows(state.rows));
|
||||
let saveTimer = null;
|
||||
let inFlightSave = null;
|
||||
|
||||
async function persistState() {
|
||||
if (!cardId) return;
|
||||
const nextState = {
|
||||
...state,
|
||||
left_label: leftLabel,
|
||||
right_label: rightLabel,
|
||||
total_label: totalLabel,
|
||||
total_suffix: totalSuffix,
|
||||
max_digits: maxDigits,
|
||||
rows: persistedRows(),
|
||||
};
|
||||
|
||||
try {
|
||||
setStatus('Saving', 'ok');
|
||||
inFlightSave = fetch(`/cards/${encodeURIComponent(cardId)}/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template_state: nextState }),
|
||||
});
|
||||
const response = await inFlightSave;
|
||||
if (!response.ok) {
|
||||
let message = `save failed (${response.status})`;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload && typeof payload.error === 'string' && payload.error) {
|
||||
message = payload.error;
|
||||
}
|
||||
} catch (_) {}
|
||||
throw new Error(message);
|
||||
}
|
||||
setStatus('', '');
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'save failed', 'error');
|
||||
} finally {
|
||||
inFlightSave = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePersist() {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = window.setTimeout(() => {
|
||||
void persistState();
|
||||
}, 280);
|
||||
}
|
||||
|
||||
function pruneRows() {
|
||||
rows = ensureTrailingBlankRow(
|
||||
rows.filter((row, index) => !isBlankRow(row) || index === rows.length - 1),
|
||||
);
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
rowsEl.innerHTML = '';
|
||||
rows.forEach((row, index) => {
|
||||
const rowEl = document.createElement('div');
|
||||
rowEl.className = 'list-total-card__row';
|
||||
|
||||
const valueInput = document.createElement('input');
|
||||
valueInput.className = 'list-total-card__input list-total-card__value';
|
||||
valueInput.type = 'text';
|
||||
valueInput.inputMode = 'numeric';
|
||||
valueInput.maxLength = maxDigits;
|
||||
valueInput.placeholder = '0';
|
||||
valueInput.value = row.value;
|
||||
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.className = 'list-total-card__input list-total-card__name';
|
||||
nameInput.type = 'text';
|
||||
nameInput.placeholder = 'Item';
|
||||
nameInput.value = row.name;
|
||||
|
||||
valueInput.addEventListener('input', () => {
|
||||
rows[index].value = sanitizeValue(valueInput.value);
|
||||
valueInput.value = rows[index].value;
|
||||
if (index === rows.length - 1 && !isBlankRow(rows[index])) {
|
||||
rows = ensureTrailingBlankRow(rows);
|
||||
renderRows();
|
||||
schedulePersist();
|
||||
return;
|
||||
}
|
||||
updateTotal();
|
||||
schedulePersist();
|
||||
});
|
||||
|
||||
nameInput.addEventListener('input', () => {
|
||||
rows[index].name = sanitizeName(nameInput.value);
|
||||
if (index === rows.length - 1 && !isBlankRow(rows[index])) {
|
||||
rows = ensureTrailingBlankRow(rows);
|
||||
renderRows();
|
||||
schedulePersist();
|
||||
return;
|
||||
}
|
||||
updateTotal();
|
||||
schedulePersist();
|
||||
});
|
||||
|
||||
const handleBlur = () => {
|
||||
rows[index].value = sanitizeValue(valueInput.value);
|
||||
rows[index].name = sanitizeName(nameInput.value);
|
||||
const nextRows = ensureTrailingBlankRow(
|
||||
rows.filter((candidate, candidateIndex) => !isBlankRow(candidate) || candidateIndex === rows.length - 1),
|
||||
);
|
||||
const changed = JSON.stringify(nextRows) !== JSON.stringify(rows);
|
||||
rows = nextRows;
|
||||
if (changed) {
|
||||
renderRows();
|
||||
} else {
|
||||
updateTotal();
|
||||
}
|
||||
schedulePersist();
|
||||
};
|
||||
|
||||
valueInput.addEventListener('blur', handleBlur);
|
||||
nameInput.addEventListener('blur', handleBlur);
|
||||
|
||||
rowEl.append(valueInput, nameInput);
|
||||
rowsEl.appendChild(rowEl);
|
||||
});
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
window.__nanobotSetCardRefresh?.(script, () => {
|
||||
pruneRows();
|
||||
renderRows();
|
||||
});
|
||||
|
||||
renderRows();
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue