feat: init inkl. docker configs
This commit is contained in:
334
src/pages/editor/[id].astro
Normal file
334
src/pages/editor/[id].astro
Normal file
@@ -0,0 +1,334 @@
|
||||
---
|
||||
import { getCVById, getProfileById } from '../../lib/db';
|
||||
import { getLang, useT } from '../../lib/i18n';
|
||||
import { TEMPLATES } from '../../types';
|
||||
import Template1 from '../../components/templates/Template1.astro';
|
||||
import Template2 from '../../components/templates/Template2.astro';
|
||||
import Template3 from '../../components/templates/Template3.astro';
|
||||
import Template4 from '../../components/templates/Template4.astro';
|
||||
|
||||
const { id } = Astro.params;
|
||||
const user = Astro.locals.user;
|
||||
const lang = getLang(Astro.request);
|
||||
const t = useT(lang);
|
||||
|
||||
const cv = getCVById(id!, user.id);
|
||||
if (!cv) return Astro.redirect('/dashboard');
|
||||
|
||||
const profile = getProfileById(cv.profile_id, user.id);
|
||||
if (!profile) return Astro.redirect('/dashboard');
|
||||
|
||||
const d = profile.data;
|
||||
const s = cv.settings;
|
||||
const de = lang === 'de';
|
||||
|
||||
function j(v: any) { return JSON.stringify(v).replace(/</g, '\\u003c'); }
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{cv.title} – {t('app.name')}</title>
|
||||
<link rel="stylesheet" href="/styles/global.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Lora:ital,wght@0,400;0,600;1,400&family=Merriweather:wght@400;700&family=Montserrat:wght@300;400;600;700&family=Outfit:wght@300;400;600&family=Oswald:wght@400;600&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/dashboard" class="logo" style="font-size:.95rem">Lebenslauf<span>App</span></a>
|
||||
<span style="color:rgba(255,255,255,.3)">›</span>
|
||||
<span id="cv-title-display" style="color:rgba(255,255,255,.9);font-size:.9rem">{cv.title}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href={`/profile/${profile.id}`} class="btn btn-ghost btn-sm" style="color:#fff">{de ? '✏️ Daten bearbeiten' : '✏️ Edit Data'}</a>
|
||||
<span id="save-status" class="save-status hidden">✓ {t('editor.save')}</span>
|
||||
<button id="toggle-preview" class="btn btn-ghost btn-sm" style="display:none">
|
||||
{de ? '👁 Vorschau' : '👁 Preview'}
|
||||
</button>
|
||||
<div style="position:relative">
|
||||
<button id="export-btn" class="btn btn-accent btn-sm">↓ {t('editor.export')}</button>
|
||||
<div id="export-menu" class="hidden" style="position:absolute;right:0;top:calc(100% + 4px);background:white;border:1px solid #ddd;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15);min-width:160px;z-index:50">
|
||||
<button onclick="exportPDF()" style="width:100%;text-align:left;padding:10px 16px;border:none;background:none;font-size:.875rem;cursor:pointer">📄 PDF</button>
|
||||
<button onclick="exportHTML()" style="width:100%;text-align:left;padding:10px 16px;border:none;background:none;font-size:.875rem;cursor:pointer">🌐 HTML</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="editor-layout" id="editor-layout">
|
||||
<!-- SIDEBAR -->
|
||||
<div class="editor-sidebar">
|
||||
<div class="editor-tabs" id="editor-tabs">
|
||||
<button class="editor-tab active" data-tab="settings">{t('editor.tab.settings')}</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-content" id="editor-content">
|
||||
<!-- SETTINGS -->
|
||||
<div class="tab-pane" id="tab-settings">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{t('settings.template')}</label>
|
||||
<div class="template-grid" id="template-grid">
|
||||
{TEMPLATES.map(tpl => (
|
||||
<div class={`template-option ${cv.template === tpl.id ? 'selected' : ''}`} data-id={tpl.id} onclick="selectTemplate(this)">
|
||||
<div class="template-thumb"><span style="color:white;font-size:1.3rem">T{tpl.id}</span></div>
|
||||
<div class="template-label"><strong>{tpl.name}</strong><br/><span style="font-size:.7rem;color:#888">{tpl.desc}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<hr class="divider" />
|
||||
<div class="form-group">
|
||||
<label class="form-label">{t('settings.colors')}</label>
|
||||
<div class="color-row mt-1">
|
||||
<div class="color-swatch"><input type="color" id="color-primary" value={s.primaryColor} /></div>
|
||||
<span class="text-sm">{t('settings.primaryColor')}</span>
|
||||
</div>
|
||||
<div class="color-row mt-2">
|
||||
<div class="color-swatch"><input type="color" id="color-accent" value={s.accentColor} /></div>
|
||||
<span class="text-sm">{t('settings.accentColor')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{de ? 'Text Schriftart' : 'Text Font'}</label>
|
||||
<select id="font-family" class="form-input form-select">
|
||||
{[
|
||||
["'Inter', sans-serif", 'Inter'],
|
||||
["'Roboto', sans-serif", 'Roboto'],
|
||||
["'Montserrat', sans-serif", 'Montserrat'],
|
||||
["'Outfit', sans-serif", 'Outfit'],
|
||||
["'Lora', serif", 'Lora'],
|
||||
['Arial, sans-serif', 'Arial'],
|
||||
].map(([val, label]) => (
|
||||
<option value={val} selected={s.fontFamily?.replace(/"/g,"'") === val || s.fontFamily === val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{de ? 'Überschriften Schriftart' : 'Heading Font'}</label>
|
||||
<select id="font-heading" class="form-input form-select">
|
||||
{[
|
||||
["'Inter', sans-serif", 'Inter'],
|
||||
["'Montserrat', sans-serif", 'Montserrat'],
|
||||
["'Playfair Display', serif", 'Playfair Display'],
|
||||
["'Oswald', sans-serif", 'Oswald'],
|
||||
["'Merriweather', serif", 'Merriweather'],
|
||||
['Arial, sans-serif', 'Arial'],
|
||||
].map(([val, label]) => (
|
||||
<option value={val} selected={(s.fontHeading || s.fontFamily)?.replace(/"/g,"'") === val || (s.fontHeading || s.fontFamily) === val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{t('settings.language')}</label>
|
||||
<select id="cv-lang" class="form-input form-select">
|
||||
<option value="de" selected={s.language === 'de'}>Deutsch</option>
|
||||
<option value="en" selected={s.language === 'en'}>English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group flex items-center gap-2" style="flex-direction:row;align-items:center">
|
||||
<input type="checkbox" id="show-photo" checked={s.showPhoto} style="width:16px;height:16px" />
|
||||
<label for="show-photo" class="form-label" style="margin:0">{t('settings.showPhoto')}</label>
|
||||
</div>
|
||||
<hr class="divider" />
|
||||
<div class="form-group">
|
||||
<div class="flex items-center gap-2" style="flex-direction:row;align-items:center;margin-bottom:8px">
|
||||
<input type="checkbox" id="cv-public" checked={cv.public} style="width:16px;height:16px" />
|
||||
<label for="cv-public" class="form-label" style="margin:0">{t('settings.public')}</label>
|
||||
</div>
|
||||
{cv.public && (
|
||||
<div class="flex gap-2">
|
||||
<input type="text" class="form-input" id="public-link" value={`${Astro.url.origin}/cv/${cv.hash}`} readonly style="font-size:.75rem" />
|
||||
<button class="btn btn-ghost btn-sm" onclick="copyLink()">{t('settings.copy')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PREVIEW -->
|
||||
<div class="editor-preview">
|
||||
<div class="editor-preview-toolbar">
|
||||
<span style="font-size:.8rem;color:var(--muted)">{de ? 'Vorschau (A4)' : 'Preview (A4)'}</span>
|
||||
<div class="flex gap-2">
|
||||
<span id="preview-save-status" class="save-status hidden text-xs">✓</span>
|
||||
<button onclick="exportPDF()" class="btn btn-accent btn-sm">↓ PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-wrap" id="preview-wrap">
|
||||
<div class="preview-scale-wrap" id="preview-scale">
|
||||
<div id="cv-preview">
|
||||
{cv.template === 1 && <Template1 data={d} settings={s} />}
|
||||
{cv.template === 2 && <Template2 data={d} settings={s} />}
|
||||
{cv.template === 3 && <Template3 data={d} settings={s} />}
|
||||
{cv.template === 4 && <Template4 data={d} settings={s} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{
|
||||
cvId: cv.id,
|
||||
cvHash: cv.hash,
|
||||
cvTitle: cv.title,
|
||||
profileId: profile.id,
|
||||
initData: j(profile.data),
|
||||
initSettings: j(cv.settings),
|
||||
initTemplate: cv.template,
|
||||
isPublic: cv.public,
|
||||
de,
|
||||
origin: Astro.url.origin,
|
||||
}}>
|
||||
// ── State ────────────────────────────────────────────────────────
|
||||
let cvSettings = JSON.parse(initSettings);
|
||||
let cvData = JSON.parse(initData);
|
||||
let template = initTemplate;
|
||||
let saveTimer = null;
|
||||
let isDirty = false;
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────
|
||||
document.getElementById('color-primary').addEventListener('input', function() { cvSettings.primaryColor = this.value; markDirty(); });
|
||||
document.getElementById('color-accent').addEventListener('input', function() { cvSettings.accentColor = this.value; markDirty(); });
|
||||
document.getElementById('font-family').addEventListener('change', function() { cvSettings.fontFamily = this.value; markDirty(); });
|
||||
document.getElementById('font-heading').addEventListener('change', function() { cvSettings.fontHeading = this.value; markDirty(); });
|
||||
document.getElementById('cv-lang').addEventListener('change', function() { cvSettings.language = this.value; markDirty(); });
|
||||
document.getElementById('show-photo').addEventListener('change', function() { cvSettings.showPhoto = this.checked; markDirty(); });
|
||||
document.getElementById('cv-public').addEventListener('change', async function() {
|
||||
await saveNow({ public: this.checked });
|
||||
const linkRow = document.getElementById('public-link');
|
||||
if (this.checked) {
|
||||
if (!linkRow) {
|
||||
document.getElementById('cv-public').parentElement.insertAdjacentHTML('afterend',
|
||||
`<div class="flex gap-2"><input type="text" class="form-input" id="public-link" value="${origin}/cv/${cvHash}" readonly style="font-size:.75rem"/><button class="btn btn-ghost btn-sm" onclick="copyLink()">${de ? 'Kopieren' : 'Copy'}</button></div>`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.selectTemplate = function(el) {
|
||||
document.querySelectorAll('.template-option').forEach(e => e.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
template = parseInt(el.dataset.id);
|
||||
markDirty();
|
||||
};
|
||||
|
||||
window.copyLink = function() {
|
||||
const link = document.getElementById('public-link');
|
||||
if (link) { navigator.clipboard.writeText(link.value); }
|
||||
};
|
||||
|
||||
// ── Save & Preview ───────────────────────────────────────────────
|
||||
function markDirty() {
|
||||
isDirty = true;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveNow, 2000);
|
||||
const s = document.getElementById('save-status');
|
||||
s.className = 'save-status saving';
|
||||
s.textContent = de ? '⏳ Speichert...' : '⏳ Saving...';
|
||||
s.classList.remove('hidden');
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
async function saveNow(extra = {}) {
|
||||
const body = {
|
||||
title: cvTitle,
|
||||
template,
|
||||
settings: cvSettings,
|
||||
...extra
|
||||
};
|
||||
const res = await fetch(`/api/cv/${cvId}/update`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
isDirty = false;
|
||||
const s = document.getElementById('save-status');
|
||||
s.className = 'save-status';
|
||||
s.textContent = '✓ ' + (de ? 'Gespeichert' : 'Saved');
|
||||
setTimeout(() => s.classList.add('hidden'), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPreview() {
|
||||
const preview = document.getElementById('cv-preview');
|
||||
// Using a new parameter format: profile_id
|
||||
const res = await fetch(`/api/cv/${cvId}/preview`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template, settings: cvSettings, profile_id: profileId })
|
||||
});
|
||||
if (res.ok) {
|
||||
preview.innerHTML = await res.text();
|
||||
}
|
||||
}
|
||||
|
||||
// Scale preview
|
||||
function scalePreview() {
|
||||
const wrap = document.getElementById('preview-wrap');
|
||||
const scale = document.getElementById('preview-scale');
|
||||
if (!wrap || !scale) return;
|
||||
const available = wrap.clientWidth - 48;
|
||||
const factor = Math.min(1, available / 794);
|
||||
scale.style.transform = `scale(${factor})`;
|
||||
scale.style.transformOrigin = 'top center';
|
||||
scale.style.marginBottom = `${(1 - factor) * -1123}px`;
|
||||
}
|
||||
window.addEventListener('resize', scalePreview);
|
||||
scalePreview();
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────
|
||||
document.getElementById('export-btn').addEventListener('click', () => {
|
||||
document.getElementById('export-menu').classList.toggle('hidden');
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('#export-btn') && !e.target.closest('#export-menu')) {
|
||||
document.getElementById('export-menu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
window.exportPDF = function() {
|
||||
const el = document.getElementById('cv-preview');
|
||||
const opt = {
|
||||
margin: 0,
|
||||
filename: (cvTitle || 'lebenslauf') + '.pdf',
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||
};
|
||||
window.html2pdf().set(opt).from(el).save();
|
||||
};
|
||||
|
||||
window.exportHTML = function() {
|
||||
const el = document.getElementById('cv-preview');
|
||||
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${cvTitle}</title><style>${getAllStyles()}</style></head><body style="margin:0;background:#E8ECF0;display:flex;justify-content:center;padding:32px">${el.outerHTML}</body></html>`;
|
||||
download(html, cvTitle + '.html', 'text/html');
|
||||
};
|
||||
|
||||
function getAllStyles() {
|
||||
return Array.from(document.styleSheets).map(ss => {
|
||||
try { return Array.from(ss.cssRules).map(r => r.cssText).join('\n'); } catch { return ''; }
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function download(content, filename, mime) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([content], { type: mime }));
|
||||
a.download = filename; a.click();
|
||||
}
|
||||
|
||||
// ── Mobile toggle ─────────────────────────────────────────────────
|
||||
if (window.innerWidth <= 900) {
|
||||
document.getElementById('toggle-preview').style.display = 'inline-flex';
|
||||
}
|
||||
document.getElementById('toggle-preview')?.addEventListener('click', () => {
|
||||
document.getElementById('editor-layout').classList.toggle('show-preview');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user