feat(ui-phase-14): app shell — Docusign nav, router, state, brand tokens

Replace monolithic app.js/style.css with a modular CSS+JS architecture:

CSS: tokens.css (Docusign 2024 brand tokens), base.css (reset, typography,
buttons, badges, cards, toggles), nav.css (Inkwell sidebar, topbar, auth
chips), cards.css (readiness badges, filter bar, bulk toolbar, issue/result
rows), modals.css (modal shell, options panel, project switcher), tables.css
(sortable headers, pagination, checksum display), forms.css (inputs, setting
rows, connection info).

JS: utils.js (escHtml, formatDate, downloadCsv, uuid), state.js (global
reactive state with pub/sub), api.js (fetch wrappers for all endpoints),
router.js (hash-based SPA router), auth.js (connect/disconnect chips,
Adobe OAuth dialog, toast notifications), app.js (entry point — wires
router, auth, nav badges, project display).

index.html: full app shell with official docusign SVG logo, 7 nav links,
top bar with auth chips, router outlet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-21 11:24:06 -04:00
parent 89382537b1
commit 516af313a1
16 changed files with 1978 additions and 586 deletions

View File

@ -1,343 +0,0 @@
// Adobe Sign → DocuSign Migrator — frontend app
// Vanilla JS, no build step.
const $ = id => document.getElementById(id);
let statusTemplates = []; // [{adobe_id, name, status, docusign_id, ...}]
let dsTemplates = []; // [{id, name, lastModified}]
let authState = { adobe: false, docusign: false };
// ── Init ────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
await refreshAuth();
await refreshTemplates();
await refreshHistory();
$('btn-migrate').addEventListener('click', onMigrate);
$('btn-refresh').addEventListener('click', async () => {
await refreshTemplates();
await refreshHistory();
});
});
// ── Auth ─────────────────────────────────────────────────────────────────────
async function refreshAuth() {
const resp = await fetch('/api/auth/status');
authState = await resp.json();
renderAuthBar();
}
function renderAuthBar() {
// Adobe: use .env credentials (primary), OAuth dialog (secondary)
const adobeEl = $('badge-adobe');
adobeEl.textContent = authState.adobe ? '✓ Adobe Sign' : 'Connect Adobe Sign';
adobeEl.className = 'auth-badge' + (authState.adobe ? ' connected' : '');
adobeEl.onclick = authState.adobe
? () => disconnectPlatform('adobe')
: () => connectAdobeEnv();
// DocuSign: JWT grant from .env — no browser sign-in needed
const dsEl = $('badge-docusign');
dsEl.textContent = authState.docusign ? '✓ DocuSign' : 'Connect DocuSign';
dsEl.className = 'auth-badge' + (authState.docusign ? ' connected' : '');
dsEl.onclick = authState.docusign
? () => disconnectPlatform('docusign')
: () => connectDocusign();
}
async function disconnectPlatform(platform) {
await fetch(`/api/auth/${platform}/disconnect`);
authState[platform] = false;
renderAuthBar();
await refreshTemplates();
}
async function connectAdobeEnv() {
const el = $('badge-adobe');
el.textContent = 'Connecting…';
const resp = await fetch('/api/auth/adobe/connect');
const data = await resp.json();
if (data.connected) {
authState.adobe = true;
renderAuthBar();
await refreshTemplates();
} else {
el.textContent = 'Connect Adobe Sign';
// If .env has no credentials, fall back to the OAuth dialog
if (data.error && data.error.includes('No Adobe Sign credentials')) {
startAdobeAuth();
} else {
setStatus('Adobe Sign error: ' + (data.error || 'unknown'));
}
}
}
async function connectDocusign() {
const dsEl = $('badge-docusign');
dsEl.textContent = 'Connecting…';
const resp = await fetch('/api/auth/docusign/connect');
const data = await resp.json();
if (data.connected) {
authState.docusign = true;
renderAuthBar();
await refreshTemplates();
} else {
dsEl.textContent = 'Connect DocuSign';
setStatus('DocuSign error: ' + (data.error || 'unknown'));
}
}
// Adobe Sign uses the same manual-paste flow as the CLI:
// 1. Open auth URL in new tab
// 2. User authorizes → lands on failed https://localhost:8080/callback page
// 3. User copies that URL, pastes it into the dialog here
// 4. We POST it to /api/auth/adobe/exchange
async function startAdobeAuth() {
const resp = await fetch('/api/auth/adobe/url');
const { url } = await resp.json();
showAdobeDialog(url);
}
function showAdobeDialog(authUrl) {
// Remove any existing dialog
const existing = $('adobe-auth-dialog');
if (existing) existing.remove();
const dialog = document.createElement('div');
dialog.id = 'adobe-auth-dialog';
dialog.innerHTML = `
<div class="dialog-backdrop"></div>
<div class="dialog-box">
<h2>Connect Adobe Sign</h2>
<ol>
<li><a href="${escHtml(authUrl)}" target="_blank" rel="noopener" id="adobe-auth-link">Click here to authorize in Adobe Sign</a></li>
<li>After authorizing, your browser will show a page that fails to load that's expected.</li>
<li>Copy the full URL from the address bar and paste it below.</li>
</ol>
<input type="text" id="adobe-redirect-input" placeholder="https://localhost:8080/callback?code=…" />
<div class="dialog-error" id="dialog-error"></div>
<div class="dialog-actions">
<button id="btn-submit-code">Connect</button>
<button id="btn-cancel-dialog" class="btn-secondary">Cancel</button>
</div>
</div>
`;
document.body.appendChild(dialog);
$('btn-cancel-dialog').onclick = () => dialog.remove();
$('btn-submit-code').onclick = () => submitAdobeCode(dialog);
// Also handle Enter key
$('adobe-redirect-input').addEventListener('keydown', e => {
if (e.key === 'Enter') submitAdobeCode(dialog);
});
}
async function submitAdobeCode(dialog) {
const url = $('adobe-redirect-input').value.trim();
if (!url) return;
$('btn-submit-code').disabled = true;
$('btn-submit-code').textContent = 'Connecting…';
$('dialog-error').textContent = '';
try {
const resp = await fetch('/api/auth/adobe/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ redirect_url: url }),
});
const data = await resp.json();
if (!resp.ok || data.error) {
$('dialog-error').textContent = data.error || 'Connection failed.';
$('btn-submit-code').disabled = false;
$('btn-submit-code').textContent = 'Connect';
return;
}
dialog.remove();
authState.adobe = true;
renderAuthBar();
await refreshTemplates();
} catch (e) {
$('dialog-error').textContent = 'Error: ' + e.message;
$('btn-submit-code').disabled = false;
$('btn-submit-code').textContent = 'Connect';
}
}
// ── Templates ────────────────────────────────────────────────────────────────
async function refreshTemplates() {
renderAdobeList([]);
renderDsList([]);
if (!authState.adobe || !authState.docusign) {
setStatus(authState.adobe || authState.docusign
? 'Connect both platforms to see migration status.'
: 'Connect Adobe Sign and DocuSign to get started.');
$('btn-migrate').disabled = true;
return;
}
setStatus('Loading templates…');
try {
const [statusResp, dsResp] = await Promise.all([
fetch('/api/templates/status'),
fetch('/api/templates/docusign'),
]);
statusTemplates = (await statusResp.json()).templates || [];
dsTemplates = (await dsResp.json()).templates || [];
renderAdobeList(statusTemplates);
renderDsList(dsTemplates);
setStatus(`${statusTemplates.length} Adobe template(s) loaded.`);
} catch (e) {
setStatus('Error loading templates: ' + e.message);
}
}
function renderAdobeList(items) {
const ul = $('adobe-list');
if (!items.length) {
ul.innerHTML = '<li class="empty-msg">No templates found.</li>';
return;
}
ul.innerHTML = items.map(t => `
<li class="template-item" data-id="${t.adobe_id}">
<input type="checkbox" data-id="${t.adobe_id}" />
<span class="template-name">${escHtml(t.name)}</span>
<span class="badge badge-${t.status}">${statusLabel(t.status)}</span>
<span class="template-spinner" id="spin-${t.adobe_id}"></span>
</li>
`).join('');
ul.querySelectorAll('.template-item').forEach(li => {
li.addEventListener('click', e => {
if (e.target.type === 'checkbox') return;
const cb = li.querySelector('input[type=checkbox]');
cb.checked = !cb.checked;
li.classList.toggle('selected', cb.checked);
updateMigrateButton();
});
li.querySelector('input').addEventListener('change', () => {
li.classList.toggle('selected', li.querySelector('input').checked);
updateMigrateButton();
});
});
}
function renderDsList(items) {
const ul = $('ds-list');
if (!items.length) {
ul.innerHTML = '<li class="empty-msg">No templates found.</li>';
return;
}
ul.innerHTML = items.map(t => `
<li class="template-item">
<span class="template-name">${escHtml(t.name)}</span>
<span style="font-size:11px;color:#999">${(t.lastModified || '').slice(0, 10)}</span>
</li>
`).join('');
}
function updateMigrateButton() {
const checked = document.querySelectorAll('#adobe-list input[type=checkbox]:checked');
$('btn-migrate').disabled = checked.length === 0;
}
// ── Migration ─────────────────────────────────────────────────────────────────
async function onMigrate() {
const checked = [...document.querySelectorAll('#adobe-list input[type=checkbox]:checked')];
const ids = checked.map(cb => cb.dataset.id);
if (!ids.length) return;
$('btn-migrate').disabled = true;
setStatus(`Migrating ${ids.length} template(s)…`);
ids.forEach(id => {
const spin = $('spin-' + id);
if (spin) spin.textContent = '⏳';
});
try {
const resp = await fetch('/api/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ adobe_template_ids: ids }),
});
const data = await resp.json();
let successCount = 0;
(data.results || []).forEach(r => {
const spin = $('spin-' + r.adobe_template_id);
if (r.status === 'success') {
successCount++;
if (spin) spin.textContent = r.action === 'updated' ? '✏️' : '✅';
} else {
if (spin) spin.textContent = '❌';
}
});
setStatus(`Done: ${successCount}/${ids.length} succeeded.`);
await refreshTemplates();
await refreshHistory();
} catch (e) {
setStatus('Migration error: ' + e.message);
}
}
// ── History ───────────────────────────────────────────────────────────────────
async function refreshHistory() {
try {
const resp = await fetch('/api/migrate/history');
const { history } = await resp.json();
renderHistory(history || []);
} catch {
renderHistory([]);
}
}
function renderHistory(records) {
const tbody = $('history-tbody');
if (!records.length) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-msg">No migrations yet.</td></tr>';
return;
}
tbody.innerHTML = [...records].reverse().slice(0, 50).map(r => `
<tr>
<td>${(r.timestamp || '').replace('T', ' ').slice(0, 19)}</td>
<td>${escHtml(r.adobe_template_name || r.adobe_template_id || '')}</td>
<td>${escHtml(r.docusign_template_id || '—')}</td>
<td>${escHtml(r.action || '—')}</td>
<td>
<span class="badge ${r.status === 'success' ? 'badge-migrated' : 'badge-not_migrated'}">
${r.status}
</span>
</td>
</tr>
`).join('');
}
// ── Utilities ─────────────────────────────────────────────────────────────────
function setStatus(msg) { $('status-msg').textContent = msg; }
function statusLabel(s) {
return { not_migrated: 'Not Migrated', migrated: 'Migrated', needs_update: 'Needs Update' }[s] || s;
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

279
web/static/css/base.css Normal file
View File

@ -0,0 +1,279 @@
/* Base reset, typography, and utility classes */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
font-family: var(--font);
font-size: var(--font-size-base);
color: var(--text);
background: var(--page-bg);
-webkit-font-smoothing: antialiased;
}
body {
display: flex;
height: 100vh;
overflow: hidden;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
font-family: var(--font);
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s;
user-select: none;
white-space: nowrap;
line-height: 1;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--cobalt); color: #fff; }
.btn-primary:not(:disabled):hover { background: var(--cobalt-hover); }
.btn-secondary { background: var(--card-bg); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:hover { background: var(--ecru); }
.btn-ghost { background: transparent; color: var(--cobalt); padding: 6px 10px; }
.btn-ghost:hover { background: var(--cobalt-light); }
.btn-danger { background: var(--poppy); color: #fff; }
.btn-danger:hover { background: #e04040; }
.btn-sm { padding: 5px 10px; font-size: var(--font-size-sm); }
.btn-xs { padding: 3px 8px; font-size: var(--font-size-xs); }
.btn-icon { padding: 6px; border-radius: var(--radius-sm); }
/* ── Badges ── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: var(--font-size-xs);
font-weight: 600;
white-space: nowrap;
}
.badge-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.badge-green { background: var(--success-bg); color: var(--success); }
.badge-amber { background: var(--warning-bg); color: var(--warning); }
.badge-red { background: var(--error-bg); color: var(--error); }
.badge-blue { background: var(--cobalt-light); color: var(--cobalt); }
.badge-gray { background: #EDF0F4; color: var(--slate); }
/* ── Cards ── */
.card {
background: var(--card-bg);
border-radius: var(--radius-md);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-md);
}
.card-header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title { font-size: var(--font-size-md); font-weight: 700; }
.card-body { padding: var(--space-md) 20px; }
/* ── Tables ── */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
padding: 10px 14px;
font-size: var(--font-size-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
background: #FAFBFC;
white-space: nowrap;
}
td {
padding: 11px 14px;
border-bottom: 1px solid var(--border);
font-size: var(--font-size-base);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: #FAFBFC; }
/* ── Page layout ── */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: var(--font-size-xl); font-weight: 700; color: var(--text); }
.page-subtitle { font-size: var(--font-size-base); color: var(--text-muted); margin-top: 2px; }
.page-actions { display: flex; gap: var(--space-sm); align-items: center; }
/* ── Callouts ── */
.callout {
padding: 12px 16px;
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
margin-bottom: var(--space-md);
display: flex;
gap: 10px;
align-items: flex-start;
}
.callout-icon { font-size: 16px; flex-shrink: 0; }
.callout.info { background: var(--cobalt-light); border: 1px solid #B3D4FF; color: #0052A3; }
.callout.warn { background: var(--warning-bg); border: 1px solid #FFD280; color: #7A3E00; }
.callout.success { background: var(--success-bg); border: 1px solid #B3E8D5; color: #006644; }
.callout.error { background: var(--error-bg); border: 1px solid #FFB3B3; color: #8B0000; }
/* ── Tabs ── */
.tabs {
display: flex;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
gap: 0;
}
.tab {
padding: 10px 18px;
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
color: var(--text-muted);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.1s;
user-select: none;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--cobalt); border-bottom-color: var(--cobalt); }
/* ── Divider ── */
.divider { height: 1px; background: var(--border); margin: var(--space-md) 0; }
/* ── Misc utilities ── */
.mono { font-family: var(--font-mono); font-size: var(--font-size-sm); background: var(--ecru); padding: 1px 6px; border-radius: 3px; }
.tag { display: inline-block; padding: 1px 7px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 600; background: var(--ecru); color: var(--text-muted); margin-right: 4px; }
.cb { width: 15px; height: 15px; accent-color: var(--cobalt); cursor: pointer; flex-shrink: 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
/* ── Empty state ── */
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
}
.empty-state-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; }
.empty-state-title { font-size: var(--font-size-lg); font-weight: 700; margin-bottom: 6px; color: var(--text); }
.empty-state-sub { font-size: var(--font-size-base); }
/* ── Spinner ── */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--cobalt);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
.spinner-sm { width: 12px; height: 12px; border-width: 1.5px; }
/* ── Progress bar ── */
.progress-wrap { margin-bottom: var(--space-lg); }
.progress-label { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: var(--font-size-sm); color: var(--text-muted); }
.progress-bar { height: 8px; border-radius: 4px; background: var(--border); overflow: hidden; }
.progress-fill { height: 100%; background: var(--cobalt); border-radius: 4px; transition: width 0.4s ease; }
.progress-fill.green { background: var(--success); }
.progress-fill.amber { background: var(--warning-amber); }
/* ── Toggle switch ── */
.toggle {
width: 36px;
height: 20px;
background: var(--border);
border-radius: 10px;
cursor: pointer;
position: relative;
flex-shrink: 0;
transition: background 0.2s;
border: none;
}
.toggle.on { background: var(--cobalt); }
.toggle::after {
content: '';
position: absolute;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
top: 3px;
left: 3px;
transition: left 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.toggle.on::after { left: 19px; }
/* ── Stat cards grid ── */
.stat-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 24px; }
.stat-card {
background: var(--card-bg);
border-radius: var(--radius-md);
padding: 16px 18px;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: box-shadow 0.15s;
}
.stat-card:hover { box-shadow: var(--shadow-md); }
.stat-label { font-size: var(--font-size-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: 800; line-height: 1; margin-bottom: 4px; }
.stat-sub { font-size: var(--font-size-xs); color: var(--text-muted); }
.stat-card.blue .stat-value { color: var(--cobalt); }
.stat-card.green .stat-value { color: var(--success); }
.stat-card.amber .stat-value { color: var(--warning); }
.stat-card.red .stat-value { color: var(--error); }
.stat-card.gray .stat-value { color: var(--slate); }
/* ── Two/three-col layouts ── */
.two-col { display: grid; grid-template-columns: 1fr 320px; gap: var(--space-md); align-items: start; }
.three-col { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: var(--space-md); }
/* ── Avatar ── */
.avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--cobalt);
color: #fff;
font-size: var(--font-size-sm);
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* ── Responsive ── */
@media (max-width: 900px) {
.stat-grid { grid-template-columns: repeat(3, 1fr); }
.two-col { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
}

228
web/static/css/cards.css Normal file
View File

@ -0,0 +1,228 @@
/* Template cards, readiness badges, filter bar, bulk toolbar */
/* ── Readiness badges (extend base .badge) ── */
.badge-ready { background: var(--success-bg); color: var(--success); }
.badge-caveats { background: var(--warning-bg); color: var(--warning); }
.badge-blocked { background: var(--error-bg); color: var(--error); }
.badge-migrated { background: var(--cobalt-light); color: var(--cobalt); }
.badge-needs-update { background: var(--warning-bg); color: var(--warning); }
.badge-verified { background: var(--success-bg); color: var(--success); }
.badge-not-migrated { background: #EDF0F4; color: var(--slate); }
.badge-dry-run { background: #EDF0F4; color: var(--slate); }
.badge-skipped { background: #EDF0F4; color: var(--slate); }
.badge-error { background: var(--error-bg); color: var(--error); }
/* ── Table name cell ── */
.table-name {
font-weight: 600;
color: var(--text);
cursor: pointer;
}
.table-name:hover { color: var(--cobalt); }
.table-sub {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-top: 2px;
}
/* ── Issue count cell ── */
.issue-count {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--font-size-sm);
font-weight: 600;
}
.issue-count.has-issues { color: var(--warning); cursor: pointer; }
.issue-count.no-issues { color: var(--success); }
.issue-count.blocked { color: var(--error); }
/* ── Filter bar ── */
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: var(--space-md);
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 200px;
max-width: 320px;
padding: 7px 12px 7px 32px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
font-family: var(--font);
background: var(--card-bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236B5F8A' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E") no-repeat 10px center;
outline: none;
color: var(--text);
}
.search-input:focus { border-color: var(--cobalt); }
.search-input::placeholder { color: var(--text-muted); }
/* ── Filter tabs ── */
.filter-tabs {
display: flex;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.filter-tab {
padding: 7px 14px;
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
color: var(--text-muted);
border-right: 1px solid var(--border);
white-space: nowrap;
transition: all 0.1s;
user-select: none;
background: transparent;
border-top: none;
border-bottom: none;
}
.filter-tab:last-child { border-right: none; }
.filter-tab:hover { background: var(--ecru); }
.filter-tab.active { background: var(--cobalt); color: #fff; border-color: var(--cobalt); }
.tab-count { font-size: 10px; margin-left: 4px; opacity: 0.8; }
/* ── Bulk action toolbar ── */
.bulk-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: var(--cobalt-light);
border: 1px solid var(--cobalt);
border-radius: var(--radius-sm);
margin-bottom: 12px;
}
.bulk-bar-text {
font-size: var(--font-size-base);
font-weight: 600;
color: var(--cobalt);
flex: 1;
}
.bulk-bar.hidden { display: none; }
/* ── Template row action buttons ── */
.row-actions { display: flex; gap: 6px; align-items: center; }
/* ── Stat progress bar (dashboard) ── */
.migration-progress-bar {
height: 6px;
border-radius: 3px;
background: var(--border);
overflow: hidden;
margin-top: 6px;
}
.migration-progress-fill {
height: 100%;
background: var(--cobalt);
border-radius: 3px;
transition: width 0.4s;
}
/* ── Attention items (issues view) ── */
.attention-list { display: flex; flex-direction: column; gap: 8px; }
.attention-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.attention-item.blocker { border-left: 3px solid var(--error); background: var(--error-bg); }
.attention-item.warning { border-left: 3px solid var(--warning-amber); background: var(--warning-bg); }
.attention-icon { font-size: 16px; flex-shrink: 0; }
.attention-name { font-weight: 600; font-size: var(--font-size-base); }
.attention-detail { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 2px; }
.attention-action { margin-left: auto; flex-shrink: 0; }
/* ── Issue rows (template detail) ── */
.issue-row {
display: flex;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
align-items: flex-start;
}
.issue-row:last-child { border-bottom: none; }
.issue-severity {
font-size: var(--font-size-xs);
font-weight: 700;
padding: 2px 8px;
border-radius: 12px;
flex-shrink: 0;
margin-top: 1px;
}
.issue-severity.blocker { background: var(--error-bg); color: var(--error); }
.issue-severity.warn { background: var(--warning-bg); color: var(--warning); }
.issue-severity.info { background: var(--cobalt-light); color: var(--cobalt); }
.issue-body { flex: 1; }
.issue-title { font-weight: 600; font-size: var(--font-size-base); }
.issue-desc { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 3px; line-height: 1.5; }
.issue-fix { font-size: var(--font-size-xs); margin-top: 6px; padding: 4px 10px; background: var(--ecru); border-radius: var(--radius-sm); color: var(--text); display: inline-block; }
/* ── Result rows (migration results view) ── */
.result-row {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: 8px;
overflow: hidden;
}
.result-header {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
cursor: pointer;
background: var(--card-bg);
transition: background 0.1s;
}
.result-header:hover { background: #FAFBFC; }
.result-icon { font-size: 16px; flex-shrink: 0; }
.result-name { font-weight: 600; flex: 1; }
.result-meta { font-size: var(--font-size-xs); color: var(--text-muted); }
.result-body { padding: 12px 16px; border-top: 1px solid var(--border); background: #FAFBFC; display: none; }
.result-row.open .result-body { display: block; }
/* ── DS template link pill ── */
.ds-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: #EAF2FF;
border: 1px solid #B3D4FF;
border-radius: var(--radius-pill);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--cobalt);
}
/* ── Activity list (dashboard) ── */
.activity-item {
display: flex;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
align-items: flex-start;
}
.activity-item:last-child { border-bottom: none; }
.activity-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 5px;
flex-shrink: 0;
}
.activity-dot.green { background: var(--success); }
.activity-dot.amber { background: var(--warning-amber); }
.activity-dot.red { background: var(--error); }
.activity-dot.blue { background: var(--cobalt); }
.activity-text { font-size: var(--font-size-base); flex: 1; }
.activity-time { font-size: var(--font-size-xs); color: var(--text-muted); flex-shrink: 0; }

107
web/static/css/forms.css Normal file
View File

@ -0,0 +1,107 @@
/* Form input styles — used in settings and modals */
.form-group {
margin-bottom: var(--space-md);
}
.form-label {
display: block;
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.form-label-sub {
font-size: var(--font-size-xs);
color: var(--text-muted);
font-weight: 400;
margin-left: 4px;
}
.form-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
font-family: var(--font);
color: var(--text);
background: var(--card-bg);
outline: none;
transition: border-color 0.15s;
}
.form-input:focus { border-color: var(--cobalt); box-shadow: 0 0 0 3px rgba(76,0,255,0.08); }
.form-input:disabled { background: var(--ecru); color: var(--text-muted); cursor: not-allowed; }
.form-input.error { border-color: var(--error); }
.form-input-mono { font-family: var(--font-mono); font-size: var(--font-size-sm); }
.form-hint {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-top: 4px;
}
.form-error {
font-size: var(--font-size-xs);
color: var(--error);
margin-top: 4px;
min-height: 16px;
}
/* ── Toggle setting row ── */
.setting-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.setting-row:last-child { border-bottom: none; }
.setting-body { flex: 1; }
.setting-label { font-weight: 600; font-size: var(--font-size-base); }
.setting-desc { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 2px; line-height: 1.5; }
.setting-control { flex-shrink: 0; }
/* ── Settings section ── */
.settings-section {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
overflow: hidden;
}
.settings-section-header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: #FAFBFC;
}
.settings-section-title {
font-size: var(--font-size-md);
font-weight: 700;
}
.settings-section-sub {
font-size: var(--font-size-sm);
color: var(--text-muted);
margin-top: 2px;
}
.settings-section-body { padding: 6px 20px; }
/* ── Connection info card ── */
.conn-info-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
font-size: var(--font-size-base);
}
.conn-info-row:last-child { border-bottom: none; }
.conn-info-label { width: 160px; color: var(--text-muted); font-size: var(--font-size-sm); flex-shrink: 0; }
.conn-info-value { flex: 1; font-family: var(--font-mono); font-size: var(--font-size-sm); }
.conn-info-status { flex-shrink: 0; }
/* ── Number input ── */
input[type="number"].form-input {
-moz-appearance: textfield;
}
input[type="number"].form-input::-webkit-outer-spin-button,
input[type="number"].form-input::-webkit-inner-spin-button {
-webkit-appearance: none;
}

181
web/static/css/modals.css Normal file
View File

@ -0,0 +1,181 @@
/* Modal and dialog styles */
/* ── Overlay ── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(19, 0, 50, 0.5);
z-index: 200;
animation: backdropIn 0.15s ease;
}
@keyframes backdropIn { from { opacity: 0; } to { opacity: 1; } }
/* ── Modal box ── */
.modal-box {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--card-bg);
border-radius: var(--radius-md);
width: min(520px, 94vw);
max-height: 90vh;
display: flex;
flex-direction: column;
z-index: 201;
box-shadow: var(--shadow-md);
animation: modalIn 0.18s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: translate(-50%, -52%); }
to { opacity: 1; transform: translate(-50%, -50%); }
}
.modal-box.modal-lg { width: min(720px, 94vw); }
.modal-box.modal-sm { width: min(380px, 94vw); }
/* ── Modal sections ── */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.modal-title {
font-size: var(--font-size-md);
font-weight: 700;
color: var(--text);
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
flex-shrink: 0;
background: var(--ecru);
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
/* ── Close button ── */
.modal-close {
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted);
font-size: 18px;
padding: 2px 6px;
border-radius: var(--radius-sm);
line-height: 1;
transition: background 0.1s;
}
.modal-close:hover { background: var(--ecru); color: var(--text); }
/* ── Options panel inside modal ── */
.options-panel {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 18px;
margin-bottom: var(--space-md);
}
.options-title {
font-weight: 700;
font-size: var(--font-size-md);
margin-bottom: 14px;
}
.option-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.option-row:last-child { border-bottom: none; }
.option-label { font-weight: 600; font-size: var(--font-size-base); }
.option-desc { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 2px; }
.option-body { flex: 1; }
/* ── Progress inside modal ── */
.migration-progress {
padding: 8px 0;
}
.progress-template-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 280px;
overflow-y: auto;
margin-top: 12px;
}
.progress-template-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--ecru);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
}
.progress-template-name { flex: 1; font-weight: 500; }
.progress-template-status { font-size: 16px; flex-shrink: 0; }
/* ── Project switcher modal ── */
.project-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
max-height: 280px;
overflow-y: auto;
}
.project-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.1s;
}
.project-row:hover { background: var(--ecru); }
.project-row.active {
border-color: var(--cobalt);
background: var(--cobalt-light);
}
.project-row-icon {
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background: var(--cobalt);
color: #fff;
font-size: var(--font-size-sm);
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.project-row-name { font-weight: 600; font-size: var(--font-size-base); flex: 1; }
.project-row-sub { font-size: var(--font-size-xs); color: var(--text-muted); }
.project-row-active-badge { font-size: var(--font-size-xs); color: var(--cobalt); font-weight: 700; }
/* ── New project form inside modal ── */
.new-project-form {
border-top: 1px solid var(--border);
padding-top: 16px;
margin-top: 8px;
}
.new-project-form h4 {
font-size: var(--font-size-base);
font-weight: 700;
margin-bottom: 10px;
}

236
web/static/css/nav.css Normal file
View File

@ -0,0 +1,236 @@
/* Left sidebar navigation and top bar */
/* ── App layout shell ── */
#app-nav {
width: var(--nav-width);
background: var(--nav-bg);
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100vh;
position: fixed;
left: 0;
top: 0;
z-index: 50;
}
#app-body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
margin-left: var(--nav-width);
height: 100vh;
}
/* ── Logo area ── */
#nav-logo {
padding: 14px 20px 12px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
#nav-logo svg { display: block; }
.nav-logo-sub {
font-size: var(--font-size-xs);
color: var(--nav-text);
font-weight: 500;
letter-spacing: 0.02em;
margin-top: 6px;
}
/* ── Project switcher ── */
#nav-project-switcher {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
padding: 8px 10px;
cursor: pointer;
transition: background 0.15s;
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
width: 100%;
}
#nav-project-switcher:hover { background: rgba(255,255,255,0.10); }
.project-icon {
width: 24px;
height: 24px;
border-radius: 4px;
background: var(--cobalt);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-weight: 800;
color: #fff;
flex-shrink: 0;
}
.project-name {
font-size: var(--font-size-sm);
font-weight: 600;
color: #fff;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-arrow {
font-size: 10px;
color: var(--nav-text);
flex-shrink: 0;
}
.project-name.no-project { color: var(--warning-amber); }
/* ── Nav sections ── */
.nav-section-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(189,201,217,0.5);
padding: 16px 20px 6px;
}
#nav-links { list-style: none; flex: 1; overflow-y: auto; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 20px;
color: var(--nav-text);
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.15s;
font-size: var(--font-size-base);
user-select: none;
text-decoration: none;
}
.nav-item:hover {
background: var(--nav-hover);
color: var(--nav-text-active);
}
.nav-item.active {
background: var(--nav-active-bg);
color: var(--nav-text-active);
border-left-color: var(--nav-active-border);
}
.nav-icon {
font-size: 16px;
width: 20px;
text-align: center;
flex-shrink: 0;
}
.nav-label { flex: 1; }
.nav-badge {
margin-left: auto;
background: var(--poppy);
color: #fff;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
min-width: 18px;
text-align: center;
}
.nav-badge.amber { background: var(--warning-amber); }
.nav-badge[data-count="0"] { display: none; }
/* ── Nav bottom (customer context) ── */
#nav-bottom {
margin-top: auto;
padding: 12px 0;
border-top: 1px solid rgba(255,255,255,0.08);
}
.nav-customer { padding: 10px 20px; }
.nav-customer-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(189,201,217,0.5);
margin-bottom: 4px;
}
.nav-customer-name { font-size: var(--font-size-sm); font-weight: 600; color: var(--nav-text-active); }
.nav-customer-sub { font-size: var(--font-size-xs); color: var(--nav-text); }
/* ── Top bar ── */
#top-bar {
height: var(--topbar-h);
background: var(--card-bg);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 var(--space-lg);
gap: var(--space-md);
flex-shrink: 0;
box-shadow: var(--shadow-sm);
z-index: 10;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--font-size-base);
color: var(--text-muted);
}
.breadcrumb .sep { color: var(--border); }
.breadcrumb .current { color: var(--text); font-weight: 600; }
#topbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.conn-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--radius-pill);
font-size: var(--font-size-sm);
font-weight: 500;
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
background: var(--card-bg);
}
.conn-pill:hover { background: var(--ecru); }
.conn-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.conn-pill.connected .conn-dot { background: var(--success); }
.conn-pill.disconnected .conn-dot { background: var(--error); }
.conn-pill.connecting .conn-dot { background: var(--warning-amber); }
/* ── Router outlet ── */
#router-outlet {
flex: 1;
overflow-y: auto;
padding: var(--space-lg);
}
/* ── View transitions ── */
.view-enter {
animation: fadeIn 0.18s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Mobile nav toggle ── */
@media (max-width: 768px) {
#app-nav {
transform: translateX(-100%);
transition: transform 0.2s;
}
#app-nav.open { transform: translateX(0); }
#app-body { margin-left: 0; }
}

