Files
Lebenslauf-App/src/pages/editor/[id].astro
betalabor.de afca68db7f 1
2026-04-27 21:05:48 +02:00

646 lines
24 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"
/>
</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(
`${window.location.origin}/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="${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>`;
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);
});
}
// ── 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);
};
// ── 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...");
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;
}
};
window.exportPDF = window.exportPDFPrint;
// ── HTML Export ───────────────────────────────────────────────
window.exportHTML = async function () {
document.getElementById("export-menu").classList.add("hidden");
const el = document.getElementById("cv-preview");
const globalCss = await fetch(
`${window.location.origin}/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>