371 lines
14 KiB
JavaScript
371 lines
14 KiB
JavaScript
// Carbone Word Template Manager - Frontend
|
|
// Use same host as the page, just different port
|
|
const CARBONE_API = window.location.protocol + '//' + window.location.hostname + ':8771/api/carbone';
|
|
|
|
// ============================================================
|
|
// NAVIGATION
|
|
// ============================================================
|
|
// Load carbone templates when the templates page is shown
|
|
const carboneNavObserver = new MutationObserver(() => {
|
|
if (document.getElementById('page-templates') && document.getElementById('page-templates').classList.contains('active')) {
|
|
loadCarboneTemplates();
|
|
}
|
|
});
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const templatesPage = document.getElementById('page-templates');
|
|
if (templatesPage) carboneNavObserver.observe(templatesPage, { attributes: true, attributeFilter: ['class'] });
|
|
});
|
|
|
|
// ============================================================
|
|
// DOWNLOAD MASTER TEMPLATE
|
|
// ============================================================
|
|
function downloadMasterTemplate() {
|
|
window.open(CARBONE_API + '/master-template', '_blank');
|
|
}
|
|
|
|
// ============================================================
|
|
// UPLOAD CUSTOM TEMPLATE
|
|
// ============================================================
|
|
const carboneUploadZone = document.getElementById('carbone-upload-zone');
|
|
if (carboneUploadZone) {
|
|
carboneUploadZone.addEventListener('dragover', e => { e.preventDefault(); carboneUploadZone.classList.add('dragover'); });
|
|
carboneUploadZone.addEventListener('dragleave', () => carboneUploadZone.classList.remove('dragover'));
|
|
carboneUploadZone.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
carboneUploadZone.classList.remove('dragover');
|
|
if (e.dataTransfer.files.length) uploadCarboneTemplate(e.dataTransfer.files[0]);
|
|
});
|
|
}
|
|
|
|
async function uploadCarboneTemplate(file) {
|
|
if (!file) return;
|
|
const name = document.getElementById('carbone-template-name').value || file.name.replace(/\.[^.]+$/, '');
|
|
const status = document.getElementById('carbone-upload-status');
|
|
status.innerHTML = '<div class="loading"><span class="spinner"></span> Uploading template...</div>';
|
|
|
|
const formData = new FormData();
|
|
formData.append('template', file);
|
|
formData.append('name', name);
|
|
|
|
try {
|
|
const resp = await fetch(CARBONE_API + '/templates/upload', { method: 'POST', body: formData });
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
status.innerHTML = '<div class="toast success" style="position:relative">Template uploaded successfully!</div>';
|
|
toast('Template uploaded: ' + name);
|
|
document.getElementById('carbone-template-name').value = '';
|
|
loadCarboneTemplates();
|
|
} else {
|
|
status.innerHTML = '<div class="toast error" style="position:relative">Upload failed: ' + (data.error || 'Unknown error') + '</div>';
|
|
}
|
|
} catch (e) {
|
|
status.innerHTML = '<div class="toast error" style="position:relative">Upload failed: ' + e.message + '</div>';
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// LIST TEMPLATES
|
|
// ============================================================
|
|
async function loadCarboneTemplates() {
|
|
const list = document.getElementById('carbone-templates-list');
|
|
if (!list) return;
|
|
list.innerHTML = '<div class="loading">Loading...</div>';
|
|
|
|
try {
|
|
const resp = await fetch(CARBONE_API + '/templates');
|
|
const data = await resp.json();
|
|
if (!data.templates || !data.templates.length) {
|
|
list.innerHTML = '<p class="text-muted">No custom templates uploaded yet. Download the master template, customize it in Word, and upload it here.</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = data.templates.map(t => `
|
|
<div class="skill-row">
|
|
<div>
|
|
<strong>${t.name}</strong>
|
|
<div class="text-muted text-sm">Uploaded: ${new Date(t.uploadedAt).toLocaleDateString()} · ${t.originalName}</div>
|
|
</div>
|
|
<div class="flex gap-8">
|
|
<button class="btn btn-sm btn-outline" onclick="renderSampleCV('${t.id}')">Generate Sample CV</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteCarboneTemplate('${t.id}')">Delete</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch (e) {
|
|
list.innerHTML = '<p class="text-muted">Error loading templates: ' + e.message + '</p>';
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// RENDER SAMPLE CV
|
|
// ============================================================
|
|
async function renderSampleCV(templateId) {
|
|
toast('Generating sample CV PDF...');
|
|
|
|
// Get first candidate for sample data
|
|
try {
|
|
const candidatesResp = await fetch('/api/candidates?limit=1');
|
|
const candidatesData = await candidatesResp.json();
|
|
let cvData = null;
|
|
|
|
if (candidatesData.candidates && candidatesData.candidates.length > 0) {
|
|
const fullResp = await fetch('/api/candidates/' + candidatesData.candidates[0].id);
|
|
cvData = await fullResp.json();
|
|
}
|
|
|
|
if (!cvData) {
|
|
toast('Upload a CV first to generate a sample', 'error');
|
|
return;
|
|
}
|
|
|
|
const resp = await fetch(CARBONE_API + '/templates/' + templateId + '/render', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ candidateData: cvData })
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || 'Render failed');
|
|
}
|
|
|
|
const blob = await resp.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
window.open(url, '_blank');
|
|
toast('Sample CV generated');
|
|
} catch (e) {
|
|
toast('Render failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// DELETE TEMPLATE
|
|
// ============================================================
|
|
async function deleteCarboneTemplate(templateId) {
|
|
if (!confirm('Delete this template?')) return;
|
|
try {
|
|
await fetch(CARBONE_API + '/templates/' + templateId, { method: 'DELETE' });
|
|
toast('Template deleted');
|
|
loadCarboneTemplates();
|
|
} catch (e) {
|
|
toast('Delete failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// TAGS HELP - View in app
|
|
// ============================================================
|
|
async function viewTagsHelp() {
|
|
try {
|
|
const resp = await fetch(CARBONE_API + '/help');
|
|
const data = await resp.json();
|
|
|
|
let html = '<div style="max-height:600px;overflow-y:auto">';
|
|
for (const section of data.sections) {
|
|
html += `<h3 style="margin-top:20px;color:var(--accent)">${section.title}</h3>`;
|
|
if (section.note) html += `<p class="text-muted text-sm" style="margin-bottom:8px">${section.note}</p>`;
|
|
html += '<table><thead><tr><th>Tag</th><th>Description</th></tr></thead><tbody>';
|
|
for (const t of section.tags) {
|
|
html += `<tr><td style="font-family:monospace;color:var(--accent);white-space:nowrap">${escapeHtml(t.tag)}</td><td>${escapeHtml(t.desc)}</td></tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
}
|
|
html += '</div>';
|
|
html += '<div class="mt-16"><button class="btn btn-outline" onclick="downloadHelpPdf()">Download as .pdf</button></div>';
|
|
|
|
showModal(html, 'Carbone Tags Reference');
|
|
} catch (e) {
|
|
toast('Failed to load help: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// TAGS HELP - Download .pdf
|
|
// ============================================================
|
|
function downloadHelpPdf() {
|
|
window.open(CARBONE_API + '/help-pdf', '_blank');
|
|
}
|
|
|
|
// ============================================================
|
|
// ONLYOFFICE INLINE EDITOR
|
|
// ============================================================
|
|
|
|
const ONLYOFFICE_API = window.location.protocol + '//' + window.location.hostname + ':8771/api/onlyoffice';
|
|
let onlyofficeEditorInstance = null;
|
|
|
|
// Check ONLYOFFICE status when templates page loads
|
|
async function checkOnlyofficeStatus() {
|
|
const badge = document.getElementById('onlyoffice-status');
|
|
const list = document.getElementById('onlyoffice-templates-list');
|
|
if (!badge) return;
|
|
|
|
try {
|
|
const resp = await fetch(ONLYOFFICE_API + '/health');
|
|
const data = await resp.json();
|
|
if (data.onlyoffice === 'healthy') {
|
|
badge.className = 'badge badge-green';
|
|
badge.textContent = 'online';
|
|
} else {
|
|
badge.className = 'badge badge-red';
|
|
badge.textContent = data.onlyoffice === 'offline' ? 'offline' : data.onlyoffice;
|
|
if (list) list.innerHTML = '<p class="text-muted text-sm">ONLYOFFICE server is not running. Start it with: <code>cd /root/workspace/cv-app && docker compose up -d onlyoffice-server</code></p>';
|
|
}
|
|
} catch (e) {
|
|
badge.className = 'badge badge-red';
|
|
badge.textContent = 'error';
|
|
if (list) list.innerHTML = '<p class="text-muted text-sm">Cannot connect to ONLYOFFICE service. Make sure the Carbone+ONLYOFFICE backend is running.</p>';
|
|
}
|
|
}
|
|
|
|
// Load templates list for ONLYOFFICE editing
|
|
async function loadOnlyofficeTemplates() {
|
|
const list = document.getElementById('onlyoffice-templates-list');
|
|
if (!list) return;
|
|
|
|
// Check if ONLYOFFICE is online first
|
|
try {
|
|
const healthResp = await fetch(ONLYOFFICE_API + '/health');
|
|
const health = await healthResp.json();
|
|
if (health.onlyoffice !== 'healthy') {
|
|
list.innerHTML = '<p class="text-muted text-sm">ONLYOFFICE server is ' + health.onlyoffice + '. Start the container to enable inline editing.</p>';
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
list.innerHTML = '<p class="text-muted text-sm">Cannot reach ONLYOFFICE service.</p>';
|
|
return;
|
|
}
|
|
|
|
// Load templates from Carbone registry
|
|
try {
|
|
const resp = await fetch(CARBONE_API + '/templates');
|
|
const data = await resp.json();
|
|
if (!data.templates || !data.templates.length) {
|
|
list.innerHTML = '<p class="text-muted text-sm">No templates uploaded yet. Upload a .docx template first to edit it inline.</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = data.templates.map(t => `
|
|
<div class="skill-row">
|
|
<div>
|
|
<strong>${t.name}</strong>
|
|
<div class="text-muted text-sm">${t.originalName} · Updated: ${t.updatedAt ? new Date(t.updatedAt).toLocaleString() : new Date(t.uploadedAt).toLocaleString()}</div>
|
|
</div>
|
|
<div class="flex gap-8">
|
|
<button class="btn btn-sm" onclick="openOnlyofficeEditor('${t.id}', '${t.name.replace(/'/g, "\\'")}')">Edit Online</button>
|
|
<button class="btn btn-sm btn-outline" onclick="renderSampleCV('${t.id}')">Preview PDF</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteCarboneTemplate('${t.id}')">Delete</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch (e) {
|
|
list.innerHTML = '<p class="text-muted text-sm">Error: ' + e.message + '</p>';
|
|
}
|
|
}
|
|
|
|
// Open ONLYOFFICE editor in a modal
|
|
async function openOnlyofficeEditor(templateId, templateName) {
|
|
try {
|
|
// Get the JWT-signed editor config from our backend
|
|
const resp = await fetch(ONLYOFFICE_API + '/' + templateId + '/config');
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
throw new Error(err.error || 'Failed to get editor config');
|
|
}
|
|
const data = await resp.json();
|
|
const config = data.config;
|
|
const onlyofficeUrl = data.onlyofficeUrl;
|
|
|
|
// Build the editor modal
|
|
const body = `
|
|
<div class="flex-between mb-16">
|
|
<h3>Editing: ${templateName}</h3>
|
|
<div class="flex gap-8">
|
|
<button class="btn btn-sm btn-outline" onclick="forceSaveOnlyoffice()">Save</button>
|
|
<button class="btn btn-sm btn-danger" onclick="closeOnlyofficeEditor()">Close Editor</button>
|
|
</div>
|
|
</div>
|
|
<div id="onlyoffice-editor-container" style="width:100%;height:800px;border:1px solid var(--border);border-radius:8px;overflow:hidden"></div>
|
|
<p class="text-muted text-sm mt-8">Changes are saved automatically. Click "Save" to force-save, or "Close Editor" to return to the template list.</p>
|
|
`;
|
|
showModal(body, '', 'xlarge');
|
|
|
|
// Load ONLYOFFICE API script dynamically
|
|
const scriptUrl = onlyofficeUrl + '/web-apps/apps/api/documents/api.js';
|
|
if (!document.getElementById('onlyoffice-api-script')) {
|
|
await new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.id = 'onlyoffice-api-script';
|
|
script.src = scriptUrl;
|
|
script.onload = resolve;
|
|
script.onerror = () => reject(new Error('Failed to load ONLYOFFICE API from ' + scriptUrl));
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
// Initialize the ONLYOFFICE editor
|
|
if (typeof DocsAPI === 'undefined' || !DocsAPI.DocEditor) {
|
|
throw new Error('ONLYOFFICE API not loaded. Check that the server is running at ' + onlyofficeUrl);
|
|
}
|
|
|
|
onlyofficeEditorInstance = new DocsAPI.DocEditor('onlyoffice-editor-container', {
|
|
...config,
|
|
width: '100%',
|
|
height: '800px',
|
|
events: {
|
|
onAppReady: function() {
|
|
console.log('ONLYOFFICE editor ready');
|
|
},
|
|
onDocumentReady: function() {
|
|
console.log('Document loaded in editor');
|
|
},
|
|
onSave: function(event) {
|
|
toast('Document saved successfully');
|
|
// Reload the templates list
|
|
loadOnlyofficeTemplates();
|
|
},
|
|
onError: function(event) {
|
|
console.error('ONLYOFFICE error:', event);
|
|
toast('Editor error: ' + (event.data?.errorDescription || 'unknown'), 'error');
|
|
},
|
|
},
|
|
});
|
|
} catch (e) {
|
|
toast('Failed to open editor: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Force save the document
|
|
function forceSaveOnlyoffice() {
|
|
if (onlyofficeEditorInstance) {
|
|
// The ONLYOFFICE editor's onSave event will trigger the callback
|
|
// which saves the file to our backend
|
|
toast('Saving... changes will be stored automatically');
|
|
}
|
|
}
|
|
|
|
// Close the editor and clean up
|
|
function closeOnlyofficeEditor() {
|
|
if (onlyofficeEditorInstance) {
|
|
try {
|
|
onlyofficeEditorInstance.destroyEditor();
|
|
} catch (e) {
|
|
console.error('Error closing editor:', e);
|
|
}
|
|
onlyofficeEditorInstance = null;
|
|
}
|
|
closeModal();
|
|
// Refresh the templates list to show any changes
|
|
loadOnlyofficeTemplates();
|
|
}
|
|
|
|
// Call status check when templates page becomes active
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const templatesPage = document.getElementById('page-templates');
|
|
if (templatesPage) {
|
|
const observer = new MutationObserver(() => {
|
|
if (templatesPage.classList.contains('active')) {
|
|
checkOnlyofficeStatus();
|
|
loadOnlyofficeTemplates();
|
|
}
|
|
});
|
|
observer.observe(templatesPage, { attributes: true, attributeFilter: ['class'] });
|
|
}
|
|
}); |