82
web/static/css/tables.css Normal file
View File

@ -0,0 +1,82 @@
/* History and audit table styles */
/* ── Sortable column headers ── */
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover { background: #F0F1F5; }
th.sortable::after {
content: ' ⇅';
font-size: 10px;
opacity: 0.5;
}
th.sort-asc::after { content: ' ↑'; opacity: 1; color: var(--cobalt); }
th.sort-desc::after { content: ' ↓'; opacity: 1; color: var(--cobalt); }
/* ── Expandable row ── */
.row-expandable { cursor: pointer; }
.row-expanded-content {
background: #FAFBFC;
}
.row-expand-body {
padding: 12px 14px 14px;
font-size: var(--font-size-sm);
color: var(--text-muted);
border-top: 1px solid var(--border);
}
.expand-icon { font-size: 10px; color: var(--text-muted); transition: transform 0.15s; }
tr.open .expand-icon { transform: rotate(90deg); }
/* ── Checksum display ── */
.checksum {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
background: var(--ecru);
padding: 2px 6px;
border-radius: 3px;
cursor: help;
}
/* ── Date range filter ── */
.date-filter {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--font-size-sm);
}
.date-input {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-family: var(--font);
color: var(--text);
background: var(--card-bg);
outline: none;
}
.date-input:focus { border-color: var(--cobalt); }
/* ── Pagination ── */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-top: 1px solid var(--border);
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.pagination-pages { display: flex; gap: 4px; }
.page-btn {
padding: 4px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--card-bg);
cursor: pointer;
font-size: var(--font-size-sm);
transition: background 0.1s;
}
.page-btn:hover { background: var(--ecru); }
.page-btn.active { background: var(--cobalt); color: #fff; border-color: var(--cobalt); }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }

70
web/static/css/tokens.css Normal file
View File

@ -0,0 +1,70 @@
/* Docusign 2024 brand design tokens
Source: brand.docusign.com (April 2024 rebrand)
Inkwell #130032 replaces old navy; Cobalt #4C00FF is new primary.
*/
:root {
/* ── Brand palette ── */
--cobalt: #4C00FF;
--cobalt-hover: #3A00CC;
--cobalt-light: #EDE8FF;
--inkwell: #130032;
--deep-violet: #26065D;
--mist: #CBC2FF;
--ecru: #F8F3F0;
--poppy: #FF5252;
--poppy-bg: #FFF0F0;
--slate: #6B5F8A;
/* ── Semantic colours ── */
--success: #027A48;
--success-bg: #ECFDF3;
--warning: #92400E;
--warning-bg: #FFFBEB;
--warning-amber:#FFAB00;
--error: var(--poppy);
--error-bg: var(--poppy-bg);
/* ── Nav ── */
--nav-bg: var(--inkwell);
--nav-hover: var(--deep-violet);
--nav-active-bg: rgba(76,0,255,0.22);
--nav-active-border:var(--cobalt);
--nav-text: #A899CC;
--nav-text-active: #FFFFFF;
--nav-width: 228px;
/* ── Layout ── */
--topbar-h: 56px;
--page-bg: var(--ecru);
--card-bg: #FFFFFF;
--border: #E2DDF0;
--text: var(--inkwell);
--text-muted: var(--slate);
/* ── Shadows ── */
--shadow-sm: 0 1px 3px rgba(19,0,50,0.08), 0 1px 2px rgba(19,0,50,0.04);
--shadow-md: 0 4px 16px rgba(19,0,50,0.14);
/* ── Spacing ── */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* ── Border radius ── */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 20px;
/* ── Typography ── */
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-base:13px;
--font-size-md: 14px;
--font-size-lg: 16px;
--font-size-xl: 20px;
}

View File

@ -3,77 +3,164 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Adobe Sign → DocuSign Migrator</title> <title>docusign — Template Migration Console</title>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/css/tokens.css" />
<link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/nav.css" />
<link rel="stylesheet" href="/static/css/cards.css" />
<link rel="stylesheet" href="/static/css/modals.css" />
<link rel="stylesheet" href="/static/css/tables.css" />
<link rel="stylesheet" href="/static/css/forms.css" />
</head> </head>
<body> <body>
<header> <!-- ═══════════════════════════════════════════════════════════════
<h1>Adobe Sign → DocuSign Migrator</h1> LEFT NAVIGATION
<div id="auth-bar"> ═══════════════════════════════════════════════════════════════ -->
<span id="badge-adobe" class="auth-badge">Connect Adobe Sign</span> <nav id="app-nav">
<span id="badge-docusign" class="auth-badge">Connect DocuSign</span>
</div>
</header>
<main> <!-- Logo + project switcher -->
<div id="nav-logo">
<svg viewBox="0 0 1200 241.4" xmlns="http://www.w3.org/2000/svg"
style="width:148px;height:auto;display:block;">
<style>.st0{fill:#4C00FF;}.st1{fill:#FF5252;}</style>
<g fill="#FFFFFF">
<g>
<path d="M1169.2,109.7v78.7h-28.9v-73.5c0-17.9-7.7-27.9-22.7-27.9s-24.9,10.5-27.7,28.1c-0.8,4.2-1,10.7-1,24.4v48.8H1060v-125h25.6c0.1,1.1,0.7,12.3,0.7,13c0,0.9,1.1,1.4,1.8,0.8c10.6-8.4,22.3-16.2,38.6-16.2C1153.5,60.9,1169.2,79,1169.2,109.7z"/>
<path d="M1013.4,63.4l-0.9,14.3c-0.1,0.9-1.2,1.4-1.8,0.8c-3.5-3.3-16.4-17.5-38.3-17.5c-31.4,0-54.5,27.1-54.5,63.9l0,0c0,37.3,22.9,64.5,54.5,64.5c21.1,0,34-13.7,36.4-16.7c0.7-0.8,2-0.3,2,0.7c-0.3,3.8-0.8,13.3-4,21.4c-4,10.2-13,19.7-31.1,19.7c-14.9,0-28.1-5.7-40.6-17.9L920,217.3c13.7,15.5,35.3,24.2,58.8,24.2c37.8,0,60.5-25.9,60.5-68.2V63.4H1013.4z M978.6,163.2c-18.7,0-31.9-16.2-31.9-38.3S959.9,87,978.6,87c18.7,0,31.9,15.7,31.9,37.9C1010.4,147.1,997.2,163.2,978.6,163.2z"/>
<path d="M857.5,151.3c0,23.7-19.9,39.6-49.1,39.6c-22.9,0-43.3-8.9-55.5-21.6l0,0l0,0l9.5-22.6c9.2,8.3,24,20.2,45.1,20.2c14.7,0,23.2-6.5,23.2-14.7c0-9.5-11.7-12-25.7-14.7c-19.9-4.2-46.3-11-46.3-38.1c0-22.7,18.4-38.3,45.6-38.3c20.9,0,38.9,8,51.3,18.4l-14.2,19.9c-12-9.5-24.6-14.2-37.1-14.2s-18.7,5.2-18.7,12.7c0,10.5,13.5,13.2,23.4,15.2C833.9,117.9,857.5,125.4,857.5,151.3z"/>
<path d="M434.9,60.9c-35.3,0-60.7,27.4-60.7,65s25.4,65,60.7,65s60.8-27.4,60.8-65S470.3,60.9,434.9,60.9z M434.9,164.7c-18.7,0-31.9-15.9-31.9-38.9c0-22.9,12.9-38.9,31.9-38.9c18.9,0,31.9,15.9,31.9,38.9S453.6,164.7,434.9,164.7z"/>
<path d="M505.9,125.9c0-37.1,25.4-65,59.3-65c26.9,0,46.6,13.5,55.8,38.9l-25.6,9.7c-7-15.7-16.2-22.4-30.1-22.4c-17.4,0-30.4,16.4-30.4,38.9c0,22.4,12.9,38.9,30.4,38.9c14,0,23.1-6.7,30.1-22.4l25.6,9.7c-9.2,25.4-28.9,38.9-55.8,38.9C531.3,190.9,505.9,163,505.9,125.9z"/>
<path d="M351.4,5.3c-0.5,0-1.1,0.1-1.6,0.4l-18.8,10c-0.4,0.2-0.6,0.6-0.6,1v59.5c0,1-1.2,1.4-1.9,0.8c-2.8-2.4-9.3-8.5-18.3-12.7c-4.7-2.2-11.6-3.4-17.9-3.4c-31.6,0-54.5,27.4-54.5,65s22.9,65,54.5,65c16.6,0,29.1-8.7,36.7-16.5c0.5-0.5,0.8-0.8,1.3-1.3c0.7-0.7,1.9-0.3,1.9,0.7l1,14.6h26.1V6.1c0-0.4-0.3-0.8-0.8-0.8C358.5,5.3,351.4,5.3,351.4,5.3z M298.5,164.7c-18.9,0-31.9-15.9-31.9-38.9S279.9,87,298.5,87c18.7,0,31.9,15.9,31.9,38.9C330.4,148.8,317.5,164.7,298.5,164.7z"/>
<path d="M891.5,63.8l-18.1,9.6c-0.4,0.2-0.6,0.6-0.6,1v114h28.9V64.1c0-0.4-0.3-0.8-0.8-0.8h-7.8C892.5,63.4,892,63.5,891.5,63.8z"/>
<path d="M887.2,43.1c9.6,0,17.4-7.8,17.4-17.4s-7.8-17.4-17.4-17.4c-9.6,0-17.4,7.8-17.4,17.4S877.6,43.1,887.2,43.1z"/>
<path d="M742.5,63.3v67.9c0,51.5-28.8,59.6-54.5,59.6s-54.5-8.2-54.5-59.6V63.3h28.8v75.1c0,7.3,1.8,26.3,25.7,26.3s25.7-18.9,25.7-26.3V63.3H742.5z"/>
</g>
<g fill="#FFFFFF">
<path d="M1185.7,175.6v1.8h-4.1v10.9h-2v-10.9h-4.1v-1.8H1185.7z M1200,188.3h-2v-10l-3.9,7.5h-1.1l-3.9-7.4v9.9h-2v-12.7h2.6l3.8,7.3l3.8-7.3h2.6L1200,188.3z"/>
</g>
</g>
<path class="st0" d="M139.5,139.5V189c0,2.6-2.1,4.7-4.7,4.7H4.7c-2.6,0-4.7-2.1-4.7-4.7V59c0-2.6,2.1-4.7,4.7-4.7h49.4v80.5c0,2.6,2.1,4.7,4.7,4.7H139.5z"/>
<path class="st1" d="M193.7,69.7c0,41.6-24.3,69.7-54.2,69.8V87.1c0-1.5-0.6-3-1.7-4l-27.2-27.2c-1.1-1.1-2.5-1.7-4-1.7H54.2V4.8c0-2.6,2.1-4.7,4.7-4.7h73.3C167,0,193.7,28,193.7,69.7z"/>
<path fill="#FFFFFF" d="M137.8,83c1.1,1.1,1.7,2.5,1.7,4v52.4H58.9c-2.6,0-4.7-2.1-4.7-4.7V54.2h52.4c1.5,0,3,0.6,4,1.7L137.8,83z"/>
</svg>
<div class="nav-logo-sub">Template Migration Console</div>
<!-- Action bar --> <!-- Project switcher button -->
<div class="action-bar"> <button id="nav-project-switcher" aria-label="Switch project">
<button id="btn-migrate" disabled>Migrate Selected</button> <div class="project-icon" id="nav-project-icon">?</div>
<button id="btn-refresh">↻ Refresh</button> <div class="project-name no-project" id="nav-project-name">New Project</div>
<span id="status-msg">Loading…</span> <div class="project-arrow"></div>
</button>
</div> </div>
<!-- Side-by-side panels --> <!-- Nav links -->
<div class="panel-row"> <ul id="nav-links">
<li class="nav-section-label">Migration</li>
<li>
<a class="nav-item" data-route="#/templates" href="#/templates">
<span class="nav-icon"></span>
<span class="nav-label">Templates</span>
<span class="nav-badge amber" id="nav-badge-caveats" data-count="0">0</span>
</a>
</li>
<li>
<a class="nav-item" data-route="#/results" href="#/results">
<span class="nav-icon"></span>
<span class="nav-label">Migration Results</span>
</a>
</li>
<li>
<a class="nav-item" data-route="#/issues" href="#/issues">
<span class="nav-icon"></span>
<span class="nav-label">Issues &amp; Warnings</span>
<span class="nav-badge" id="nav-badge-issues" data-count="0">0</span>
</a>
</li>
<div class="panel"> <li class="nav-section-label">Post-Migration</li>
<div class="panel-header"> <li>
<span>Adobe Sign Templates</span> <a class="nav-item" data-route="#/verify" href="#/verify">
<span style="font-weight:400;font-size:12px;color:#888">Select to migrate →</span> <span class="nav-icon"></span>
</div> <span class="nav-label">Verification</span>
<div class="panel-body"> </a>
<ul class="template-list" id="adobe-list"> </li>
<li class="empty-msg">Loading…</li> <li>
<a class="nav-item" data-route="#/history" href="#/history">
<span class="nav-icon"></span>
<span class="nav-label">History &amp; Audit</span>
</a>
</li>
<li class="nav-section-label">Admin</li>
<li>
<a class="nav-item" data-route="#/settings" href="#/settings">
<span class="nav-icon"></span>
<span class="nav-label">Settings</span>
</a>
</li>
</ul> </ul>
<!-- Bottom: customer context -->
<div id="nav-bottom">
<div class="nav-customer">
<div class="nav-customer-label">Current Project</div>
<div class="nav-customer-name" id="nav-customer-name"></div>
<div class="nav-customer-sub" id="nav-customer-sub"></div>
</div> </div>
</div> </div>
<div class="panel"> </nav>
<div class="panel-header">
<span>DocuSign Templates</span>
</div>
<div class="panel-body">
<ul class="template-list" id="ds-list">
<li class="empty-msg">Loading…</li>
</ul>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════
MAIN CONTENT AREA
═══════════════════════════════════════════════════════════════ -->
<div id="app-body">
<!-- Top bar -->
<header id="top-bar">
<nav class="breadcrumb" aria-label="breadcrumb">
<span>Migration Console</span>
<span class="sep"></span>
<span class="current" id="breadcrumb-current">Templates</span>
</nav>
<div id="topbar-right">
<!-- Auth connection chips -->
<button id="chip-adobe" class="conn-pill disconnected" aria-label="Adobe Sign connection">
<span class="conn-dot"></span>Adobe Sign
</button>
<button id="chip-docusign" class="conn-pill disconnected" aria-label="DocuSign connection">
<span class="conn-dot"></span>DocuSign
</button>
<!-- User avatar -->
<div class="avatar" title="Logged in" aria-label="User">M</div>
</div> </div>
</header>
<!-- Migration history --> <!-- Router outlet — views injected here -->
<div class="history-section"> <main id="router-outlet">
<div class="panel-header">Migration History</div> <div class="empty-state">
<table class="history-table"> <div class="empty-state-icon"></div>
<thead> <div class="empty-state-title">Loading…</div>
<tr>
<th>Time</th>
<th>Adobe Template</th>
<th>DocuSign Template ID</th>
<th>Action</th>
<th>Status</th>
</tr>
</thead>
<tbody id="history-tbody">
<tr><td colspan="5" class="empty-msg">No migrations yet.</td></tr>
</tbody>
</table>
</div> </div>
</main>
</main> </div>
<!-- ═══════════════════════════════════════════════════════════════
MODAL OVERLAY (shared, managed by modals.js)
═══════════════════════════════════════════════════════════════ -->
<div id="modal-root"></div>
<!-- ═══════════════════════════════════════════════════════════════
TOAST CONTAINER (managed by auth.js)
═══════════════════════════════════════════════════════════════ -->
<div id="toast-container"></div>
<!-- ═══════════════════════════════════════════════════════════════
APP ENTRY POINT
═══════════════════════════════════════════════════════════════ -->
<script type="module" src="/static/js/app.js"></script>
<script src="/static/app.js"></script>
</body> </body>
</html> </html>

92
web/static/js/api.js Normal file
View File

@ -0,0 +1,92 @@
// Thin fetch wrappers for all backend endpoints
async function request(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body !== undefined) opts.body = JSON.stringify(body);
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
const msg = data.detail || data.error || `HTTP ${resp.status}`;
throw Object.assign(new Error(msg), { status: resp.status, data });
}
return data;
}
const GET = path => request('GET', path);
const POST = (path, body) => request('POST', path, body);
const PUT = (path, body) => request('PUT', path, body);
export const api = {
// ── Auth ──────────────────────────────────────────────────────────────────
auth: {
status() {
return GET('/api/auth/status');
},
connectAdobe() {
return POST('/api/auth/adobe/connect');
},
adobeUrl() {
return GET('/api/auth/adobe/url');
},
exchangeAdobe(redirectUrl) {
return POST('/api/auth/adobe/exchange', { redirect_url: redirectUrl });
},
connectDocusign() {
return POST('/api/auth/docusign/connect');
},
disconnect(platform) {
return POST(`/api/auth/${platform}/disconnect`);
},
},
// ── Templates ─────────────────────────────────────────────────────────────
templates: {
status() {
return GET('/api/templates/status');
},
adobe() {
return GET('/api/templates/adobe');
},
docusign() {
return GET('/api/templates/docusign');
},
},
// ── Migration ─────────────────────────────────────────────────────────────
migrate: {
run(body) {
return POST('/api/migrate', body);
},
batch(body) {
return POST('/api/migrate/batch', body);
},
batchStatus(jobId) {
return GET(`/api/migrate/batch/${jobId}`);
},
history() {
return GET('/api/migrate/history');
},
},
// ── Verification ──────────────────────────────────────────────────────────
verify: {
send(templateId, recipientName, recipientEmail) {
return POST('/api/verify/send', {
template_id: templateId,
recipient_name: recipientName,
recipient_email: recipientEmail,
});
},
status(envelopeId) {
return GET(`/api/verify/status/${envelopeId}`);
},
void(envelopeId, reason = 'Test envelope — voided after verification') {
return POST(`/api/verify/void/${envelopeId}`, { reason });
},
},
};

