This commit is contained in:
betalabor.de
2026-04-27 19:29:23 +02:00
parent 50f02268d8
commit aa4057fd9d
13 changed files with 4195 additions and 733 deletions

View File

@@ -1,11 +1,11 @@
---
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';
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;
@@ -13,322 +13,629 @@ const lang = getLang(Astro.request);
const t = useT(lang);
const cv = getCVById(id!, user.id);
if (!cv) return Astro.redirect('/dashboard');
if (!cv) return Astro.redirect("/dashboard");
const profile = getProfileById(cv.profile_id, user.id);
if (!profile) return Astro.redirect('/dashboard');
if (!profile) return Astro.redirect("/dashboard");
const d = profile.data;
const s = cv.settings;
const de = lang === 'de';
const de = lang === "de";
function j(v: any) { return JSON.stringify(v).replace(/</g, '\\u003c'); }
function j(v: any) {
return JSON.stringify(v).replace(/</g, "\\u003c");
}
---
<!DOCTYPE html>
<!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" />
<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"
/>
</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:200px;z-index:50"
>
<div
style="padding:8px 16px 4px;font-size:.7rem;color:#999;font-weight:600;text-transform:uppercase;letter-spacing:.5px"
>
PDF
</div>
<button
onclick="exportPDFPrint()"
style="width:100%;text-align:left;padding:8px 16px;border:none;background:none;font-size:.875rem;cursor:pointer;display:flex;align-items:center;gap:8px"
>
🖨️ <span>
<strong>{de ? "Browser-Druck" : "Browser Print"}</strong><br />
<span style="font-size:.75rem;color:#888"
>{
de
? "Empfohlen, Icons korrekt"
: "Recommended, icons correct"
}</span
>
</span>
</button>
<button
id="btn-puppeteer"
onclick="exportPDFCanvas()"
style="width:100%;text-align:left;padding:8px 16px;border:none;background:none;font-size:.875rem;cursor:pointer;display:flex;align-items:center;gap:8px"
>
📄 <span>
<strong>{de ? "PDF Download" : "PDF Download"}</strong><br />
<span style="font-size:.75rem;color:#888"
>{
de
? "Serverseitig, beste Qualität"
: "Server-side, best quality"
}</span
>
</span>
</button>
<div style="border-top:1px solid #eee;margin:4px 0"></div>
<button
onclick="exportHTML()"
style="width:100%;text-align:left;padding:8px 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">
<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="exportPDFPrint()" class="btn btn-ghost btn-sm"
>🖨️ {de ? "Drucken" : "Print"}</button
>
<button onclick="exportPDFCanvas()" 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,
}}
>
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 });
if (this.checked && !document.getElementById("public-link")) {
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 res = await fetch(`/api/cv/${cvId}/update`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: cvTitle,
template,
settings: cvSettings,
...extra,
}),
});
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 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) {
document.getElementById("cv-preview").innerHTML = await res.text();
}
}
function scalePreview() {
const wrap = document.getElementById("preview-wrap");
const scale = document.getElementById("preview-scale");
if (!wrap || !scale) return;
const factor = Math.min(1, (wrap.clientWidth - 48) / 794);
scale.style.transform = `scale(${factor})`;
scale.style.transformOrigin = "top center";
scale.style.marginBottom = `${(1 - factor) * -1123}px`;
}
window.addEventListener("resize", scalePreview);
scalePreview();
// ── Export Menu ───────────────────────────────────────────────
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");
}
});
// ── printCV (inline, kein externer Script nötig) ─────────────
async function printCV(el, title, settings, tpl) {
let globalCss = "";
try {
globalCss = await fetch("/styles/global.css").then((r) => r.text());
} catch (e) {}
const inlineStyles = Array.from(document.querySelectorAll("style"))
.map((s) => s.textContent)
.join("\n");
const fontUrl =
"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";
const SIDEBAR_WIDTH = { 1: "220px", 2: "0", 3: "160px", 4: "240px" };
const sw = SIDEBAR_WIDTH[tpl] || "0";
const color = (settings && settings.primaryColor) || "#1B2A5E";
const sidebarCss =
sw !== "0"
? `
body {
background: linear-gradient(to right, ${color} ${sw}, white ${sw}) !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.t1-sidebar, .t3-left, .t4-left { background: transparent !important; }
`
: "";
const html = `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<title>${title || "Lebenslauf"}</title>
<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>
<link href="${fontUrl}" rel="stylesheet">
<style>
${globalCss}
${inlineStyles}
@page { size: A4 portrait; margin: 0; }
html { margin: 0; padding: 0; }
body { margin: 0 !important; padding: 0 !important; display: flex; justify-content: center; background: white; }
${sidebarCss}
.cv-a4 { width: 210mm !important; min-height: 297mm !important; box-shadow: none !important; margin: 0 !important; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
.t1-entry, .t1-sec, .t2-tl-item, .t2-sec, .t3-entry, .t3-sec, .t4-entry, .t4-sec { break-inside: avoid; }
</style>
</head><body>${el.innerHTML}</body></html>`;
<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>`);
const blob = new Blob([html], { type: "text/html; charset=utf-8" });
const blobUrl = URL.createObjectURL(blob);
const win = window.open(blobUrl, "_blank", "width=900,height=1200");
if (!win) {
alert("Popup blockiert bitte Popup-Blocker deaktivieren.");
URL.revokeObjectURL(blobUrl);
return;
}
win.addEventListener("load", () => {
URL.revokeObjectURL(blobUrl);
setTimeout(() => {
win.focus();
win.print();
win.addEventListener("afterprint", () => win.close());
}, 1000);
});
}
}
});
window.selectTemplate = function(el) {
document.querySelectorAll('.template-option').forEach(e => e.classList.remove('selected'));
el.classList.add('selected');
template = parseInt(el.dataset.id);
markDirty();
};
// ── PDF via Browser-Druck ─────────────────────────────────────
window.exportPDFPrint = function () {
document.getElementById("export-menu").classList.add("hidden");
const el = document.getElementById("cv-preview");
printCV(el, cvTitle, cvSettings, template);
};
window.copyLink = function() {
const link = document.getElementById('public-link');
if (link) { navigator.clipboard.writeText(link.value); }
};
// ── PDF via Puppeteer (serverseitig) ──────────────────────────
window.exportPDFCanvas = async function () {
document.getElementById("export-menu").classList.add("hidden");
const btn = document.getElementById("btn-puppeteer");
const origHtml = btn?.innerHTML;
if (btn)
btn.innerHTML = "⏳ " + (de ? "Generiert..." : "Generating...");
// ── 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();
}
try {
const res = await fetch(`/api/cv/${cvId}/pdf`);
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = (cvTitle || "lebenslauf") + ".pdf";
a.click();
URL.revokeObjectURL(a.href);
} catch (err) {
alert((de ? "PDF-Fehler: " : "PDF error: ") + err.message);
} finally {
if (btn && origHtml) btn.innerHTML = origHtml;
}
};
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);
}
}
window.exportPDF = window.exportPDFPrint;
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>
// ── HTML Export ───────────────────────────────────────────────
window.exportHTML = async function () {
document.getElementById("export-menu").classList.add("hidden");
const el = document.getElementById("cv-preview");
const globalCss = await fetch("/styles/global.css")
.then((r) => r.text())
.catch(() => "");
const inlineStyles = Array.from(document.querySelectorAll("style"))
.map((s) => s.textContent)
.join("\n");
const fontLinks = Array.from(
document.querySelectorAll('link[rel="stylesheet"]'),
)
.map((l) => l.outerHTML)
.join("\n");
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${cvTitle}</title>
${fontLinks}
<style>${globalCss}\n${inlineStyles}</style>
</head>
<body style="margin:0;background:#E8ECF0;display:flex;justify-content:center;padding:32px">
${el.outerHTML}
</body>
</html>`;
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([html], { type: "text/html" }));
a.download = (cvTitle || "lebenslauf") + ".html";
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>