Edit textarea now mirrors composer behavior: - Enter alone → save the edit - Shift+Enter → insert newline - Escape → cancel edit
277 lines
10 KiB
JavaScript
277 lines
10 KiB
JavaScript
/**
|
||
* static/js/instructions.js
|
||
* Renders the pending and consumed instruction panels.
|
||
* Handles add, edit, delete interactions.
|
||
*/
|
||
|
||
import { state } from './state.js';
|
||
import { api } from './api.js';
|
||
import { toast } from './app.js';
|
||
|
||
// ── SVG icon helpers ──────────────────────────────────────────────────────
|
||
|
||
function iconEdit() {
|
||
return `<svg class="icon" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`;
|
||
}
|
||
|
||
function iconDelete() {
|
||
return `<svg class="icon" viewBox="0 0 24 24"><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>`;
|
||
}
|
||
|
||
function iconCheck() {
|
||
return `<svg class="icon" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||
}
|
||
|
||
function iconX() {
|
||
return `<svg class="icon" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||
}
|
||
|
||
// ── Time formatters ───────────────────────────────────────────────────────
|
||
|
||
function fmtRelativeTime(isoStr) {
|
||
if (!isoStr) return '–';
|
||
const d = new Date(isoStr);
|
||
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
|
||
if (diff < 5) return 'just now';
|
||
if (diff < 60) return `${diff}s ago`;
|
||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||
return d.toLocaleDateString();
|
||
}
|
||
|
||
function fmtAbsTime(isoStr) {
|
||
if (!isoStr) return '–';
|
||
const d = new Date(isoStr);
|
||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
}
|
||
|
||
/** Refresh all relative-time spans in the lists (called by app.js on a timer). */
|
||
export function refreshTimestamps() {
|
||
document.querySelectorAll('[data-ts]').forEach(el => {
|
||
el.textContent = fmtRelativeTime(el.dataset.ts);
|
||
});
|
||
}
|
||
|
||
// ── Card renderer ─────────────────────────────────────────────────────────
|
||
|
||
function renderPendingCard(item, index) {
|
||
const card = document.createElement('div');
|
||
card.className = 'instruction-card';
|
||
card.dataset.id = item.id;
|
||
card.style.animationDelay = `${index * 30}ms`;
|
||
|
||
card.innerHTML = `
|
||
<div class="instruction-card__meta">
|
||
<span class="instruction-card__pos">#${item.position}</span>
|
||
<span class="instruction-card__time" data-ts="${item.created_at}">${fmtRelativeTime(item.created_at)}</span>
|
||
</div>
|
||
<div class="instruction-card__content">${escapeHtml(item.content)}</div>
|
||
<div class="instruction-card__actions">
|
||
<button class="btn btn--ghost btn--icon btn--edit" title="Edit instruction" aria-label="Edit">${iconEdit()}</button>
|
||
<button class="btn btn--danger btn--icon btn--delete" title="Delete instruction" aria-label="Delete">${iconDelete()}</button>
|
||
</div>
|
||
<div class="instruction-card__edit-area" style="display:none; grid-column:1/-1;">
|
||
<textarea class="textarea edit-textarea" rows="3">${escapeHtml(item.content)}</textarea>
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||
<button class="btn btn--primary btn--sm btn--save" title="Save">${iconCheck()}</button>
|
||
<button class="btn btn--ghost btn--sm btn--cancel" title="Cancel">${iconX()}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const editBtn = card.querySelector('.btn--edit');
|
||
const deleteBtn = card.querySelector('.btn--delete');
|
||
const cancelBtn = card.querySelector('.btn--cancel');
|
||
const saveBtn = card.querySelector('.btn--save');
|
||
const editArea = card.querySelector('.instruction-card__edit-area');
|
||
const content = card.querySelector('.instruction-card__content');
|
||
const actions = card.querySelector('.instruction-card__actions');
|
||
const editTA = card.querySelector('.edit-textarea');
|
||
|
||
function showEdit() {
|
||
editTA.value = item.content;
|
||
editArea.style.display = 'flex';
|
||
content.style.display = 'none';
|
||
actions.style.display = 'none';
|
||
editTA.focus();
|
||
editTA.setSelectionRange(editTA.value.length, editTA.value.length);
|
||
}
|
||
|
||
function hideEdit() {
|
||
editArea.style.display = 'none';
|
||
content.style.display = '';
|
||
actions.style.display = '';
|
||
}
|
||
|
||
editBtn.addEventListener('click', showEdit);
|
||
cancelBtn.addEventListener('click', hideEdit);
|
||
|
||
editTA.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') { e.preventDefault(); hideEdit(); }
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveBtn.click(); }
|
||
// Shift+Enter → default browser behaviour (newline)
|
||
});
|
||
|
||
saveBtn.addEventListener('click', async () => {
|
||
const newContent = editTA.value.trim();
|
||
if (!newContent) { toast('Content cannot be empty', 'error'); return; }
|
||
saveBtn.disabled = true;
|
||
try {
|
||
await api.updateInstruction(item.id, newContent);
|
||
toast('Instruction updated', 'success');
|
||
} catch (e) {
|
||
toast(e.message, 'error');
|
||
} finally {
|
||
saveBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
deleteBtn.addEventListener('click', async () => {
|
||
if (!confirm('Delete this instruction?')) return;
|
||
deleteBtn.disabled = true;
|
||
try {
|
||
await api.deleteInstruction(item.id);
|
||
toast('Instruction deleted', 'info');
|
||
} catch (e) {
|
||
toast(e.message, 'error');
|
||
deleteBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
return card;
|
||
}
|
||
|
||
function renderConsumedCard(item) {
|
||
const card = document.createElement('div');
|
||
card.className = 'instruction-card instruction-card--consumed';
|
||
card.dataset.id = item.id;
|
||
|
||
card.innerHTML = `
|
||
<div class="instruction-card__meta">
|
||
<span class="instruction-card__pos">#${item.position}</span>
|
||
<span class="instruction-card__time">${fmtAbsTime(item.consumed_at)}</span>
|
||
${item.consumed_by_agent_id
|
||
? `<span class="instruction-card__consumed-by">→ ${escapeHtml(item.consumed_by_agent_id)}</span>`
|
||
: ''}
|
||
</div>
|
||
<div class="instruction-card__content">${escapeHtml(item.content)}</div>
|
||
<div></div>
|
||
`;
|
||
return card;
|
||
}
|
||
|
||
// ── List renderers ────────────────────────────────────────────────────────
|
||
|
||
export function initInstructions() {
|
||
const pendingList = document.getElementById('pending-list');
|
||
const pendingBadge = document.getElementById('pending-badge');
|
||
const consumedList = document.getElementById('consumed-list');
|
||
const consumedBadge = document.getElementById('consumed-badge');
|
||
const clearHistoryBtn = document.getElementById('clear-history-btn');
|
||
|
||
function render(instructions) {
|
||
if (!instructions) return;
|
||
const pending = instructions.filter(i => i.status === 'pending');
|
||
const consumed = instructions.filter(i => i.status === 'consumed').reverse();
|
||
|
||
// Pending
|
||
pendingList.innerHTML = '';
|
||
if (pending.length === 0) {
|
||
pendingList.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-state__icon">◈</div>
|
||
Queue is empty – add an instruction above
|
||
</div>`;
|
||
} else {
|
||
pending.forEach((item, i) => pendingList.appendChild(renderPendingCard(item, i)));
|
||
}
|
||
pendingBadge.textContent = pending.length;
|
||
pendingBadge.className = `badge ${pending.length > 0 ? 'badge--cyan' : 'badge--muted'}`;
|
||
|
||
// Consumed
|
||
consumedList.innerHTML = '';
|
||
if (consumed.length === 0) {
|
||
consumedList.innerHTML = `<div class="empty-state"><div class="empty-state__icon">◈</div>No consumed instructions yet</div>`;
|
||
clearHistoryBtn.style.display = 'none';
|
||
} else {
|
||
consumed.forEach(item => consumedList.appendChild(renderConsumedCard(item)));
|
||
clearHistoryBtn.style.display = '';
|
||
}
|
||
consumedBadge.textContent = consumed.length;
|
||
consumedBadge.className = `badge ${consumed.length > 0 ? 'badge--amber' : 'badge--muted'}`;
|
||
}
|
||
|
||
state.subscribe('instructions', render);
|
||
|
||
// Clear history button
|
||
clearHistoryBtn.addEventListener('click', async () => {
|
||
if (!confirm('Clear all consumed instruction history? This cannot be undone.')) return;
|
||
clearHistoryBtn.disabled = true;
|
||
try {
|
||
const res = await api.clearConsumed();
|
||
toast(`Cleared ${res.cleared} consumed instruction${res.cleared !== 1 ? 's' : ''}`, 'info');
|
||
} catch (e) {
|
||
toast(e.message, 'error');
|
||
} finally {
|
||
clearHistoryBtn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Composer ──────────────────────────────────────────────────────────────
|
||
|
||
export function initComposer() {
|
||
const form = document.getElementById('composer-form');
|
||
const textarea = document.getElementById('composer-input');
|
||
const btn = document.getElementById('composer-submit');
|
||
|
||
textarea.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
// Plain Enter → submit (like a chat box)
|
||
e.preventDefault();
|
||
form.requestSubmit();
|
||
}
|
||
// Shift+Enter → default browser behaviour (newline)
|
||
});
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const content = textarea.value.trim();
|
||
if (!content) return;
|
||
|
||
btn.disabled = true;
|
||
const original = btn.innerHTML;
|
||
btn.innerHTML = '<span class="spinner"></span>';
|
||
|
||
try {
|
||
await api.createInstruction(content);
|
||
textarea.value = '';
|
||
textarea.style.height = '';
|
||
toast('Instruction queued', 'success');
|
||
} catch (err) {
|
||
toast(err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = original;
|
||
textarea.focus();
|
||
}
|
||
});
|
||
|
||
// Auto-resize textarea
|
||
textarea.addEventListener('input', () => {
|
||
textarea.style.height = 'auto';
|
||
textarea.style.height = textarea.scrollHeight + 'px';
|
||
});
|
||
}
|
||
|
||
// ── Utility ───────────────────────────────────────────────────────────────
|
||
|
||
function escapeHtml(str) {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|