feat(ui-phase-14): app shell — Docusign nav, router, state, brand tokens
Replace monolithic app.js/style.css with a modular CSS+JS architecture: CSS: tokens.css (Docusign 2024 brand tokens), base.css (reset, typography, buttons, badges, cards, toggles), nav.css (Inkwell sidebar, topbar, auth chips), cards.css (readiness badges, filter bar, bulk toolbar, issue/result rows), modals.css (modal shell, options panel, project switcher), tables.css (sortable headers, pagination, checksum display), forms.css (inputs, setting rows, connection info). JS: utils.js (escHtml, formatDate, downloadCsv, uuid), state.js (global reactive state with pub/sub), api.js (fetch wrappers for all endpoints), router.js (hash-based SPA router), auth.js (connect/disconnect chips, Adobe OAuth dialog, toast notifications), app.js (entry point — wires router, auth, nav badges, project display). index.html: full app shell with official docusign SVG logo, 7 nav links, top bar with auth chips, router outlet. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
89382537b1
commit
516af313a1
|
|
@ -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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Adobe Sign → DocuSign Migrator</title>
|
<title>docusign — Template Migration Console</title>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/css/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/base.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/nav.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/cards.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/modals.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/tables.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/forms.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header>
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
<h1>Adobe Sign → DocuSign Migrator</h1>
|
LEFT NAVIGATION
|
||||||
<div id="auth-bar">
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
<span id="badge-adobe" class="auth-badge">Connect Adobe Sign</span>
|
<nav id="app-nav">
|
||||||
<span id="badge-docusign" class="auth-badge">Connect DocuSign</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
<!-- Logo + project switcher -->
|
||||||
|
<div id="nav-logo">
|
||||||
|
<svg viewBox="0 0 1200 241.4" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="width:148px;height:auto;display:block;">
|
||||||
|
<style>.st0{fill:#4C00FF;}.st1{fill:#FF5252;}</style>
|
||||||
|
<g fill="#FFFFFF">
|
||||||
|
<g>
|
||||||
|
<path d="M1169.2,109.7v78.7h-28.9v-73.5c0-17.9-7.7-27.9-22.7-27.9s-24.9,10.5-27.7,28.1c-0.8,4.2-1,10.7-1,24.4v48.8H1060v-125h25.6c0.1,1.1,0.7,12.3,0.7,13c0,0.9,1.1,1.4,1.8,0.8c10.6-8.4,22.3-16.2,38.6-16.2C1153.5,60.9,1169.2,79,1169.2,109.7z"/>
|
||||||
|
<path d="M1013.4,63.4l-0.9,14.3c-0.1,0.9-1.2,1.4-1.8,0.8c-3.5-3.3-16.4-17.5-38.3-17.5c-31.4,0-54.5,27.1-54.5,63.9l0,0c0,37.3,22.9,64.5,54.5,64.5c21.1,0,34-13.7,36.4-16.7c0.7-0.8,2-0.3,2,0.7c-0.3,3.8-0.8,13.3-4,21.4c-4,10.2-13,19.7-31.1,19.7c-14.9,0-28.1-5.7-40.6-17.9L920,217.3c13.7,15.5,35.3,24.2,58.8,24.2c37.8,0,60.5-25.9,60.5-68.2V63.4H1013.4z M978.6,163.2c-18.7,0-31.9-16.2-31.9-38.3S959.9,87,978.6,87c18.7,0,31.9,15.7,31.9,37.9C1010.4,147.1,997.2,163.2,978.6,163.2z"/>
|
||||||
|
<path d="M857.5,151.3c0,23.7-19.9,39.6-49.1,39.6c-22.9,0-43.3-8.9-55.5-21.6l0,0l0,0l9.5-22.6c9.2,8.3,24,20.2,45.1,20.2c14.7,0,23.2-6.5,23.2-14.7c0-9.5-11.7-12-25.7-14.7c-19.9-4.2-46.3-11-46.3-38.1c0-22.7,18.4-38.3,45.6-38.3c20.9,0,38.9,8,51.3,18.4l-14.2,19.9c-12-9.5-24.6-14.2-37.1-14.2s-18.7,5.2-18.7,12.7c0,10.5,13.5,13.2,23.4,15.2C833.9,117.9,857.5,125.4,857.5,151.3z"/>
|
||||||
|
<path d="M434.9,60.9c-35.3,0-60.7,27.4-60.7,65s25.4,65,60.7,65s60.8-27.4,60.8-65S470.3,60.9,434.9,60.9z M434.9,164.7c-18.7,0-31.9-15.9-31.9-38.9c0-22.9,12.9-38.9,31.9-38.9c18.9,0,31.9,15.9,31.9,38.9S453.6,164.7,434.9,164.7z"/>
|
||||||
|
<path d="M505.9,125.9c0-37.1,25.4-65,59.3-65c26.9,0,46.6,13.5,55.8,38.9l-25.6,9.7c-7-15.7-16.2-22.4-30.1-22.4c-17.4,0-30.4,16.4-30.4,38.9c0,22.4,12.9,38.9,30.4,38.9c14,0,23.1-6.7,30.1-22.4l25.6,9.7c-9.2,25.4-28.9,38.9-55.8,38.9C531.3,190.9,505.9,163,505.9,125.9z"/>
|
||||||
|
<path d="M351.4,5.3c-0.5,0-1.1,0.1-1.6,0.4l-18.8,10c-0.4,0.2-0.6,0.6-0.6,1v59.5c0,1-1.2,1.4-1.9,0.8c-2.8-2.4-9.3-8.5-18.3-12.7c-4.7-2.2-11.6-3.4-17.9-3.4c-31.6,0-54.5,27.4-54.5,65s22.9,65,54.5,65c16.6,0,29.1-8.7,36.7-16.5c0.5-0.5,0.8-0.8,1.3-1.3c0.7-0.7,1.9-0.3,1.9,0.7l1,14.6h26.1V6.1c0-0.4-0.3-0.8-0.8-0.8C358.5,5.3,351.4,5.3,351.4,5.3z M298.5,164.7c-18.9,0-31.9-15.9-31.9-38.9S279.9,87,298.5,87c18.7,0,31.9,15.9,31.9,38.9C330.4,148.8,317.5,164.7,298.5,164.7z"/>
|
||||||
|
<path d="M891.5,63.8l-18.1,9.6c-0.4,0.2-0.6,0.6-0.6,1v114h28.9V64.1c0-0.4-0.3-0.8-0.8-0.8h-7.8C892.5,63.4,892,63.5,891.5,63.8z"/>
|
||||||
|
<path d="M887.2,43.1c9.6,0,17.4-7.8,17.4-17.4s-7.8-17.4-17.4-17.4c-9.6,0-17.4,7.8-17.4,17.4S877.6,43.1,887.2,43.1z"/>
|
||||||
|
<path d="M742.5,63.3v67.9c0,51.5-28.8,59.6-54.5,59.6s-54.5-8.2-54.5-59.6V63.3h28.8v75.1c0,7.3,1.8,26.3,25.7,26.3s25.7-18.9,25.7-26.3V63.3H742.5z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#FFFFFF">
|
||||||
|
<path d="M1185.7,175.6v1.8h-4.1v10.9h-2v-10.9h-4.1v-1.8H1185.7z M1200,188.3h-2v-10l-3.9,7.5h-1.1l-3.9-7.4v9.9h-2v-12.7h2.6l3.8,7.3l3.8-7.3h2.6L1200,188.3z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path class="st0" d="M139.5,139.5V189c0,2.6-2.1,4.7-4.7,4.7H4.7c-2.6,0-4.7-2.1-4.7-4.7V59c0-2.6,2.1-4.7,4.7-4.7h49.4v80.5c0,2.6,2.1,4.7,4.7,4.7H139.5z"/>
|
||||||
|
<path class="st1" d="M193.7,69.7c0,41.6-24.3,69.7-54.2,69.8V87.1c0-1.5-0.6-3-1.7-4l-27.2-27.2c-1.1-1.1-2.5-1.7-4-1.7H54.2V4.8c0-2.6,2.1-4.7,4.7-4.7h73.3C167,0,193.7,28,193.7,69.7z"/>
|
||||||
|
<path fill="#FFFFFF" d="M137.8,83c1.1,1.1,1.7,2.5,1.7,4v52.4H58.9c-2.6,0-4.7-2.1-4.7-4.7V54.2h52.4c1.5,0,3,0.6,4,1.7L137.8,83z"/>
|
||||||
|
</svg>
|
||||||
|
<div class="nav-logo-sub">Template Migration Console</div>
|
||||||
|
|
||||||
<!-- Action bar -->
|
<!-- Project switcher button -->
|
||||||
<div class="action-bar">
|
<button id="nav-project-switcher" aria-label="Switch project">
|
||||||
<button id="btn-migrate" disabled>Migrate Selected</button>
|
<div class="project-icon" id="nav-project-icon">?</div>
|
||||||
<button id="btn-refresh">↻ Refresh</button>
|
<div class="project-name no-project" id="nav-project-name">New Project</div>
|
||||||
<span id="status-msg">Loading…</span>
|
<div class="project-arrow">⇅</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Side-by-side panels -->
|
<!-- Nav links -->
|
||||||
<div class="panel-row">
|
<ul id="nav-links">
|
||||||
|
<li class="nav-section-label">Migration</li>
|
||||||
|
<li>
|
||||||
|
<a class="nav-item" data-route="#/templates" href="#/templates">
|
||||||
|
<span class="nav-icon">☰</span>
|
||||||
|
<span class="nav-label">Templates</span>
|
||||||
|
<span class="nav-badge amber" id="nav-badge-caveats" data-count="0">0</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="nav-item" data-route="#/results" href="#/results">
|
||||||
|
<span class="nav-icon">⬡</span>
|
||||||
|
<span class="nav-label">Migration Results</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="nav-item" data-route="#/issues" href="#/issues">
|
||||||
|
<span class="nav-icon">⚠</span>
|
||||||
|
<span class="nav-label">Issues & Warnings</span>
|
||||||
|
<span class="nav-badge" id="nav-badge-issues" data-count="0">0</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<div class="panel">
|
<li class="nav-section-label">Post-Migration</li>
|
||||||
<div class="panel-header">
|
<li>
|
||||||
<span>Adobe Sign Templates</span>
|
<a class="nav-item" data-route="#/verify" href="#/verify">
|
||||||
<span style="font-weight:400;font-size:12px;color:#888">Select to migrate →</span>
|
<span class="nav-icon">✓</span>
|
||||||
</div>
|
<span class="nav-label">Verification</span>
|
||||||
<div class="panel-body">
|
</a>
|
||||||
<ul class="template-list" id="adobe-list">
|
</li>
|
||||||
<li class="empty-msg">Loading…</li>
|
<li>
|
||||||
</ul>
|
<a class="nav-item" data-route="#/history" href="#/history">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div class="panel">
|
</nav>
|
||||||
<div class="panel-header">
|
|
||||||
<span>DocuSign Templates</span>
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
</div>
|
MAIN CONTENT AREA
|
||||||
<div class="panel-body">
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
<ul class="template-list" id="ds-list">
|
<div id="app-body">
|
||||||
<li class="empty-msg">Loading…</li>
|
|
||||||
</ul>
|
<!-- Top bar -->
|
||||||
</div>
|
<header id="top-bar">
|
||||||
|
<nav class="breadcrumb" aria-label="breadcrumb">
|
||||||
|
<span>Migration Console</span>
|
||||||
|
<span class="sep">›</span>
|
||||||
|
<span class="current" id="breadcrumb-current">Templates</span>
|
||||||
|
</nav>
|
||||||
|
<div id="topbar-right">
|
||||||
|
<!-- Auth connection chips -->
|
||||||
|
<button id="chip-adobe" class="conn-pill disconnected" aria-label="Adobe Sign connection">
|
||||||
|
<span class="conn-dot"></span>Adobe Sign
|
||||||
|
</button>
|
||||||
|
<button id="chip-docusign" class="conn-pill disconnected" aria-label="DocuSign connection">
|
||||||
|
<span class="conn-dot"></span>DocuSign
|
||||||
|
</button>
|
||||||
|
<!-- User avatar -->
|
||||||
|
<div class="avatar" title="Logged in" aria-label="User">M</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
</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>
|
||||||
<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>
|
|
||||||
|
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</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