109
web/static/js/app.js Normal file
View File

@ -0,0 +1,109 @@
// Main app entry point — wires together router, auth, state, and nav badges
import * as router from './router.js';
import { refreshAuth, renderAuthChips } from './auth.js';
import { state, subscribe } from './state.js';
import { getActive, initProject } from './project.js';
// ── Route registrations (lazy-loaded views) ───────────────────────────────
router.register('#/templates', async (param) => {
const { renderTemplates, renderTemplateDetail } = await import('./templates.js');
if (param) {
await renderTemplateDetail(param);
} else {
await renderTemplates();
}
});
router.register('#/results', async () => {
const { renderResults } = await import('./migration.js');
renderResults();
});
router.register('#/issues', async () => {
const { renderIssues } = await import('./issues.js');
renderIssues();
});
router.register('#/verify', async () => {
const { renderVerification } = await import('./verification.js');
await renderVerification();
});
router.register('#/history', async () => {
const { renderHistory } = await import('./history.js');
await renderHistory();
});
router.register('#/settings', async () => {
const { renderSettings } = await import('./settings.js');
renderSettings();
});
// ── Nav badge subscriptions ───────────────────────────────────────────────
subscribe('issueCount', count => {
const badge = document.getElementById('nav-badge-issues');
if (badge) {
badge.dataset.count = count;
badge.textContent = count;
}
});
subscribe('templates', templates => {
const caveats = (templates || []).filter(t =>
(!t.blockers || t.blockers.length === 0) &&
t.warnings && t.warnings.length > 0
).length;
const badge = document.getElementById('nav-badge-caveats');
if (badge) {
badge.dataset.count = caveats;
badge.textContent = caveats;
}
});
// ── Project switcher wiring ───────────────────────────────────────────────
function syncProjectDisplay() {
const project = getActive();
const iconEl = document.getElementById('nav-project-icon');
const nameEl = document.getElementById('nav-project-name');
const custName = document.getElementById('nav-customer-name');
const custSub = document.getElementById('nav-customer-sub');
if (project) {
const initials = project.name.slice(0, 2).toUpperCase();
if (iconEl) { iconEl.textContent = initials; }
if (nameEl) { nameEl.textContent = project.name; nameEl.classList.remove('no-project'); }
if (custName) { custName.textContent = project.name; }
if (custSub) { custSub.textContent = `Created ${new Date(project.createdAt).toLocaleDateString()}`; }
} else {
if (iconEl) { iconEl.textContent = '?'; }
if (nameEl) { nameEl.textContent = 'New Project'; nameEl.classList.add('no-project'); }
if (custName) { custName.textContent = '—'; }
if (custSub) { custSub.textContent = ''; }
}
}
// ── Init ─────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
// Init project context
initProject(syncProjectDisplay);
// Wire project switcher button
const switcher = document.getElementById('nav-project-switcher');
if (switcher) {
switcher.addEventListener('click', async () => {
const { showProjectModal } = await import('./project.js');
showProjectModal(syncProjectDisplay);
});
}
// Auth chips
await refreshAuth();
// Start router
router.init();
});

