layout
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user