Enterprise UI redesign — Phases 14–22 (Docusign-branded migration console) #1
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -3,77 +3,164 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Adobe Sign → DocuSign Migrator</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<title>docusign — Template Migration Console</title>
|
||||
<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>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>Adobe Sign → DocuSign Migrator</h1>
|
||||
<div id="auth-bar">
|
||||
<span id="badge-adobe" class="auth-badge">Connect Adobe Sign</span>
|
||||
<span id="badge-docusign" class="auth-badge">Connect DocuSign</span>
|
||||
</div>
|
||||
</header>
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
LEFT NAVIGATION
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<nav id="app-nav">
|
||||
|
||||
<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 -->
|
||||
<div class="action-bar">
|
||||
<button id="btn-migrate" disabled>Migrate Selected</button>
|
||||
<button id="btn-refresh">↻ Refresh</button>
|
||||
<span id="status-msg">Loading…</span>
|
||||
<!-- Project switcher button -->
|
||||
<button id="nav-project-switcher" aria-label="Switch project">
|
||||
<div class="project-icon" id="nav-project-icon">?</div>
|
||||
<div class="project-name no-project" id="nav-project-name">New Project</div>
|
||||
<div class="project-arrow">⇅</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Side-by-side panels -->
|
||||
<div class="panel-row">
|
||||
<!-- Nav links -->
|
||||
<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 & Warnings</span>
|
||||
<span class="nav-badge" id="nav-badge-issues" data-count="0">0</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span>Adobe Sign Templates</span>
|
||||
<span style="font-weight:400;font-size:12px;color:#888">Select to migrate →</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ul class="template-list" id="adobe-list">
|
||||
<li class="empty-msg">Loading…</li>
|
||||
</ul>
|
||||
</div>
|
||||
<li class="nav-section-label">Post-Migration</li>
|
||||
<li>
|
||||
<a class="nav-item" data-route="#/verify" href="#/verify">
|
||||
<span class="nav-icon">✓</span>
|
||||
<span class="nav-label">Verification</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="nav-item" data-route="#/history" href="#/history">
|
||||
<span class="nav-icon">◷</span>
|
||||
<span class="nav-label">History & 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>
|
||||
|
||||
<!-- 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 class="panel">
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
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>
|
||||
</header>
|
||||
|
||||
</div>
|
||||
<!-- Router outlet — views injected here -->
|
||||
<main id="router-outlet">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">⏳</div>
|
||||
<div class="empty-state-title">Loading…</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Migration history -->
|
||||
<div class="history-section">
|
||||
<div class="panel-header">Migration History</div>
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<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>
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
},
|
||||
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) : '—';
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
Loading…
Reference in New Issue