diff --git a/web/static/app.js b/web/static/app.js deleted file mode 100644 index a1832be..0000000 --- a/web/static/app.js +++ /dev/null @@ -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 = ` -
-
-

Connect Adobe Sign

-
    -
  1. Click here to authorize in Adobe Sign
  2. -
  3. After authorizing, your browser will show a page that fails to load — that's expected.
  4. -
  5. Copy the full URL from the address bar and paste it below.
  6. -
- -
-
- - -
-
- `; - 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 = '
  • No templates found.
  • '; - return; - } - ul.innerHTML = items.map(t => ` -
  • - - ${escHtml(t.name)} - ${statusLabel(t.status)} - -
  • - `).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 = '
  • No templates found.
  • '; - return; - } - ul.innerHTML = items.map(t => ` -
  • - ${escHtml(t.name)} - ${(t.lastModified || '').slice(0, 10)} -
  • - `).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 = 'No migrations yet.'; - return; - } - tbody.innerHTML = [...records].reverse().slice(0, 50).map(r => ` - - ${(r.timestamp || '').replace('T', ' ').slice(0, 19)} - ${escHtml(r.adobe_template_name || r.adobe_template_id || '')} - ${escHtml(r.docusign_template_id || '—')} - ${escHtml(r.action || '—')} - - - ${r.status} - - - - `).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, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} diff --git a/web/static/css/base.css b/web/static/css/base.css new file mode 100644 index 0000000..af38890 --- /dev/null +++ b/web/static/css/base.css @@ -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); } +} diff --git a/web/static/css/cards.css b/web/static/css/cards.css new file mode 100644 index 0000000..4ed1998 --- /dev/null +++ b/web/static/css/cards.css @@ -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; } diff --git a/web/static/css/forms.css b/web/static/css/forms.css new file mode 100644 index 0000000..3586581 --- /dev/null +++ b/web/static/css/forms.css @@ -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; +} diff --git a/web/static/css/modals.css b/web/static/css/modals.css new file mode 100644 index 0000000..9e52c20 --- /dev/null +++ b/web/static/css/modals.css @@ -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; +} diff --git a/web/static/css/nav.css b/web/static/css/nav.css new file mode 100644 index 0000000..1657695 --- /dev/null +++ b/web/static/css/nav.css @@ -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; } +} diff --git a/web/static/css/tables.css b/web/static/css/tables.css new file mode 100644 index 0000000..2b9a765 --- /dev/null +++ b/web/static/css/tables.css @@ -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; } diff --git a/web/static/css/tokens.css b/web/static/css/tokens.css new file mode 100644 index 0000000..48cb66b --- /dev/null +++ b/web/static/css/tokens.css @@ -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; +} diff --git a/web/static/index.html b/web/static/index.html index dbcb4c1..3538791 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -3,77 +3,164 @@ - Adobe Sign → DocuSign Migrator - + docusign — Template Migration Console + + + + + + + -
    -

    Adobe Sign → DocuSign Migrator

    -
    - Connect Adobe Sign - Connect DocuSign -
    -
    + + + + +
    + + +
    + +
    + + + + +
    M
    +
    -
    + +
    +
    +
    +
    Loading…
    +
    +
    - -
    -
    Migration History
    - - - - - - - - - - - - - -
    TimeAdobe TemplateDocuSign Template IDActionStatus
    No migrations yet.
    -
    + - + + + + +
    + + + - diff --git a/web/static/js/api.js b/web/static/js/api.js new file mode 100644 index 0000000..b34500b --- /dev/null +++ b/web/static/js/api.js @@ -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 }); + }, + }, + +}; diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..b95832e --- /dev/null +++ b/web/static/js/app.js @@ -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(); +}); diff --git a/web/static/js/auth.js b/web/static/js/auth.js new file mode 100644 index 0000000..eb368b4 --- /dev/null +++ b/web/static/js/auth.js @@ -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 = `${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 = ``; +} + +// ── 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 = ` + + + `; + 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); +} diff --git a/web/static/js/router.js b/web/static/js/router.js new file mode 100644 index 0000000..8c9773a --- /dev/null +++ b/web/static/js/router.js @@ -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 = ` +
    +
    ⚠️
    +
    Failed to load view
    +
    ${escHtml(err.message)}
    +
    `; + } + + 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; } diff --git a/web/static/js/state.js b/web/static/js/state.js new file mode 100644 index 0000000..b51134c --- /dev/null +++ b/web/static/js/state.js @@ -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); +} diff --git a/web/static/js/utils.js b/web/static/js/utils.js new file mode 100644 index 0000000..138a846 --- /dev/null +++ b/web/static/js/utils.js @@ -0,0 +1,97 @@ +// Shared utility functions + +export function escHtml(str) { + return String(str ?? '').replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + })[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) : '—'; +} diff --git a/web/static/style.css b/web/static/style.css deleted file mode 100644 index b8194e6..0000000 --- a/web/static/style.css +++ /dev/null @@ -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; } -}