208
web/static/js/auth.js Normal file
View File

@ -0,0 +1,208 @@
// Auth: connect/disconnect Adobe Sign and DocuSign, auth status chips
import { api } from './api.js';
import { state, setState } from './state.js';
import { escHtml } from './utils.js';
// ── Refresh auth state and update chips ────────────────────────────────────
export async function refreshAuth() {
try {
const data = await api.auth.status();
setState('auth', {
adobe: !!data.adobe,
docusign: !!data.docusign,
adobeLabel: data.adobe_label || 'Adobe Sign',
docusignLabel: data.docusign_label || 'DocuSign',
});
} catch (e) {
console.warn('Auth status failed:', e.message);
}
renderAuthChips();
}
// ── Render connection pills in top bar ─────────────────────────────────────
export function renderAuthChips() {
renderChip('chip-adobe', state.auth.adobe, 'Adobe Sign', onClickAdobe);
renderChip('chip-docusign', state.auth.docusign, 'DocuSign', onClickDocusign);
}
function renderChip(id, connected, label, onClick) {
const el = document.getElementById(id);
if (!el) return;
el.className = 'conn-pill ' + (connected ? 'connected' : 'disconnected');
el.innerHTML = `<span class="conn-dot"></span>${escHtml(label)}`;
el.onclick = onClick;
}
// ── Click handlers ─────────────────────────────────────────────────────────
async function onClickAdobe() {
if (state.auth.adobe) {
await disconnect('adobe');
} else {
await connectAdobeEnv();
}
}
async function onClickDocusign() {
if (state.auth.docusign) {
await disconnect('docusign');
} else {
await connectDocusign();
}
}
async function disconnect(platform) {
setChipConnecting(platform === 'adobe' ? 'chip-adobe' : 'chip-docusign');
try {
await api.auth.disconnect(platform);
setState('auth', { ...state.auth, [platform]: false });
renderAuthChips();
// Reload templates (they'll be empty without auth)
const { refreshTemplates } = await import('./templates.js');
refreshTemplates();
} catch (e) {
console.error('Disconnect failed:', e.message);
renderAuthChips();
}
}
async function connectAdobeEnv() {
setChipConnecting('chip-adobe');
try {
const data = await api.auth.connectAdobe();
if (data.connected) {
setState('auth', { ...state.auth, adobe: true });
renderAuthChips();
const { refreshTemplates } = await import('./templates.js');
refreshTemplates();
} else if (data.error && data.error.includes('No Adobe Sign credentials')) {
renderAuthChips();
showAdobeOAuthDialog();
} else {
renderAuthChips();
showToast('Adobe Sign error: ' + (data.error || 'unknown'), 'error');
}
} catch (e) {
renderAuthChips();
showAdobeOAuthDialog();
}
}
async function connectDocusign() {
setChipConnecting('chip-docusign');
try {
const data = await api.auth.connectDocusign();
if (data.connected) {
setState('auth', { ...state.auth, docusign: true });
renderAuthChips();
const { refreshTemplates } = await import('./templates.js');
refreshTemplates();
} else {
renderAuthChips();
showToast('DocuSign error: ' + (data.error || 'unknown'), 'error');
}
} catch (e) {
renderAuthChips();
showToast('DocuSign connection failed: ' + e.message, 'error');
}
}
function setChipConnecting(id) {
const el = document.getElementById(id);
if (!el) return;
el.className = 'conn-pill connecting';
el.innerHTML = `<span class="conn-dot"></span><span class="spinner spinner-sm"></span>`;
}
// ── Adobe OAuth dialog (manual redirect URL paste) ─────────────────────────
async function showAdobeOAuthDialog() {
const { url } = await api.auth.adobeUrl().catch(() => ({ url: '#' }));
const existing = document.getElementById('adobe-auth-dialog');
if (existing) existing.remove();
const dialog = document.createElement('div');
dialog.id = 'adobe-auth-dialog';
dialog.innerHTML = `
<div class="modal-backdrop"></div>
<div class="modal-box">
<div class="modal-header">
<span class="modal-title">Connect Adobe Sign</span>
<button class="btn btn-ghost btn-icon" id="adobe-dialog-close"></button>
</div>
<div class="modal-body">
<ol style="padding-left:18px;line-height:1.8;margin-bottom:14px;font-size:13px">
<li><a href="${escHtml(url)}" target="_blank" rel="noopener" style="color:var(--cobalt)">Click here to authorize in Adobe Sign </a></li>
<li>After authorizing, your browser will show a page that fails to load that's expected.</li>
<li>Copy the full URL from the address bar and paste it below.</li>
</ol>
<input type="text" id="adobe-redirect-input" class="form-input"
placeholder="https://localhost:8080/callback?code=…" />
<div id="adobe-dialog-error" style="color:var(--error);font-size:12px;min-height:18px;margin-top:6px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="adobe-dialog-cancel">Cancel</button>
<button class="btn btn-primary" id="adobe-dialog-submit">Connect</button>
</div>
</div>
`;
document.body.appendChild(dialog);
document.getElementById('adobe-dialog-close').onclick = () => dialog.remove();
document.getElementById('adobe-dialog-cancel').onclick = () => dialog.remove();
document.getElementById('adobe-dialog-submit').onclick = () => submitAdobeCode(dialog);
document.getElementById('adobe-redirect-input').addEventListener('keydown', e => {
if (e.key === 'Enter') submitAdobeCode(dialog);
});
}
async function submitAdobeCode(dialog) {
const url = document.getElementById('adobe-redirect-input').value.trim();
if (!url) return;
const submitBtn = document.getElementById('adobe-dialog-submit');
const errorEl = document.getElementById('adobe-dialog-error');
submitBtn.disabled = true;
submitBtn.textContent = 'Connecting…';
errorEl.textContent = '';
try {
const data = await api.auth.exchangeAdobe(url);
dialog.remove();
setState('auth', { ...state.auth, adobe: true });
renderAuthChips();
const { refreshTemplates } = await import('./templates.js');
refreshTemplates();
} catch (e) {
errorEl.textContent = e.data?.error || e.message || 'Connection failed.';
submitBtn.disabled = false;
submitBtn.textContent = 'Connect';
}
}
// ── Toast notification ─────────────────────────────────────────────────────
export function showToast(message, type = 'info') {
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;';
document.body.appendChild(container);
}
const toast = document.createElement('div');
const colors = { info: 'var(--cobalt-light)', error: 'var(--error-bg)', success: 'var(--success-bg)' };
const borders = { info: 'var(--cobalt)', error: 'var(--error)', success: 'var(--success)' };
toast.style.cssText = `
padding:10px 16px;border-radius:6px;font-size:13px;font-weight:500;
background:${colors[type]||colors.info};border:1px solid ${borders[type]||borders.info};
box-shadow:var(--shadow-md);max-width:360px;animation:fadeIn 0.2s ease;
`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}

