feat: init inkl. docker configs

This commit is contained in:
betalabor.de
2026-04-24 18:43:42 +02:00
commit c9ef44423c
37 changed files with 10538 additions and 0 deletions

334
src/pages/editor/[id].astro Normal file
View 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>