102
web/static/js/router.js Normal file
View File

@ -0,0 +1,102 @@
// Hash-based client-side router
// Usage: navigate('#/templates') or window.location.hash = '#/templates'
import { escHtml } from './utils.js';
const _routes = {};
let _current = null;
// Register a route: router.register('#/templates', loadFn)
export function register(hash, loadFn) {
_routes[hash] = loadFn;
}
// Navigate to a hash route
export function navigate(hash) {
window.location.hash = hash;
}
// Navigate and pass data to the view (stored temporarily)
let _routeData = null;
export function navigateWith(hash, data) {
_routeData = data;
navigate(hash);
}
export function getRouteData() {
const d = _routeData;
_routeData = null;
return d;
}
// Parse route: '#/templates/abc123' → { base: '#/templates', param: 'abc123' }
function parseHash(hash) {
const clean = hash || '#/templates';
const parts = clean.split('/');
if (parts.length >= 3) {
return { base: parts.slice(0, 3).join('/'), param: parts[3] || null };
}
return { base: clean, param: null };
}
// Route to the current hash
async function route() {
const { base, param } = parseHash(window.location.hash);
const key = param ? base : (window.location.hash || '#/templates');
const baseKey = base;
const loader = _routes[key] || _routes[baseKey] || _routes['#/templates'];
if (!loader) return;
_current = key;
updateActiveNav(baseKey);
const outlet = document.getElementById('router-outlet');
if (outlet) outlet.classList.remove('view-enter');
try {
await loader(param);
} catch (err) {
console.error('Router error:', err);
const outlet = document.getElementById('router-outlet');
if (outlet) outlet.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon"></div>
<div class="empty-state-title">Failed to load view</div>
<div class="empty-state-sub">${escHtml(err.message)}</div>
</div>`;
}
if (outlet) {
outlet.classList.add('view-enter');
// Remove class after animation to allow re-trigger
setTimeout(() => outlet.classList.remove('view-enter'), 200);
}
}
// Highlight active nav item
function updateActiveNav(hash) {
document.querySelectorAll('.nav-item').forEach(el => {
el.classList.toggle('active', el.dataset.route === hash);
});
// Update breadcrumb
const label = document.querySelector(`.nav-item[data-route="${hash}"] .nav-label`);
const breadcrumbCurrent = document.getElementById('breadcrumb-current');
if (breadcrumbCurrent && label) {
breadcrumbCurrent.textContent = label.textContent.trim();
}
}
// Init: listen for hash changes and route on load
export function init() {
window.addEventListener('hashchange', route);
// Route immediately
if (!window.location.hash || window.location.hash === '#') {
window.location.hash = '#/templates';
} else {
route();
}
}
export function current() { return _current; }

43
web/static/js/state.js Normal file
View File

@ -0,0 +1,43 @@
// Global application state with simple pub/sub
const _listeners = {};
export const state = {
project: null, // { id, name, createdAt }
auth: {
adobe: false,
docusign: false,
adobeLabel: 'Adobe Sign',
docusignLabel: 'DocuSign',
},
templates: [], // [{ adobe_id, name, status, blockers, warnings, ... }]
selectedIds: new Set(),
lastMigrationResults: null, // final batch job results
issueCount: 0, // blocked template count (drives nav badge)
};
// Subscribe to state key changes: fn is called with (newValue, oldValue)
export function subscribe(key, fn) {
if (!_listeners[key]) _listeners[key] = [];
_listeners[key].push(fn);
}
// Publish a state change
export function publish(key, newValue) {
const old = state[key];
state[key] = newValue;
(_listeners[key] || []).forEach(fn => {
try { fn(newValue, old); } catch (e) { console.error('state listener error', e); }
});
}
// Convenience setter that publishes
export function setState(key, value) {
publish(key, value);
}
// Recompute derived values after template list updates
export function updateDerivedState() {
const blocked = state.templates.filter(t => t.blockers && t.blockers.length > 0).length;
setState('issueCount', blocked);
}

97
web/static/js/utils.js Normal file
View File

@ -0,0 +1,97 @@
// Shared utility functions
export function escHtml(str) {
return String(str ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[c]);
}
export function formatDate(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});
} catch { return iso.slice(0, 10); }
}
export function formatDateTime(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
} catch { return iso.slice(0, 19).replace('T', ' '); }
}
export function formatRelative(iso) {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return formatDate(iso);
}
export function debounce(fn, ms = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
export function uuid() {
return crypto.randomUUID
? crypto.randomUUID()
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
// Truncate a string to maxLen chars, appending ellipsis if needed
export function truncate(str, maxLen = 40) {
if (!str) return '';
return str.length > maxLen ? str.slice(0, maxLen - 1) + '…' : str;
}
// First letter of a name for avatar initials
export function initials(name) {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
return parts.length >= 2
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
: name.slice(0, 2).toUpperCase();
}
// Download a string as a file
export function downloadText(filename, content, type = 'text/plain') {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
document.body.removeChild(a); URL.revokeObjectURL(url);
}
// Convert array of objects to CSV and download
export function downloadCsv(filename, rows) {
if (!rows.length) return;
const headers = Object.keys(rows[0]);
const csv = [
headers.join(','),
...rows.map(r => headers.map(h => JSON.stringify(r[h] ?? '')).join(','))
].join('\n');
downloadText(filename, csv, 'text/csv');
}
// Shorten a SHA-256 hash for display
export function shortHash(hash, len = 8) {
return hash ? hash.slice(0, len) : '—';
}

View File

@ -1,186 +0,0 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #222;
font-size: 14px;
}
/* ── Header ── */
header {
background: #1a3c5e;
color: #fff;
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
header h1 { font-size: 18px; font-weight: 600; }
#auth-bar { display: flex; gap: 12px; align-items: center; font-size: 13px; }
.auth-badge {
padding: 4px 10px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.4);
cursor: pointer;
transition: background 0.15s;
}
.auth-badge.connected { background: #28a745; border-color: #28a745; }
.auth-badge:not(.connected):hover { background: rgba(255,255,255,0.15); }
/* ── Main layout ── */
main { padding: 20px 24px; }
.panel-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.panel {
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
overflow: hidden;
}
.panel-header {
padding: 12px 16px;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 13px;
color: #555;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel-body { padding: 0; }
/* ── Template list ── */
.template-list { list-style: none; }
.template-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s;
}
.template-item:last-child { border-bottom: none; }
.template-item:hover { background: #f9f9f9; }
.template-item.selected { background: #eef4ff; }
.template-item input[type=checkbox] { flex-shrink: 0; }
.template-name { flex: 1; font-size: 13px; }
/* ── Status badges ── */
.badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
}
.badge-migrated { background: #d4edda; color: #155724; }
.badge-needs_update { background: #fff3cd; color: #856404; }
.badge-not_migrated { background: #f8d7da; color: #721c24; }
.template-spinner { font-size: 12px; color: #888; }
/* ── Action bar ── */
.action-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
button {
padding: 8px 18px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: opacity 0.15s;
}
button:disabled { opacity: 0.45; cursor: not-allowed; }
#btn-migrate { background: #1a3c5e; color: #fff; }
#btn-migrate:not(:disabled):hover { background: #235080; }
#btn-refresh { background: #e9ecef; color: #333; }
#btn-refresh:hover { background: #dee2e6; }
#status-msg { font-size: 13px; color: #555; }
/* ── History ── */
.history-section { background: #fff; border: 1px solid #ddd; border-radius: 6px; }
.history-section .panel-header { background: #f8f9fa; }
.history-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.history-table th {
text-align: left;
padding: 8px 14px;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
font-weight: 600;
color: #555;
}
.history-table td {
padding: 8px 14px;
border-bottom: 1px solid #f0f0f0;
}
.history-table tr:last-child td { border-bottom: none; }
.empty-msg { padding: 20px; text-align: center; color: #999; font-size: 13px; }
/* ── Adobe auth dialog ── */
.dialog-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
z-index: 100;
}
.dialog-box {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: #fff;
border-radius: 8px;
padding: 28px 32px;
width: min(500px, 90vw);
z-index: 101;
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
}
.dialog-box h2 { font-size: 16px; margin-bottom: 16px; }
.dialog-box ol { padding-left: 20px; margin-bottom: 16px; line-height: 1.7; }
.dialog-box ol a { color: #1a3c5e; }
.dialog-box input[type=text] {
width: 100%;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
margin-bottom: 8px;
font-family: monospace;
}
.dialog-error { color: #c00; font-size: 12px; min-height: 18px; margin-bottom: 10px; }
.dialog-actions { display: flex; gap: 8px; justify-content: flex-end; }
.btn-secondary { background: #e9ecef; color: #333; }
/* ── Responsive ── */
@media (max-width: 700px) {
.panel-row { grid-template-columns: 1fr; }
}