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,12 +1,17 @@
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import node from '@astrojs/node'; import node from '@astrojs/node';
export default defineConfig({ export default defineConfig({
output: 'server', output: 'server',
adapter: node({ mode: 'standalone' }), adapter: node({ mode: 'standalone' }),
server: { port: 4321, host: true }, server: { port: 4321, host: true },
vite: { vite: {
optimizeDeps: { exclude: ['better-sqlite3'] }, optimizeDeps: {
ssr: { external: ['better-sqlite3'] } exclude: ['better-sqlite3', 'puppeteer'] // ← puppeteer ergänzen
},
ssr: {
external: ['better-sqlite3', 'puppeteer'] // ← puppeteer ergänzen
}
} }
}); });

971
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,12 @@
"start": "node dist/server/entry.mjs" "start": "node dist/server/entry.mjs"
}, },
"dependencies": { "dependencies": {
"astro": "^4.5.0",
"@astrojs/node": "^8.2.0", "@astrojs/node": "^8.2.0",
"astro": "^4.5.0",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"marked": "^18.0.2",
"nodemailer": "^6.9.13", "nodemailer": "^6.9.13",
"puppeteer": "^24.42.0",
"uuid": "^9.0.1" "uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,121 @@
// printExport.js
// Einbinden: <script src="/scripts/printExport.js"></script>
// Aufruf: window.printCV(element, cvTitle, cvSettings, template)
window.printCV = async function (el, cvTitle, cvSettings, template) {
// ── 1. Lokale CSS-Datei direkt fetchen (kein CORS-Problem) ──────
let globalCss = '';
try {
globalCss = await fetch('/styles/global.css').then(r => r.text());
} catch (e) {
console.warn('global.css nicht geladen', e);
}
// ── 2. Inline <style> Tags sammeln (Astro Scoped CSS) ───────────
const inlineStyles = Array.from(document.querySelectorAll('style'))
.map(s => s.textContent).join('\n');
// ── 3. Google Fonts URL ─────────────────────────────────────────
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';
// ── 4. Sidebar-Body-Gradient (wiederholt sich auf jeder Seite) ──
const SIDEBAR_WIDTH = { 1: '220px', 2: '0', 3: '160px', 4: '240px' };
const sw = SIDEBAR_WIDTH[template] || '0';
const color = (cvSettings && cvSettings.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;
}
/* Sidebar transparent Farbe kommt von body-Hintergrund */
.t1-sidebar { background: transparent !important; }
.t3-left { background: transparent !important; }
.t4-left { background: transparent !important; }
` : '';
// ── 5. Komplettes HTML zusammenbauen ────────────────────────────
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${cvTitle || '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>
/* ── global.css ── */
${globalCss}
/* ── Astro Scoped Styles ── */
${inlineStyles}
/* ── Print Overrides (am Ende = höchste Priorität) ── */
@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>`;
// ── 6. Blob-URL statt document.write ────────────────────────────
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 wurde blockiert. Bitte Popup-Blocker deaktivieren.');
URL.revokeObjectURL(blobUrl);
return;
}
win.addEventListener('load', () => {
// Blob-URL freigeben sobald geladen
URL.revokeObjectURL(blobUrl);
// Fonts + base64-PNGs (Icons) etwas Zeit geben
setTimeout(() => {
win.focus();
win.print();
win.addEventListener('afterprint', () => win.close());
}, 1000);
});
};

View File

@@ -0,0 +1,611 @@
---
import type { CVData, CVSettings } from "../../types";
import {
IcoEmail,
IcoPhone,
IcoCal,
IcoFlag,
IcoHome,
IcoPerson,
IcoEdu,
IcoExp,
} from "../../lib/icons_svg";
interface Props {
data: CVData;
settings: CVSettings;
}
const { data: d, settings: s } = Astro.props;
const p = d.personal;
const T = s.language === "en";
function date(str: string) {
return str || (T ? "present" : "heute");
}
function datRange(from: string, to: string, cur: boolean) {
return `${from} ${cur ? (T ? "present" : "Aktuell") : to}`;
}
---
<div
class="cv-a4 t1"
style={`--p:${s.primaryColor};--a:${s.accentColor};font-family:${s.fontFamily?.replace(/"/g, "'")}`}
>
<div class="t1-layout">
<!-- SIDEBAR -->
<aside class="t1-sidebar">
{
s.showPhoto && (
<div class="t1-photo-wrap">
{p.photo ? (
<img src={p.photo} class="t1-photo" alt="" />
) : (
<div class="t1-photo-ph">
<svg
viewBox="0 0 24 24"
fill="currentColor"
width="48"
height="48"
>
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z" />
</svg>
</div>
)}
</div>
)
}
<!-- Personal -->
<div class="t1-section-head">{T ? "PERSONAL" : "PERSÖNLICH"}</div>
<div class="t1-contact-list">
{
p.firstName && (
<div class="t1-ci">
<>
<span set:html={IcoPerson} class="t1-icon" />
<div>
<>
<div class="t1-cl">{T ? "NAME" : "NAME"}</div>
<div class="t1-cv">
{p.firstName} {p.lastName}
</div>
</>
</div>
</>
</div>
)
}
{
(p.address || p.city) && (
<div class="t1-ci">
<>
<fragment set:html={IcoHome} class="t1-icon" />
<div>
<>
<div class="t1-cl">{T ? "ADDRESS" : "ADRESSE"}</div>
<div class="t1-cv">
{p.address}
{p.address && p.city ? ", " : ""}
{p.city}
</div>
</>
</div>
</>
</div>
)
}
{
p.phone && (
<div class="t1-ci">
<>
<span set:html={IcoPhone} class="t1-icon" />
<div>
<>
<div class="t1-cl">{T ? "PHONE" : "TELEFON"}</div>
<div class="t1-cv">{p.phone}</div>
</>
</div>
</>
</div>
)
}
{
p.email && (
<div class="t1-ci">
<>
<span set:html={IcoEmail} class="t1-icon" />
<div>
<>
<div class="t1-cl">E-MAIL</div>
<div class="t1-cv">{p.email}</div>
</>
</div>
</>
</div>
)
}
{
p.birthDate && (
<div class="t1-ci">
<>
<span set:html={IcoCal} class="t1-icon" />
<div>
<>
<div class="t1-cl">{T ? "BIRTH DATE" : "GEBURTSDATUM"}</div>
<div class="t1-cv">
{p.birthDate}
{p.birthPlace ? `, ${p.birthPlace}` : ""}
</div>
</>
</div>
</>
</div>
)
}
{
p.nationality && (
<div class="t1-ci">
<>
<span set:html={IcoFlag} class="t1-icon" />
<div>
<>
<div class="t1-cl">
{T ? "NATIONALITY" : "NATIONALITÄT"}
</div>
<div class="t1-cv">{p.nationality}</div>
</>
</div>
</>
</div>
)
}
{
p.maritalStatus && (
<div class="t1-ci">
<>
<span set:html={IcoPerson} class="t1-icon" />
<div>
<>
<div class="t1-cl">
{T ? "MARITAL STATUS" : "FAMILIENSTAND"}
</div>
<div class="t1-cv t1-cv-sm">{p.maritalStatus}</div>
</>
</div>
</>
</div>
)
}
</div>
<!-- Languages -->
{
d.languages.length > 0 && (
<div>
<div class="t1-section-head">{T ? "LANGUAGES" : "SPRACHEN"}</div>
<div class="t1-lang-grid">
{d.languages.map((l) => (
<>
<span class="t1-lang-name">{l.name.toUpperCase()}</span>
<span class="t1-lang-level">{l.level}</span>
</>
))}
</div>
</div>
)
}
<!-- Skills / Interests -->
{
d.interests.length > 0 && (
<div>
<div class="t1-section-head">{T ? "INTERESTS" : "INTERESSEN"}</div>
<ul class="t1-list">
{d.interests.map((i) => (
<li>{i.trim()}</li>
))}
</ul>
</div>
)
}
{
d.skills.length > 0 && (
<div>
<div class="t1-section-head">{T ? "SKILLS" : "KENNTNISSE"}</div>
<ul class="t1-list">
{d.skills.map((sk) => (
<li>
<span class="t1-sk-name">{sk.name}</span>{" "}
<span class="t1-sk-lvl">
{["", "•", "••", "•••", "••••", "•••••"][sk.level] || ""}
</span>
</li>
))}
</ul>
</div>
)
}
</aside>
<!-- MAIN -->
<main class="t1-main">
<div class="t1-name">{p.firstName} {p.lastName}</div>
{p.jobTitle && <div class="t1-jobtitle">{p.jobTitle}</div>}
{
d.profile && (
<section class="t1-sec">
<div class="t1-heading">
<span class="t1-hicon">📋</span>
{T ? "PROFILE" : "PROFIL"}
</div>
<p class="t1-profile-text">{d.profile}</p>
</section>
)
}
{
d.education.length > 0 && (
<section class="t1-sec">
<div class="t1-heading">
<span class="t1-hicon">🎓</span>
{T ? "EDUCATION & QUALIFICATIONS" : "BILDUNG UND QUALIFIKATION"}
</div>
{d.education.map((e) => (
<div class="t1-entry">
<div class="t1-edate">
{datRange(e.dateFrom, e.dateTo, e.current)}
</div>
<div class="t1-eright">
<div class="t1-etitle">{e.degree}</div>
{e.school && (
<div class="t1-ecompany">
{e.school}
{e.location ? `, ${e.location}` : ""}
</div>
)}
{e.description && <div class="t1-edesc">{e.description}</div>}
{e.bullets.filter(Boolean).length > 0 && (
<ul class="t1-bullets">
{e.bullets.filter(Boolean).map((b) => (
<li>{b}</li>
))}
</ul>
)}
</div>
</div>
))}
</section>
)
}
{
d.experience.length > 0 && (
<section class="t1-sec">
<div class="t1-heading">
<span class="t1-hicon">💼</span>
{T ? "WORK EXPERIENCE" : "ARBEITSERFAHRUNG"}
</div>
{d.experience.map((e) => (
<div class="t1-entry">
<div class="t1-edate">
{datRange(e.dateFrom, e.dateTo, e.current)}
</div>
<div class="t1-eright">
<div class="t1-etitle">{e.jobTitle}</div>
{e.company && (
<div class="t1-ecompany">
{e.company}
{e.location ? `, ${e.location}` : ""}
</div>
)}
{e.description && <div class="t1-edesc">{e.description}</div>}
{e.bullets.filter(Boolean).length > 0 && (
<ul class="t1-bullets">
{e.bullets.filter(Boolean).map((b) => {
const [bold, rest] = b.includes(":")
? [b.split(":")[0], b.split(":").slice(1).join(":")]
: [null, b];
return (
<li>
{bold ? (
<>
<strong>{bold}</strong>:{rest}
</>
) : (
rest
)}
</li>
);
})}
</ul>
)}
</div>
</div>
))}
</section>
)
}
{
d.skills.length > 0 && (
<section class="t1-sec">
<div class="t1-heading">
<span class="t1-hicon">🔧</span>
{T ? "COMPETENCIES" : "KOMPETENZEN"}
</div>
{Object.entries(
d.skills.reduce((acc: any, sk) => {
const cat = sk.category || (T ? "Other" : "Sonstiges");
if (!acc[cat]) acc[cat] = [];
acc[cat].push(sk);
return acc;
}, {}),
).map(([cat, skills]: any) => (
<div class="t1-entry">
<div class="t1-edate">{cat}</div>
<div class="t1-eright">
<div class="t1-edesc">
{skills
.map(
(sk: any) =>
`${sk.name}: ${["", "Grundkenntnisse", "Gut", "Sehr gut", "Experte", "Meister"][sk.level] || sk.level}`,
)
.join(" · ")}
</div>
</div>
</div>
))}
</section>
)
}
{
d.certifications.length > 0 && (
<section class="t1-sec">
<div class="t1-heading">
<span class="t1-hicon">📜</span>
{T ? "COURSES, TRAINING" : "WEITERBILDUNGEN, KURSE"}
</div>
{d.certifications.map((c) => (
<div class="t1-entry">
<div class="t1-edate">
{c.dateFrom}
{c.dateTo && c.dateTo !== c.dateFrom ? ` ${c.dateTo}` : ""}
</div>
<div class="t1-eright">
<div class="t1-etitle">{c.name}</div>
{c.issuer && (
<div class="t1-ecompany">
{c.issuer}
{c.location ? `, ${c.location}` : ""}
</div>
)}
</div>
</div>
))}
</section>
)
}
{
d.achievements.length > 0 && (
<section class="t1-sec">
<div class="t1-heading">
<span class="t1-hicon">🏆</span>
{T ? "ACHIEVEMENTS" : "ERFOLGE"}
</div>
<ul class="t1-bullets">
{d.achievements.filter(Boolean).map((a) => (
<li>{a}</li>
))}
</ul>
</section>
)
}
</main>
</div>
</div>
<style>
.t1-layout {
display: grid;
grid-template-columns: 220px 1fr;
min-height: 1123px;
}
.t1-sidebar {
background: var(--p, #1b2a5e);
color: white;
padding: 0 0 24px 0;
}
.t1-photo-wrap {
padding: 24px;
display: flex;
justify-content: center;
}
.t1-photo {
width: 130px;
height: 130px;
border-radius: 50%;
object-fit: cover;
border: 3px solid rgba(255, 255, 255, 0.3);
}
.t1-photo-ph {
width: 130px;
height: 130px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.6);
}
.t1-section-head {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 2px;
padding: 10px 16px 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.9);
}
.t1-contact-list {
padding: 0 12px 8px;
}
.t1-ci {
display: flex;
gap: 8px;
align-items: flex-start;
margin-bottom: 9px;
}
.t1-icon {
font-size: 12px;
margin-top: 1px;
flex-shrink: 0;
filter: brightness(0) invert(1);
opacity: 0.9;
}
.t1-cl {
font-size: 7.5px;
font-weight: 700;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
}
.t1-cv {
font-size: 10px;
color: rgba(255, 255, 255, 0.9);
margin-top: 1px;
}
.t1-cv-sm {
font-size: 9.5px;
}
.t1-lang-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
padding: 0 12px 8px;
font-size: 10px;
}
.t1-lang-name {
font-weight: 700;
color: white;
}
.t1-lang-level {
color: rgba(255, 255, 255, 0.75);
}
.t1-list {
list-style: none;
padding: 0 12px 8px;
}
.t1-list li {
font-size: 10px;
color: rgba(255, 255, 255, 0.85);
padding: 2px 0;
padding-left: 10px;
position: relative;
}
.t1-list li::before {
content: "▪";
position: absolute;
left: 0;
color: rgba(255, 255, 255, 0.5);
font-size: 8px;
top: 3px;
}
.t1-sk-name {
color: white;
}
.t1-sk-lvl {
color: rgba(255, 255, 255, 0.5);
font-size: 9px;
}
/* Main */
.t1-main {
padding: 28px 28px 28px 24px;
}
.t1-name {
font-size: 28px;
font-weight: 700;
color: var(--p, #1b2a5e);
letter-spacing: -0.5px;
margin-bottom: 2px;
}
.t1-jobtitle {
font-size: 11px;
color: #666;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 16px;
}
.t1-sec {
margin-bottom: 16px;
}
.t1-heading {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: 2.5px;
color: var(--p, #1b2a5e);
border-bottom: 2px solid var(--p, #1b2a5e);
padding-bottom: 4px;
margin-bottom: 10px;
}
.t1-hicon {
font-size: 13px;
filter: brightness(0);
opacity: 0.8;
}
.t1-entry {
display: grid;
grid-template-columns: 100px 1fr;
gap: 8px;
margin-bottom: 8px;
}
.t1-edate {
font-size: 9.5px;
color: #888;
padding-top: 1px;
}
.t1-etitle {
font-size: 11px;
font-weight: 700;
color: #1a1a1a;
}
.t1-ecompany {
font-size: 10px;
color: var(--a, #4a7bc5);
margin-top: 1px;
}
.t1-edesc {
font-size: 10px;
color: #444;
margin-top: 3px;
}
.t1-profile-text {
font-size: 10.5px;
color: #444;
line-height: 1.55;
}
.t1-bullets {
list-style: none;
margin-top: 4px;
}
.t1-bullets li {
font-size: 10px;
color: #333;
padding: 2px 0 2px 12px;
position: relative;
}
.t1-bullets li::before {
content: "▪";
position: absolute;
left: 0;
color: var(--p, #1b2a5e);
font-size: 8px;
top: 4px;
}
</style>

View File

@@ -1,5 +1,18 @@
--- ---
import type { CVData, CVSettings } from '../../types'; import type { CVData, CVSettings } from "../../types";
import {
IcoEmail,
IcoPhone,
IcoCal,
IcoFlag,
IcoHome,
IcoPerson,
IcoEdu,
IcoExp,
} from "../../lib/icons_svg";
// markdown
import { marked } from "marked";
marked.setOptions({ breaks: true });
interface Props { interface Props {
data: CVData; data: CVData;
@@ -7,68 +20,215 @@ interface Props {
} }
const { data: d, settings: s } = Astro.props; const { data: d, settings: s } = Astro.props;
const p = d.personal; const p = d.personal;
const T = s.language === 'en'; const T = s.language === "en";
function date(str: string) { return str || (T ? 'present' : 'heute'); } function date(str: string) {
return str || (T ? "present" : "heute");
}
function datRange(from: string, to: string, cur: boolean) { function datRange(from: string, to: string, cur: boolean) {
return `${from} ${cur ? (T ? 'present' : 'Aktuell') : to}`; return `${from} ${cur ? (T ? "present" : "Aktuell") : to}`;
} }
--- ---
<div class="cv-a4 t1" style={`--p:${s.primaryColor};--a:${s.accentColor};font-family:${s.fontFamily?.replace(/"/g, "'")}`}>
<div
class="cv-a4 t1"
style={`--p:${s.primaryColor};--a:${s.accentColor};font-family:${s.fontFamily?.replace(/"/g, "'")}`}
>
<div class="t1-layout"> <div class="t1-layout">
<!-- SIDEBAR --> <!-- SIDEBAR -->
<aside class="t1-sidebar"> <aside class="t1-sidebar" style={`--p:${s.primaryColor};`}>
{s.showPhoto && ( {
s.showPhoto && (
<div class="t1-photo-wrap"> <div class="t1-photo-wrap">
{p.photo {p.photo ? (
? <img src={p.photo} class="t1-photo" alt="" /> <img src={p.photo} class="t1-photo" alt="" />
: <div class="t1-photo-ph"><svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg></div> ) : (
} <div class="t1-photo-ph">
<svg
viewBox="0 0 24 24"
fill="currentColor"
width="48"
height="48"
>
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z" />
</svg>
</div> </div>
)} )}
</div>
)
}
<!-- Personal --> <!-- Personal -->
<div class="t1-section-head">{T ? 'PERSONAL' : 'PERSÖNLICH'}</div> <div class="t1-section-head">{T ? "PERSONAL" : "PERSÖNLICH"}</div>
<div class="t1-contact-list"> <div class="t1-contact-list">
{p.firstName && <div class="t1-ci"><span class="t1-icon">👤</span><div><div class="t1-cl">{T ? 'NAME' : 'NAME'}</div><div class="t1-cv">{p.firstName} {p.lastName}</div></div></div>} {
{(p.address || p.city) && <div class="t1-ci"><span class="t1-icon">🏠</span><div><div class="t1-cl">{T ? 'ADDRESS' : 'ADRESSE'}</div><div class="t1-cv">{p.address}{p.address && p.city ? ', ' : ''}{p.city}</div></div></div>} p.firstName && (
{p.phone && <div class="t1-ci"><span class="t1-icon">📞</span><div><div class="t1-cl">{T ? 'PHONE' : 'TELEFON'}</div><div class="t1-cv">{p.phone}</div></div></div>} <div class="t1-ci">
{p.email && <div class="t1-ci"><span class="t1-icon">✉️</span><div><div class="t1-cl">E-MAIL</div><div class="t1-cv">{p.email}</div></div></div>} <>
{p.birthDate && <div class="t1-ci"><span class="t1-icon">📅</span><div><div class="t1-cl">{T ? 'BIRTH DATE' : 'GEBURTSDATUM'}</div><div class="t1-cv">{p.birthDate}{p.birthPlace ? `, ${p.birthPlace}` : ''}</div></div></div>} <span set:html={IcoPerson} class="t1-icon" />
{p.nationality && <div class="t1-ci"><span class="t1-icon">🏳️</span><div><div class="t1-cl">{T ? 'NATIONALITY' : 'NATIONALITÄT'}</div><div class="t1-cv">{p.nationality}</div></div></div>} <div>
{p.maritalStatus && <div class="t1-ci"><span class="t1-icon">👥</span><div><div class="t1-cl">{T ? 'MARITAL STATUS' : 'FAMILIENSTAND'}</div><div class="t1-cv t1-cv-sm">{p.maritalStatus}</div></div></div>} <>
<div class="t1-cl">{T ? "NAME" : "NAME"}</div>
<div class="t1-cv">
{p.firstName} {p.lastName}
</div>
</>
</div>
</>
</div>
)
}
{
(p.address || p.city) && (
<div class="t1-ci">
<>
<span set:html={IcoHome} class="t1-icon" />
<div>
<>
<div class="t1-cl">{T ? "ADDRESS" : "ADRESSE"}</div>
<div class="t1-cv">
{p.address}
{p.address && p.city ? ", " : ""}
{p.city}
</div>
</>
</div>
</>
</div>
)
}
{
p.phone && (
<div class="t1-ci">
<>
<span set:html={IcoPhone} class="t1-icon" />
<div>
<>
<div class="t1-cl">{T ? "PHONE" : "TELEFON"}</div>
<div class="t1-cv">{p.phone}</div>
</>
</div>
</>
</div>
)
}
{
p.email && (
<div class="t1-ci">
<>
<span set:html={IcoEmail} class="t1-icon" />
<div>
<>
<div class="t1-cl">E-MAIL</div>
<div class="t1-cv">{p.email}</div>
</>
</div>
</>
</div>
)
}
{
p.birthDate && (
<div class="t1-ci">
<>
<span set:html={IcoCal} class="t1-icon" />
<div>
<>
<div class="t1-cl">{T ? "BIRTH DATE" : "GEBURTSDATUM"}</div>
<div class="t1-cv">
{p.birthDate}
{p.birthPlace ? `, ${p.birthPlace}` : ""}
</div>
</>
</div>
</>
</div>
)
}
{
p.nationality && (
<div class="t1-ci">
<>
<span set:html={IcoFlag} class="t1-icon" />
<div>
<>
<div class="t1-cl">
{T ? "NATIONALITY" : "NATIONALITÄT"}
</div>
<div class="t1-cv">{p.nationality}</div>
</>
</div>
</>
</div>
)
}
{
p.maritalStatus && (
<div class="t1-ci">
<>
<span set:html={IcoPerson} class="t1-icon" />
<div>
<>
<div class="t1-cl">
{T ? "MARITAL STATUS" : "FAMILIENSTAND"}
</div>
<div class="t1-cv t1-cv-sm">{p.maritalStatus}</div>
</>
</div>
</>
</div>
)
}
</div> </div>
<!-- Languages --> <!-- Languages -->
{d.languages.length > 0 && ( {
d.languages.length > 0 && (
<div> <div>
<div class="t1-section-head">{T ? 'LANGUAGES' : 'SPRACHEN'}</div> <div class="t1-section-head">{T ? "LANGUAGES" : "SPRACHEN"}</div>
<div class="t1-lang-grid"> <div class="t1-lang-grid">
{d.languages.map(l => ( {d.languages.map((l) => (
<><span class="t1-lang-name">{l.name.toUpperCase()}</span><span class="t1-lang-level">{l.level}</span></> <>
<span class="t1-lang-name">{l.name.toUpperCase()}</span>
<span class="t1-lang-level">{l.level}</span>
</>
))} ))}
</div> </div>
</div> </div>
)} )
}
<!-- Skills / Interests --> <!-- Skills / Interests -->
{d.interests.length > 0 && ( {
d.interests.length > 0 && (
<div> <div>
<div class="t1-section-head">{T ? 'INTERESTS' : 'INTERESSEN'}</div> <div class="t1-section-head">{T ? "INTERESTS" : "INTERESSEN"}</div>
<ul class="t1-list"> <ul class="t1-list">
{d.interests.map(i => <li>{i.trim()}</li>)} {d.interests.map((i) => (
<li>{i.trim()}</li>
))}
</ul> </ul>
</div> </div>
)} )
}
{d.skills.length > 0 && ( {
d.skills.length > 0 && (
<div> <div>
<div class="t1-section-head">{T ? 'SKILLS' : 'KENNTNISSE'}</div> <div class="t1-section-head">{T ? "SKILLS" : "KENNTNISSE"}</div>
<ul class="t1-list"> <ul class="t1-list">
{d.skills.map(sk => <li><span class="t1-sk-name">{sk.name}</span> <span class="t1-sk-lvl">{['','•','••','•••','••••','•••••'][sk.level] || ''}</span></li>)} {d.skills.map((sk) => (
<li>
<span class="t1-sk-name">{sk.name}</span>{" "}
<span class="t1-sk-lvl">
{["", "•", "••", "•••", "••••", "•••••"][sk.level] || ""}
</span>
</li>
))}
</ul> </ul>
</div> </div>
)} )
}
</aside> </aside>
<!-- MAIN --> <!-- MAIN -->
@@ -76,150 +236,483 @@ function datRange(from: string, to: string, cur: boolean) {
<div class="t1-name">{p.firstName} {p.lastName}</div> <div class="t1-name">{p.firstName} {p.lastName}</div>
{p.jobTitle && <div class="t1-jobtitle">{p.jobTitle}</div>} {p.jobTitle && <div class="t1-jobtitle">{p.jobTitle}</div>}
{d.profile && ( {
d.profile && (
<section class="t1-sec"> <section class="t1-sec">
<div class="t1-heading"><span class="t1-hicon">📋</span>{T ? 'PROFILE' : 'PROFIL'}</div> <div class="t1-heading">
<p class="t1-profile-text">{d.profile}</p> <span class="t1-hicon">📋</span>
{T ? "PROFILE" : "PROFIL"}
</div>
<div
class="t1-profile-text markdown"
set:html={marked.parse(d.profile)}
/>
</section> </section>
)} )
}
{d.education.length > 0 && ( {
d.education.length > 0 && (
<section class="t1-sec"> <section class="t1-sec">
<div class="t1-heading"><span class="t1-hicon">🎓</span>{T ? 'EDUCATION & QUALIFICATIONS' : 'BILDUNG UND QUALIFIKATION'}</div> <div class="t1-heading">
{d.education.map(e => ( <span class="t1-hicon">🎓</span>
{T ? "EDUCATION & QUALIFICATIONS" : "BILDUNG UND QUALIFIKATION"}
</div>
{d.education.map((e) => (
<div class="t1-entry"> <div class="t1-entry">
<div class="t1-edate">{datRange(e.dateFrom, e.dateTo, e.current)}</div> <div class="t1-edate">
{datRange(e.dateFrom, e.dateTo, e.current)}
</div>
<div class="t1-eright"> <div class="t1-eright">
<div class="t1-etitle">{e.degree}</div> <div class="t1-etitle">{e.degree}</div>
{e.school && <div class="t1-ecompany">{e.school}{e.location ? `, ${e.location}` : ''}</div>} {e.school && (
<div class="t1-ecompany">
{e.school}
{e.location ? `, ${e.location}` : ""}
</div>
)}
{e.description && <div class="t1-edesc">{e.description}</div>} {e.description && <div class="t1-edesc">{e.description}</div>}
{e.bullets.filter(Boolean).length > 0 && ( {e.bullets.filter(Boolean).length > 0 && (
<ul class="t1-bullets">{e.bullets.filter(Boolean).map(b => <li>{b}</li>)}</ul> <ul class="t1-bullets">
{e.bullets.filter(Boolean).map((b) => (
<li>{b}</li>
))}
</ul>
)} )}
</div> </div>
</div> </div>
))} ))}
</section> </section>
)} )
}
{d.experience.length > 0 && ( {
d.experience.length > 0 && (
<section class="t1-sec"> <section class="t1-sec">
<div class="t1-heading"><span class="t1-hicon">💼</span>{T ? 'WORK EXPERIENCE' : 'ARBEITSERFAHRUNG'}</div> <div class="t1-heading">
{d.experience.map(e => ( <span class="t1-hicon">💼</span>
{T ? "WORK EXPERIENCE" : "ARBEITSERFAHRUNG"}
</div>
{d.experience.map((e) => (
<div class="t1-entry"> <div class="t1-entry">
<div class="t1-edate">{datRange(e.dateFrom, e.dateTo, e.current)}</div> <div class="t1-edate">
{datRange(e.dateFrom, e.dateTo, e.current)}
</div>
<div class="t1-eright"> <div class="t1-eright">
<div class="t1-etitle">{e.jobTitle}</div> <div class="t1-etitle">{e.jobTitle}</div>
{e.company && <div class="t1-ecompany">{e.company}{e.location ? `, ${e.location}` : ''}</div>} {e.company && (
<div class="t1-ecompany">
{e.company}
{e.location ? `, ${e.location}` : ""}
</div>
)}
{e.description && <div class="t1-edesc">{e.description}</div>} {e.description && <div class="t1-edesc">{e.description}</div>}
{e.bullets.filter(Boolean).length > 0 && ( {e.bullets.filter(Boolean).length > 0 && (
<ul class="t1-bullets">{e.bullets.filter(Boolean).map(b => { <ul class="t1-bullets">
const [bold, rest] = b.includes(':') ? [b.split(':')[0], b.split(':').slice(1).join(':')] : [null, b]; {e.bullets.filter(Boolean).map((b) => {
return <li>{bold ? <><strong>{bold}</strong>:{rest}</> : rest}</li>; const [bold, rest] = b.includes(":")
})}</ul> ? [b.split(":")[0], b.split(":").slice(1).join(":")]
: [null, b];
return (
<li>
{bold ? (
<>
<strong>{bold}</strong>:{rest}
</>
) : (
rest
)}
</li>
);
})}
</ul>
)} )}
</div> </div>
</div> </div>
))} ))}
</section> </section>
)} )
}
{d.skills.length > 0 && ( {
d.skills.length > 0 && (
<section class="t1-sec"> <section class="t1-sec">
<div class="t1-heading"><span class="t1-hicon">🔧</span>{T ? 'COMPETENCIES' : 'KOMPETENZEN'}</div> <div class="t1-heading">
{Object.entries(d.skills.reduce((acc: any, sk) => { <span class="t1-hicon">🔧</span>
const cat = sk.category || (T ? 'Other' : 'Sonstiges'); {T ? "COMPETENCIES" : "KOMPETENZEN"}
</div>
{Object.entries(
d.skills.reduce((acc: any, sk) => {
const cat = sk.category || (T ? "Other" : "Sonstiges");
if (!acc[cat]) acc[cat] = []; if (!acc[cat]) acc[cat] = [];
acc[cat].push(sk); acc[cat].push(sk);
return acc; return acc;
}, {})).map(([cat, skills]: any) => ( }, {}),
).map(([cat, skills]: any) => (
<div class="t1-entry"> <div class="t1-entry">
<div class="t1-edate">{cat}</div> <div class="t1-edate">{cat}</div>
<div class="t1-eright"> <div class="t1-eright">
<div class="t1-edesc">{skills.map((sk: any) => `${sk.name}: ${['','Grundkenntnisse','Gut','Sehr gut','Experte','Meister'][sk.level] || sk.level}`).join(' · ')}</div> <div class="t1-edesc">
{skills
.map(
(sk: any) =>
`${sk.name}: ${["", "Grundkenntnisse", "Gut", "Sehr gut", "Experte", "Meister"][sk.level] || sk.level}`,
)
.join(" · ")}
</div>
</div> </div>
</div> </div>
))} ))}
</section> </section>
)} )
}
{d.certifications.length > 0 && ( {
d.certifications.length > 0 && (
<section class="t1-sec"> <section class="t1-sec">
<div class="t1-heading"><span class="t1-hicon">📜</span>{T ? 'COURSES, TRAINING' : 'WEITERBILDUNGEN, KURSE'}</div> <div class="t1-heading">
{d.certifications.map(c => ( <span class="t1-hicon">📜</span>
{T ? "COURSES, TRAINING" : "WEITERBILDUNGEN, KURSE"}
</div>
{d.certifications.map((c) => (
<div class="t1-entry"> <div class="t1-entry">
<div class="t1-edate">{c.dateFrom}{c.dateTo && c.dateTo !== c.dateFrom ? ` ${c.dateTo}` : ''}</div> <div class="t1-edate">
{c.dateFrom}
{c.dateTo && c.dateTo !== c.dateFrom ? ` ${c.dateTo}` : ""}
</div>
<div class="t1-eright"> <div class="t1-eright">
<div class="t1-etitle">{c.name}</div> <div class="t1-etitle">{c.name}</div>
{c.issuer && <div class="t1-ecompany">{c.issuer}{c.location ? `, ${c.location}` : ''}</div>} {c.issuer && (
<div class="t1-ecompany">
{c.issuer}
{c.location ? `, ${c.location}` : ""}
</div>
)}
</div> </div>
</div> </div>
))} ))}
</section> </section>
)} )
}
{d.achievements.length > 0 && ( {
d.achievements.length > 0 && (
<section class="t1-sec"> <section class="t1-sec">
<div class="t1-heading"><span class="t1-hicon">🏆</span>{T ? 'ACHIEVEMENTS' : 'ERFOLGE'}</div> <div class="t1-heading">
<ul class="t1-bullets">{d.achievements.filter(Boolean).map(a => <li>{a}</li>)}</ul> <span class="t1-hicon">🏆</span>
{T ? "ACHIEVEMENTS" : "ERFOLGE"}
</div>
<ul class="t1-bullets">
{d.achievements.filter(Boolean).map((a) => (
<li>{a}</li>
))}
</ul>
</section> </section>
)} )
}
</main> </main>
</div> </div>
</div> </div>
<style> <style>
.t1-layout { display: grid; grid-template-columns: 220px 1fr; min-height: 1123px; } .t1-layout {
.t1-sidebar { /* Hintergrund auf Grid-Parent → wiederholt sich auf jeder Druckseite */
background: var(--p, #1B2A5E); /*background: linear-gradient(to right, var(--p, #1b2a5e) 220px, white 220px);*/
/*background-color: var(--p, #1b2a5e);*/
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
display: grid;
grid-template-columns: 220px 1fr;
min-height: 100%; /* ← nicht 1123px */
position: relative;
z-index: 2;
}
.t1-sidebar {
/*background: var(--p, #1b2a5e);*/
color: white; color: white;
padding: 0 0 24px 0; padding: 0 0 24px 0;
} /*background: var(--p, #1b2a5e);*/
.t1-photo-wrap { padding: 24px; display: flex; justify-content: center; } /*box-shadow: -100vw 0 0 100vw transparent;*/
.t1-photo { width: 130px; height: 130px; border-radius: 50%; object-fit: cover; border: 3px solid rgba(255,255,255,.3); } }
.t1-photo-ph { width: 130px; height: 130px; border-radius: 50%; background: rgba(255,255,255,.15); display: grid; place-items: center; color: rgba(255,255,255,.6); } .t1-photo-wrap {
.t1-section-head { padding: 24px;
font-size: 9.5px; font-weight: 700; letter-spacing: 2px; display: flex;
justify-content: center;
}
.t1-photo {
width: 130px;
height: 130px;
border-radius: 50%;
object-fit: cover;
border: 3px solid rgba(255, 255, 255, 0.3);
}
.t1-photo-ph {
width: 130px;
height: 130px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.6);
}
.t1-section-head {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 2px;
padding: 10px 16px 6px; padding: 10px 16px 6px;
border-bottom: 1px solid rgba(255,255,255,.2); border-bottom: 1px solid rgba(255, 255, 255, 0.2);
margin-bottom: 8px; margin-bottom: 8px;
color: rgba(255,255,255,.9); color: rgba(255, 255, 255, 0.9);
} }
.t1-contact-list { padding: 0 12px 8px; } .t1-contact-list {
.t1-ci { display: flex; gap: 8px; align-items: flex-start; margin-bottom: 9px; } padding: 0 12px 8px;
.t1-icon { font-size: 12px; margin-top: 1px; flex-shrink: 0; filter: brightness(0) invert(1); opacity: 0.9; } }
.t1-cl { font-size: 7.5px; font-weight: 700; letter-spacing: 1px; color: rgba(255,255,255,.6); text-transform: uppercase; } .t1-ci {
.t1-cv { font-size: 10px; color: rgba(255,255,255,.9); margin-top: 1px; } display: flex;
.t1-cv-sm { font-size: 9.5px; } gap: 8px;
.t1-lang-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; padding: 0 12px 8px; font-size: 10px; } align-items: flex-start;
.t1-lang-name { font-weight: 700; color: white; } margin-bottom: 9px;
.t1-lang-level { color: rgba(255,255,255,.75); } }
.t1-list { list-style: none; padding: 0 12px 8px; } .t1-icon {
.t1-list li { font-size: 10px; color: rgba(255,255,255,.85); padding: 2px 0; padding-left: 10px; position: relative; } font-size: 12px;
.t1-list li::before { content: '▪'; position: absolute; left: 0; color: rgba(255,255,255,.5); font-size: 8px; top: 3px; } margin-top: 1px;
.t1-sk-name { color: white; } flex-shrink: 0;
.t1-sk-lvl { color: rgba(255,255,255,.5); font-size: 9px; } filter: brightness(0) invert(1);
opacity: 0.9;
}
.t1-cl {
font-size: 7.5px;
font-weight: 700;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
}
.t1-cv {
font-size: 10px;
color: rgba(255, 255, 255, 0.9);
margin-top: 1px;
}
.t1-cv-sm {
font-size: 9.5px;
}
.t1-lang-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
padding: 0 12px 8px;
font-size: 10px;
}
.t1-lang-name {
font-weight: 700;
color: white;
}
.t1-lang-level {
color: rgba(255, 255, 255, 0.75);
}
.t1-list {
list-style: none;
padding: 0 12px 8px;
}
.t1-list li {
font-size: 10px;
color: rgba(255, 255, 255, 0.85);
padding: 2px 0;
padding-left: 10px;
position: relative;
}
.t1-list li::before {
content: "▪";
position: absolute;
left: 0;
color: rgba(255, 255, 255, 0.5);
font-size: 8px;
top: 3px;
}
.t1-sk-name {
color: white;
}
.t1-sk-lvl {
color: rgba(255, 255, 255, 0.5);
font-size: 9px;
}
/* Main */ /* ── Main ─────────────────────────────────────────────────────── */
.t1-main { padding: 28px 28px 28px 24px; } .t1-main {
.t1-name { font-size: 28px; font-weight: 700; color: var(--p, #1B2A5E); letter-spacing: -0.5px; margin-bottom: 2px; } padding: 28px 28px 28px 24px;
.t1-jobtitle { font-size: 11px; color: #666; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 16px; } background-color: transparent;
.t1-sec { margin-bottom: 16px; } }
.t1-heading { .t1-sidebar:before {
display: flex; align-items: center; gap: 6px; background-color: var(--p, #1b2a5e);
font-size: 10px; font-weight: 700; letter-spacing: 2.5px; position: fixed;
color: var(--p, #1B2A5E); top: 0;
border-bottom: 2px solid var(--p, #1B2A5E); left: 0;
padding-bottom: 4px; margin-bottom: 10px; right: 0;
} bottom: 0;
.t1-hicon { font-size: 13px; filter: brightness(0); opacity: 0.8; } z-index: -1;
.t1-entry { display: grid; grid-template-columns: 100px 1fr; gap: 8px; margin-bottom: 8px; } width: 220px;
.t1-edate { font-size: 9.5px; color: #888; padding-top: 1px; } content: " ";
.t1-etitle { font-size: 11px; font-weight: 700; color: #1a1a1a; } }
.t1-ecompany { font-size: 10px; color: var(--a, #4A7BC5); margin-top: 1px; } .t1-name {
.t1-edesc { font-size: 10px; color: #444; margin-top: 3px; } font-size: 28px;
.t1-profile-text { font-size: 10.5px; color: #444; line-height: 1.55; } font-weight: 700;
.t1-bullets { list-style: none; margin-top: 4px; } color: var(--p, #1b2a5e);
.t1-bullets li { font-size: 10px; color: #333; padding: 2px 0 2px 12px; position: relative; } letter-spacing: -0.5px;
.t1-bullets li::before { content: '▪'; position: absolute; left: 0; color: var(--p, #1B2A5E); font-size: 8px; top: 4px; } margin-bottom: 2px;
}
.t1-jobtitle {
font-size: 11px;
color: #666;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 16px;
}
.t1-sec {
padding-top: 16px;
}
.t1-heading {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: 2.5px;
color: var(--p, #1b2a5e);
border-bottom: 2px solid var(--p, #1b2a5e);
padding-bottom: 4px;
margin-bottom: 10px;
}
.t1-hicon {
font-size: 13px;
filter: brightness(0);
opacity: 0.8;
}
.t1-entry {
display: grid;
grid-template-columns: 100px 1fr;
gap: 8px;
padding-top: 8px;
}
.t1-edate {
font-size: 9.5px;
color: #888;
padding-top: 1px;
}
.t1-etitle {
font-size: 11px;
font-weight: 700;
color: #1a1a1a;
}
.t1-ecompany {
font-size: 10px;
color: var(--a, #4a7bc5);
margin-top: 1px;
}
.t1-edesc {
font-size: 10px;
color: #444;
margin-top: 3px;
}
/* ── Profile / Markdown ───────────────────────────────────────── */
.t1-profile-text {
font-size: 10.5px;
color: #444;
line-height: 1.55;
}
.t1-profile-text.markdown :global(p) {
margin: 0 0 6px 0;
}
.t1-profile-text.markdown :global(ul),
.t1-profile-text.markdown :global(ol) {
margin: 0 0 6px 0;
padding-left: 16px;
}
.t1-profile-text.markdown :global(li) {
margin-bottom: 2px;
font-size: 10.5px;
color: #444;
line-height: 1.55;
}
.t1-profile-text.markdown :global(strong) {
font-weight: 700;
}
.t1-profile-text.markdown :global(em) {
font-style: italic;
}
.t1-profile-text.markdown :global(*:last-child) {
margin-bottom: 0;
}
/* ── Bullets ──────────────────────────────────────────────────── */
.t1-bullets {
list-style: none;
margin-top: 4px;
}
.t1-bullets li {
font-size: 10px;
color: #333;
padding: 2px 0 2px 12px;
position: relative;
}
.t1-bullets li::before {
content: "▪";
position: absolute;
left: 0;
color: var(--p, #1b2a5e);
font-size: 8px;
top: 4px;
}
/* ── Sidebar-Hintergrund via cv-a4 Gradient ──────────────────── */
/* Liegt auf dem äußersten Container → läuft durch alle Druckseiten */
:global(.cv-aaa4.t1) {
background: linear-gradient(
to right,
var(--p, #1b2a5e) 220px,
white 220px
) !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* ── Print ────────────────────────────────────────────────────── */
@media print {
@page {
size: A4 portrait;
margin: 10mm 0 0 0;
margin: 0;
}
@page :first {
margin: 0 0 0 0;
}
.t1-layout {
border: 0px solid red;
width: 210mm;
height: 297mm;
}
main {
border: 0px solid blue;
width: 100%;
height: 100%;
}
:global(.cv-aaa4.t1) {
width: 100% !important;
min-height: 0 !important;
box-shadow: none !important;
background: linear-gradient(
to right,
var(--p, #1b2a5e) 220px,
white 220px
) !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.t1-entry,
.t1-sec {
break-inside: avoid;
page-break-inside: avoid;
}
}
</style> </style>

View File

@@ -1,8 +1,43 @@
export const IcoEmail = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>'; /***
export const IcoPhone = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>'; <!-- 1. Fertiger Tag direkt -->
export const IcoCal = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>'; <Fragment set:html={IcoPersonTag} />
export const IcoFlag = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path><line x1="4" y1="22" x2="4" y2="15"></line></svg>';
export const IcoHome = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>'; <!-- 2. Eigene Größe -->
export const IcoPerson = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>'; <Fragment set:html={icoTag(IcoPerson, '14px')} />
export const IcoEdu = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><path d="M22 10v6M2 10l10-5 10 5-10 5z"></path><path d="M6 12v5c3 3 9 3 12 0v-5"></path></svg>';
export const IcoExp = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;vertical-align:-0.15em"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg>'; <!-- 3. Nur die Data-URL, eigenes img -->
<img src={IcoPerson} style="width:14px;height:14px" />
***/
// Auto-generated PNG icons (pure Node.js, no dependencies)
// All icons: 20×20px transparent PNG, dark (#444) strokes
export const IcoEmail = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAh0lEQVR4nO3TwQ2AIAwFUFZgBVboAF7Yy9EYTcMBU0pbS+vRJj9RiU/yIyn988nknI9a6xUJALQHjGIjpZSTBfuCJX1XJnDa/kZNC4i/qqEYw9cs2O81lGLc++sDAeUwM0hRCdsCMSph2yBGOcwFjv6kNReozeuPHT56tHhvplqs51eK1nFobtcpL2DD8YzgAAAAAElFTkSuQmCC';
export const IcoPhone = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAk0lEQVR4nL2U0QnAIAxEXcEVsoID9Cd7dbSO1pKPgyPEDz2pINgWX8+7mNb+GL33y93fmGOMRwYChhk/OAIMdTKMgWZ2y7BKoexj9lAOp4JJUPYQoUjQWSjbYVUbY433ywqr48mlBChuCiuMb+zvNpSft8uJlUFpBi/bwN2HN0u+Vi1NDoqh0pHzyB4eaXGhqiqdD7k1qABJQoCkAAAAAElFTkSuQmCC';
export const IcoCal = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAd0lEQVR4nGNgoBcQFBS0NzY23k+sOEHg4uLyH4SVlJTqiRHHsBWmkFyM4mpKDcNwNbItIEEQxiaGSxyngcjhAtIAEgMFB3LwYBMnykBY2OIKc7wRRbMwpGmkkIKJDkNiwaiBQ8lAqqVDWP6kFKNkSXISNDKGGQYAXgddF8yb6SQAAAAASUVORK5CYII=';
export const IcoFlag = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAbUlEQVR4nGNgoCUQFBS0d3Fx+W9sbLxfSUmpHoQpMhBkGC4MswBkKVUMxGcJQQORFcE0ETIcFExEGYgOQBpxGYoRHNgMBCkC8fEZBJLDGrbICgh5k6gIIhRGJCcjslxBjIEUJ+hRA0cNHG4GAgCFC737QwHrBwAAAABJRU5ErkJggg==';
export const IcoHome = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAi0lEQVR4nN3TwQ2AIAwFUFZgBVZgAC/s5WiOpvkHkvqh2ArxIMlPBORRiIbwixZj3HLOxzKslHIi0yhjU6jE8Iyx12gPq82NjjA3asHMqAd7RCWGpJR2CyjX3NC60wzYnExWWUGunCuR73eviUHGtHn1RNoCVIWxZaC1/x3ojQry52PN8EfAbp4wdgHqUfXzO48S3QAAAABJRU5ErkJggg==';
export const IcoPerson = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAkElEQVR4nGNgGNJAUFDQ3tjYeL+Li8t/GFZSUqon2zBkg5AxyBKKDAO5CoSRXUuSocga0eVABsPkQBYTZSCh8CI5PGlmILZwQg5fog1EDidkTejJiCjDYAA9/aFjoiOEGEOJNgxfYiY5kZNjGF5DYd4D0bCcQQzGGgzIriM1sJEdgl+QSIDVMZQWTRj6B7WBAIJ63VJfa/AIAAAAAElFTkSuQmCC';
export const IcoEdu = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAmUlEQVR4nO2T0Q2AMAhEWaErdIUO4E/3cjRH02BSQ/Aq15r4JQkfWngnVxT545PIOa+llC2ltLwG1Vp3m1NgbUAg/0yBfKN+JXtOg4bq/XgjHnmPTxus0ozhHnq+nDEcXdwFDH0h/LsBWyBVbUAgKxoCUTM6o4Gt0I5jL83XNZEQaL2L6uCG9Mbs5dMPIAJWKEpqb1WVSQQ7AMPb/9EAxbXuAAAAAElFTkSuQmCC';
export const IcoExp = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAWklEQVR4nGNgGAXIQFBQ0N7FxeU/MjY2Nt5PtoHohsGwkpJSPUUGglwFMoRoA7F5jVSMEhSUGobhcnSvoWNi5TEMxBU2JMuPGjhq4GAykGpZD5SlqGEgqJABABvuMFINme7XAAAAAElFTkSuQmCC';
export const IcoRings = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAhklEQVR4nGNgGAWDHwgKCtobGxvvd3Fx+Q/DSkpK9bjECRqGrIEYDLKEKMNAtoMwNlehi2M1FFkBLnEQBlkMkwMZjE0cDHCFC7oXccljhCfNDEQPD3wGIoc7hoHI4YEsiSyOHL7oyQgjUrBFALEYI0KIMRSXOF7DkL0DS2/oYYZNfBRQFwAAfjjIRhgi+hMAAAAASUVORK5CYII=';
export const IcoLink = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAd0lEQVR4nGNgGJZAUFDQ3sXF5T8IGxsb76eaYTCspKRUT7FhIEPIMhCbi0BiyOIgNtEGgsII3XvIYiQZBgPohlBkGC6XUmQYemRQzTCqGUR1wygyEN0w5MggOZuhhxdybiDLpegxicwnK3bRXTO4Ei8MkO09WgEA2VWQrb3SXjUAAAAASUVORK5CYII=';
export const IcoGlobe = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAn0lEQVR4nL2U0Q3AIAhEXcEVXIEB+uNeHa2jtfFDc7GHoKY1ucSIPBHQEP4aMcYjpXTmnG+UiFxlfQrGQAxcDjVhZWPvyOZVQyhz8IhCPdcc6VWAPukor41Gx1JRbay61Jee4gTi7Zp9J3eo74Es6VZR0N6A2H87RWn9SBedQDWY1VeiBoLln5XWbhRqfQ4qbCVSEzaTU9dfyEbfcxboAUk6gd3N0EXVAAAAAElFTkSuQmCC';
// ── Helper: PNG-Data-URL als fertiges <img>-Tag ───────────────────────
export function icoTag(src: string, size = '1em'): string {
return `<img src="${src}" style="width:${size};height:${size};vertical-align:-0.15em;display:inline-block;flex-shrink:0"/>`;
}
// ── Fertige <img>-Tags für direkte Verwendung mit set:html ────────────
export const IcoEmailTag = icoTag(IcoEmail);
export const IcoPhoneTag = icoTag(IcoPhone);
export const IcoCalTag = icoTag(IcoCal);
export const IcoFlagTag = icoTag(IcoFlag);
export const IcoHomeTag = icoTag(IcoHome);
export const IcoPersonTag = icoTag(IcoPerson);
export const IcoEduTag = icoTag(IcoEdu);
export const IcoExpTag = icoTag(IcoExp);
export const IcoRingsTag = icoTag(IcoRings);
export const IcoLinkTag = icoTag(IcoLink);
export const IcoGlobeTag = icoTag(IcoGlobe);

80
src/lib/icons_svg.ts Normal file
View File

@@ -0,0 +1,80 @@
// Wraps SVG string as base64 img tag → PDF-kompatibel
// Buffer.from() statt btoa() → Node/Astro-SSR-kompatibel
// Farbe ist fest kodiert (kein currentColor) → funktioniert in data:image/svg+xml
/***
---
import { IcoPersonW, IcoEmailW, makeIco } from '../lib/icons';
---
<!--Weiß für dunkle Sidebar-- >
<Fragment set: html = { IcoPersonW } />
<!--Beliebige Farbe-- >
<Fragment set: html = { makeIco('email', '#1B2A5E') } />
***/
function ico(svg: string): string {
const b64 = Buffer.from(svg, 'utf-8').toString('base64');
return `<img src="data:image/svg+xml;base64,${b64}" style="width:14px;height:14px;vertical-align:-0.15em;display:inline-block;flex-shrink:0"/>`;
}
export const IcoEmail = ico('<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>');
export const IcoPhone = ico('<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>');
export const IcoCal = ico('<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>');
export const IcoFlag = ico('<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>');
export const IcoHome = ico('<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>');
export const IcoPerson = ico('<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>');
export const IcoEdu = ico('<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>');
export const IcoExp = ico('<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>');
export const IcoRings = ico('<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="12" r="4"/><circle cx="16" cy="12" r="4"/></svg>');
export const IcoLink = ico('<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>');
export const IcoGlobe = ico('<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>');
// ── Weiß-Varianten für dunkle Sidebar ────────────────────────────────
function icoColor(svg: string, color: string): string {
const colored = svg.replace(/stroke="#444"/g, `stroke="${color}"`);
const b64 = Buffer.from(colored, 'utf-8').toString('base64');
return `<img src="data:image/svg+xml;base64,${b64}" style="width:1em;height:1em;vertical-align:-0.15em;display:inline-block;flex-shrink:0"/>`;
}
const _svgs: Record<string, string> = {
email: '<svg viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>',
phone: '<svg viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>',
cal: '<svg viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
flag: '<svg viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>',
home: '<svg viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
person: '<svg viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
edu: '<svg viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>',
exp: '<svg viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>',
rings: '<svg viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="12" r="4"/><circle cx="16" cy="12" r="4"/></svg>',
link: '<svg viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
globe: '<svg viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
};
// Weiß-Varianten
export const IcoEmailW = icoColor(_svgs.email, '#fff');
export const IcoPhoneW = icoColor(_svgs.phone, '#fff');
export const IcoCalW = icoColor(_svgs.cal, '#fff');
export const IcoFlagW = icoColor(_svgs.flag, '#fff');
export const IcoHomeW = icoColor(_svgs.home, '#fff');
export const IcoPersonW = icoColor(_svgs.person, '#fff');
export const IcoEduW = icoColor(_svgs.edu, '#fff');
export const IcoExpW = icoColor(_svgs.exp, '#fff');
export const IcoRingsW = icoColor(_svgs.rings, '#fff');
export const IcoLinkW = icoColor(_svgs.link, '#fff');
export const IcoGlobeW = icoColor(_svgs.globe, '#fff');
// ── Factory für beliebige Farben ──────────────────────────────────────
export function makeIco(name: keyof typeof _svgs, color = '#444'): string {
return icoColor(_svgs[name], color);
}

View File

@@ -0,0 +1,59 @@
import type { APIRoute } from 'astro';
import { getCVById, getProfileById } from '../../../../lib/db';
export const GET: APIRoute = async ({ params, locals, request }) => {
const user = locals.user;
const cv = getCVById(params.id!, user?.id);
if (!cv) return new Response('Not Found', { status: 404 });
const profile = getProfileById(cv.profile_id, user?.id);
if (!profile) return new Response('Profile Not Found', { status: 404 });
const name = [profile.data.personal.firstName, profile.data.personal.lastName]
.filter(Boolean).join(' ') || cv.title;
const origin = new URL(request.url).origin;
const targetUrl = `${origin}/cv/${cv.hash}`;
try {
// Dynamic import verhindert Vite-Verarbeitung
const { default: puppeteer } = await import('puppeteer');
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123 });
await page.goto(targetUrl, { waitUntil: 'networkidle0', timeout: 20000 });
// Toolbar ausblenden
await page.addStyleTag({
content: `
.cv-public-bar { display: none !important; }
body { background: white !important; padding: 0 !important; }
.cv-public-wrap { padding: 0 !important; background: white !important; }
`
});
await page.emulateMediaType('print');
const pdf = await page.pdf({
format: 'A4',
margin: { top: '0', right: '0', bottom: '0', left: '0' },
printBackground: true,
scale: 1,
});
await browser.close();
return new Response(pdf, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${encodeURIComponent(name)}.pdf"`,
},
});
} catch (err: any) {
console.error('[PDF]', err.message);
return new Response(err.message, { status: 500 });
}
};

View File

@@ -0,0 +1,57 @@
import type { APIRoute } from 'astro';
import { getCVByHash, getProfileById } from '../../../lib/db';
// Öffentlicher Endpunkt kein Login nötig, CV muss public=1 sein
export const GET: APIRoute = async ({ params, request }) => {
const cv = getCVByHash(params.hash!);
if (!cv) return new Response('Not Found', { status: 404 });
const profile = getProfileById(cv.profile_id);
if (!profile) return new Response('Profile Not Found', { status: 404 });
const name = [profile.data.personal.firstName, profile.data.personal.lastName]
.filter(Boolean).join(' ') || cv.title;
const origin = new URL(request.url).origin;
const targetUrl = `${origin}/cv/${cv.hash}`;
try {
const { default: puppeteer } = await import('puppeteer');
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123 });
await page.goto(targetUrl, { waitUntil: 'networkidle0', timeout: 20000 });
await page.addStyleTag({
content: `
.cv-public-bar { display: none !important; }
body { background: white !important; padding: 0 !important; }
.cv-public-wrap { padding: 0 !important; background: white !important; }
`
});
const pdf = await page.pdf({
format: 'A4',
margin: { top: '0mm', right: '0mm', bottom: '0mm', left: '0mm' },
printBackground: true,
displayHeaderFooter: false,
});
await browser.close();
return new Response(pdf, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${encodeURIComponent(name)}.pdf"`,
},
});
} catch (err: any) {
console.error('[PDF Hash]', err.message);
return new Response(err.message, { status: 500 });
}
};

View File

@@ -1,41 +1,60 @@
--- ---
import { getCVByHash, getProfileById } from '../../lib/db'; import { getCVByHash, getProfileById } from "../../lib/db";
import { useT } from '../../lib/i18n'; import { useT } from "../../lib/i18n";
import Template1 from '../../components/templates/Template1.astro'; import Template1 from "../../components/templates/Template1.astro";
import Template2 from '../../components/templates/Template2.astro'; import Template2 from "../../components/templates/Template2.astro";
import Template3 from '../../components/templates/Template3.astro'; import Template3 from "../../components/templates/Template3.astro";
import Template4 from '../../components/templates/Template4.astro'; import Template4 from "../../components/templates/Template4.astro";
const { hash } = Astro.params; const { hash } = Astro.params;
const cv = getCVByHash(hash!); const cv = getCVByHash(hash!);
if (!cv) return new Response('Not Found', { status: 404 }); if (!cv) return new Response("Not Found", { status: 404 });
const profile = getProfileById(cv.profile_id); const profile = getProfileById(cv.profile_id);
if (!profile) return new Response('Profile Not Found', { status: 404 }); if (!profile) return new Response("Profile Not Found", { status: 404 });
const lang = cv.settings.language || 'de'; const lang = cv.settings.language || "de";
const t = useT(lang as any); const t = useT(lang as any);
const name = `${profile.data.personal.firstName} ${profile.data.personal.lastName}`.trim() || cv.title; const name =
const appUrl = import.meta.env.APP_URL || Astro.url.origin; `${profile.data.personal.firstName} ${profile.data.personal.lastName}`.trim() ||
cv.title;
const de = lang === "de";
--- ---
<!DOCTYPE html>
<!doctype html>
<html lang={lang}> <html lang={lang}>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{name} Lebenslauf</title> <title>{name} Lebenslauf</title>
<meta name="description" content={`Lebenslauf von ${name}`} /> <meta name="description" content={`Lebenslauf von ${name}`} />
<meta property="og:title" content={name} /> <meta property="og:title" content={name} />
<meta property="og:description" content={profile.data.personal.jobTitle || ''} /> <meta
property="og:description"
content={profile.data.personal.jobTitle || ""}
/>
<link rel="stylesheet" href="/styles/global.css" /> <link rel="stylesheet" href="/styles/global.css" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js" defer></script> 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"
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js" defer></script> rel="stylesheet"
/>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"
defer></script>
<style> <style>
body { background: #E8ECF0; } body {
.cv-public-wrap { min-height: 100vh; padding: 24px 16px; display: flex; flex-direction: column; align-items: center; gap: 20px; } background: #e8ecf0;
}
.cv-public-wrap {
min-height: 100vh;
padding: 24px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.cv-public-bar { .cv-public-bar {
width: 100%; width: 100%;
max-width: 794px; max-width: 794px;
@@ -45,82 +64,335 @@ const appUrl = import.meta.env.APP_URL || Astro.url.origin;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
box-shadow: 0 2px 8px rgba(0,0,0,.08); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 10px;
} }
.cv-info { display: flex; flex-direction: column; } .cv-info {
.cv-info-name { font-weight: 700; font-size: .95rem; } display: flex;
.cv-info-sub { font-size: .75rem; color: #888; } flex-direction: column;
.cv-actions { display: flex; gap: 8px; flex-wrap: wrap; } }
.cv-info-name {
font-weight: 700;
font-size: 0.95rem;
}
.cv-info-sub {
font-size: 0.75rem;
color: #888;
}
.cv-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pdf-dropdown {
position: relative;
display: inline-block;
}
.pdf-dropdown-menu {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
min-width: 200px;
z-index: 50;
}
.pdf-dropdown-menu.open {
display: block;
}
.pdf-dropdown-menu button {
width: 100%;
text-align: left;
padding: 9px 14px;
border: none;
background: none;
font-size: 0.83rem;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 1px;
}
.pdf-dropdown-menu button:hover {
background: #f5f5f5;
}
.pdf-dropdown-menu button span {
font-size: 0.72rem;
color: #999;
}
.pdf-dropdown-menu hr {
border: none;
border-top: 1px solid #eee;
margin: 2px 0;
}
@media (max-width: 840px) { @media (max-width: 840px) {
.cv-public-bar { max-width: 100%; } .cv-public-bar {
.cv-a4 { width: 100% !important; min-height: auto !important; } max-width: 100%;
}
.cv-a4 {
width: 100% !important;
min-height: auto !important;
}
} }
@media print { @media print {
.cv-public-bar { display: none !important; } .cv-public-bar {
body { background: white; } display: none !important;
.cv-public-wrap { padding: 0; background: white; } }
body {
background: white;
}
.cv-public-wrap {
padding: 0;
background: white;
}
} }
</style> </style>
</head> </head>
<body> <body>
<div class="cv-public-wrap"> <div class="cv-public-wrap">
<div class="cv-public-bar"> <div class="cv-public-bar">
<div class="cv-info"> <div class="cv-info">
<span class="cv-info-name">{name}</span> <span class="cv-info-name">{name}</span>
<span class="cv-info-sub">{profile.data.personal.jobTitle || ''}</span> <span class="cv-info-sub">{profile.data.personal.jobTitle || ""}</span
>
</div> </div>
<div class="cv-actions"> <div class="cv-actions">
<button onclick="exportPDF()" class="btn btn-primary btn-sm">↓ PDF</button> <div class="pdf-dropdown">
<button onclick="exportHTML()" class="btn btn-ghost btn-sm">🌐 HTML</button> <button onclick="togglePdfMenu()" class="btn btn-primary btn-sm"
<button onclick="exportJSON()" class="btn btn-ghost btn-sm">{ } JSON</button> >↓ PDF ▾</button
<button onclick="window.print()" class="btn btn-ghost btn-sm">🖨️ {lang==='de'?'Drucken':'Print'}</button> >
<div class="pdf-dropdown-menu" id="pdf-menu">
<button onclick="exportPDFPrint()">
🖨️ {de ? "Browser-Druck" : "Browser Print"}
<span
>{
de
? "Empfohlen Icons & Farben korrekt"
: "Recommended icons & colors correct"
}</span
>
</button>
<hr />
<button id="btn-puppeteer" onclick="exportPDFPuppeteer(event)">
📄 {de ? "PDF Download" : "PDF Download"}
<span
>{
de
? "Serverseitig, beste Qualität"
: "Server-side, best quality"
}</span
>
</button>
</div>
</div>
<button onclick="exportHTML()" class="btn btn-ghost btn-sm"
>🌐 HTML</button
>
<button onclick="exportJSON()" class="btn btn-ghost btn-sm"
>{} JSON</button
>
<a href="/" class="btn btn-ghost btn-sm">🏠 App</a> <a href="/" class="btn btn-ghost btn-sm">🏠 App</a>
</div> </div>
</div> </div>
<div id="cv-content"> <div id="cv-content">
{cv.template === 1 && <Template1 data={profile.data} settings={cv.settings} />} {
{cv.template === 2 && <Template2 data={profile.data} settings={cv.settings} />} cv.template === 1 && (
{cv.template === 3 && <Template3 data={profile.data} settings={cv.settings} />} <Template1 data={profile.data} settings={cv.settings} />
{cv.template === 4 && <Template4 data={profile.data} settings={cv.settings} />} )
}
{
cv.template === 2 && (
<Template2 data={profile.data} settings={cv.settings} />
)
}
{
cv.template === 3 && (
<Template3 data={profile.data} settings={cv.settings} />
)
}
{
cv.template === 4 && (
<Template4 data={profile.data} settings={cv.settings} />
)
}
</div> </div>
</div> </div>
<script define:vars={{ cvTitle: name, cvData: profile.data, cvSettings: cv.settings }}> <script
window.exportPDF = function() { define:vars={{
const el = document.getElementById('cv-content'); cvTitle: name,
window.html2pdf().set({ cvData: profile.data,
margin: 0, cvSettings: cv.settings,
filename: cvTitle + '.pdf', cvTemplate: cv.template,
image: { type: 'jpeg', quality: 0.98 }, cvHash: cv.hash,
html2canvas: { scale: 2, useCORS: true }, de,
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' } }}
}).from(el).save(); >
window.togglePdfMenu = function () {
document.getElementById("pdf-menu").classList.toggle("open");
};
document.addEventListener("click", (e) => {
if (!e.target.closest(".pdf-dropdown")) {
document.getElementById("pdf-menu").classList.remove("open");
}
});
// ── printCV inline ────────────────────────────────────────────
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="${fontUrl}" rel="stylesheet">
<style>
${globalCss}
${inlineStyles}
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; border: 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; }
@page { size: A4 portrait; margin: 0; }
@page :first { margin: 0; }
</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("pdf-menu").classList.remove("open");
const el = document.getElementById("cv-content");
printCV(el, cvTitle, cvSettings, cvTemplate);
}; };
window.exportHTML = function() { // ── PDF via Puppeteer (serverseitig) ──────────────────────────
const el = document.getElementById('cv-content'); window.exportPDFPuppeteer = async function (e) {
const styles = Array.from(document.styleSheets).map(ss => { document.getElementById("pdf-menu").classList.remove("open");
try { return Array.from(ss.cssRules).map(r => r.cssText).join('\n'); } catch { return ''; } const btn = document.getElementById("btn-puppeteer");
}).join('\n'); const origHtml = btn?.innerHTML;
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${cvTitle}</title><style>${styles}</style></head><body style="margin:0;background:#E8ECF0;display:flex;justify-content:center;padding:32px">${el.innerHTML}</body></html>`; if (btn)
const a = document.createElement('a'); btn.innerHTML = "⏳ " + (de ? "Generiert..." : "Generating...");
a.href = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
a.download = cvTitle + '.html'; try {
const res = await fetch(`/api/pdf/${cvHash}`);
if (!res.ok) {
const msg = await res.text();
throw new Error(msg);
}
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = cvTitle + ".pdf";
a.click();
URL.revokeObjectURL(a.href);
} catch (err) {
alert((de ? "PDF-Fehler: " : "PDF error: ") + err.message);
} finally {
if (btn && origHtml) btn.innerHTML = origHtml;
}
};
// ── HTML Export ───────────────────────────────────────────────
window.exportHTML = async function () {
const el = document.getElementById("cv-content");
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.innerHTML}
</body>
</html>`;
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([html], { type: "text/html" }));
a.download = cvTitle + ".html";
a.click(); a.click();
}; };
window.exportJSON = function() { // ── JSON Export ───────────────────────────────────────────────
const a = document.createElement('a'); window.exportJSON = function () {
a.href = URL.createObjectURL(new Blob( const a = document.createElement("a");
[JSON.stringify({ title: cvTitle, settings: cvSettings, data: cvData }, null, 2)], a.href = URL.createObjectURL(
{ type: 'application/json' } new Blob(
)); [
a.download = cvTitle + '.json'; JSON.stringify(
{ title: cvTitle, settings: cvSettings, data: cvData },
null,
2,
),
],
{ type: "application/json" },
),
);
a.download = cvTitle + ".json";
a.click(); a.click();
}; };
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,11 +1,11 @@
--- ---
import { getCVById, getProfileById } from '../../lib/db'; import { getCVById, getProfileById } from "../../lib/db";
import { getLang, useT } from '../../lib/i18n'; import { getLang, useT } from "../../lib/i18n";
import { TEMPLATES } from '../../types'; import { TEMPLATES } from "../../types";
import Template1 from '../../components/templates/Template1.astro'; import Template1 from "../../components/templates/Template1.astro";
import Template2 from '../../components/templates/Template2.astro'; import Template2 from "../../components/templates/Template2.astro";
import Template3 from '../../components/templates/Template3.astro'; import Template3 from "../../components/templates/Template3.astro";
import Template4 from '../../components/templates/Template4.astro'; import Template4 from "../../components/templates/Template4.astro";
const { id } = Astro.params; const { id } = Astro.params;
const user = Astro.locals.user; const user = Astro.locals.user;
@@ -13,47 +13,115 @@ const lang = getLang(Astro.request);
const t = useT(lang); const t = useT(lang);
const cv = getCVById(id!, user.id); 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); 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 d = profile.data;
const s = cv.settings; 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}> <html lang={lang}>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{cv.title} {t('app.name')}</title> <title>{cv.title} {t("app.name")}</title>
<link rel="stylesheet" href="/styles/global.css" /> <link rel="stylesheet" href="/styles/global.css" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js" defer></script> 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"
</head> rel="stylesheet"
<body> />
</head>
<body>
<header class="app-header"> <header class="app-header">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="/dashboard" class="logo" style="font-size:.95rem">Lebenslauf<span>App</span></a> <a href="/dashboard" class="logo" style="font-size:.95rem"
>Lebenslauf<span>App</span></a
>
<span style="color:rgba(255,255,255,.3)"></span> <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> <span
id="cv-title-display"
style="color:rgba(255,255,255,.9);font-size:.9rem">{cv.title}</span
>
</div> </div>
<div class="flex items-center gap-2"> <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> <a
<span id="save-status" class="save-status hidden">✓ {t('editor.save')}</span> href={`/profile/${profile.id}`}
<button id="toggle-preview" class="btn btn-ghost btn-sm" style="display:none"> class="btn btn-ghost btn-sm"
{de ? '👁 Vorschau' : '👁 Preview'} 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> </button>
<div style="position:relative"> <div style="position:relative">
<button id="export-btn" class="btn btn-accent btn-sm">↓ {t('editor.export')}</button> <button id="export-btn" class="btn btn-accent btn-sm"
<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"> >↓ {t("editor.export")}</button
<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
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> </div>
</div> </div>
@@ -63,101 +131,195 @@ function j(v: any) { return JSON.stringify(v).replace(/</g, '\\u003c'); }
<!-- SIDEBAR --> <!-- SIDEBAR -->
<div class="editor-sidebar"> <div class="editor-sidebar">
<div class="editor-tabs" id="editor-tabs"> <div class="editor-tabs" id="editor-tabs">
<button class="editor-tab active" data-tab="settings">{t('editor.tab.settings')}</button> <button class="editor-tab active" data-tab="settings"
>{t("editor.tab.settings")}</button
>
</div> </div>
<div class="editor-content" id="editor-content"> <div class="editor-content" id="editor-content">
<!-- SETTINGS -->
<div class="tab-pane" id="tab-settings"> <div class="tab-pane" id="tab-settings">
<div class="form-group"> <div class="form-group">
<label class="form-label">{t('settings.template')}</label> <label class="form-label">{t("settings.template")}</label>
<div class="template-grid" id="template-grid"> <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)"> TEMPLATES.map((tpl) => (
<div class="template-thumb"><span style="color:white;font-size:1.3rem">T{tpl.id}</span></div> <div
<div class="template-label"><strong>{tpl.name}</strong><br/><span style="font-size:.7rem;color:#888">{tpl.desc}</span></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>
))} <div class="template-label">
<>
<strong>{tpl.name}</strong>
<br />
</>
<span style="font-size:.7rem;color:#888">
{tpl.desc}
</span>
</div>
</div>
))
}
</div> </div>
</div> </div>
<hr class="divider" /> <hr class="divider" />
<div class="form-group"> <div class="form-group">
<label class="form-label">{t('settings.colors')}</label> <label class="form-label">{t("settings.colors")}</label>
<div class="color-row mt-1"> <div class="color-row mt-1">
<div class="color-swatch"><input type="color" id="color-primary" value={s.primaryColor} /></div> <div class="color-swatch">
<span class="text-sm">{t('settings.primaryColor')}</span> <input
type="color"
id="color-primary"
value={s.primaryColor}
/>
</div>
<span class="text-sm">{t("settings.primaryColor")}</span>
</div> </div>
<div class="color-row mt-2"> <div class="color-row mt-2">
<div class="color-swatch"><input type="color" id="color-accent" value={s.accentColor} /></div> <div class="color-swatch">
<span class="text-sm">{t('settings.accentColor')}</span> <input type="color" id="color-accent" value={s.accentColor} />
</div>
<span class="text-sm">{t("settings.accentColor")}</span>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">{de ? 'Text Schriftart' : 'Text Font'}</label> <label class="form-label"
>{de ? "Text Schriftart" : "Text Font"}</label
>
<select id="font-family" class="form-input form-select"> <select id="font-family" class="form-input form-select">
{[ {
["'Inter', sans-serif", 'Inter'], [
["'Roboto', sans-serif", 'Roboto'], ["'Inter', sans-serif", "Inter"],
["'Montserrat', sans-serif", 'Montserrat'], ["'Roboto', sans-serif", "Roboto"],
["'Outfit', sans-serif", 'Outfit'], ["'Montserrat', sans-serif", "Montserrat"],
["'Lora', serif", 'Lora'], ["'Outfit', sans-serif", "Outfit"],
['Arial, sans-serif', 'Arial'], ["'Lora', serif", "Lora"],
["Arial, sans-serif", "Arial"],
].map(([val, label]) => ( ].map(([val, label]) => (
<option value={val} selected={s.fontFamily?.replace(/"/g,"'") === val || s.fontFamily === val}>{label}</option> <option
))} value={val}
selected={
s.fontFamily?.replace(/"/g, "'") === val ||
s.fontFamily === val
}
>
{label}
</option>
))
}
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">{de ? 'Überschriften Schriftart' : 'Heading Font'}</label> <label class="form-label"
>{de ? "Überschriften Schriftart" : "Heading Font"}</label
>
<select id="font-heading" class="form-input form-select"> <select id="font-heading" class="form-input form-select">
{[ {
["'Inter', sans-serif", 'Inter'], [
["'Montserrat', sans-serif", 'Montserrat'], ["'Inter', sans-serif", "Inter"],
["'Playfair Display', serif", 'Playfair Display'], ["'Montserrat', sans-serif", "Montserrat"],
["'Oswald', sans-serif", 'Oswald'], ["'Playfair Display', serif", "Playfair Display"],
["'Merriweather', serif", 'Merriweather'], ["'Oswald', sans-serif", "Oswald"],
['Arial, sans-serif', 'Arial'], ["'Merriweather', serif", "Merriweather"],
["Arial, sans-serif", "Arial"],
].map(([val, label]) => ( ].map(([val, label]) => (
<option value={val} selected={(s.fontHeading || s.fontFamily)?.replace(/"/g,"'") === val || (s.fontHeading || s.fontFamily) === val}>{label}</option> <option
))} value={val}
selected={
(s.fontHeading || s.fontFamily)?.replace(/"/g, "'") ===
val || (s.fontHeading || s.fontFamily) === val
}
>
{label}
</option>
))
}
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">{t('settings.language')}</label> <label class="form-label">{t("settings.language")}</label>
<select id="cv-lang" class="form-input form-select"> <select id="cv-lang" class="form-input form-select">
<option value="de" selected={s.language === 'de'}>Deutsch</option> <option value="de" selected={s.language === "de"}
<option value="en" selected={s.language === 'en'}>English</option> >Deutsch</option
>
<option value="en" selected={s.language === "en"}
>English</option
>
</select> </select>
</div> </div>
<div class="form-group flex items-center gap-2" style="flex-direction:row;align-items:center"> <div
<input type="checkbox" id="show-photo" checked={s.showPhoto} style="width:16px;height:16px" /> class="form-group flex items-center gap-2"
<label for="show-photo" class="form-label" style="margin:0">{t('settings.showPhoto')}</label> 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> </div>
<hr class="divider" /> <hr class="divider" />
<div class="form-group"> <div class="form-group">
<div class="flex items-center gap-2" style="flex-direction:row;align-items:center;margin-bottom:8px"> <div
<input type="checkbox" id="cv-public" checked={cv.public} style="width:16px;height:16px" /> class="flex items-center gap-2"
<label for="cv-public" class="form-label" style="margin:0">{t('settings.public')}</label> 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> </div>
{cv.public && ( {
cv.public && (
<div class="flex gap-2"> <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" /> <input
<button class="btn btn-ghost btn-sm" onclick="copyLink()">{t('settings.copy')}</button> 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> </div>
</div> </div>
</div> </div>
<!-- PREVIEW --> <!-- PREVIEW -->
<div class="editor-preview"> <div class="editor-preview">
<div class="editor-preview-toolbar"> <div class="editor-preview-toolbar">
<span style="font-size:.8rem;color:var(--muted)">{de ? 'Vorschau (A4)' : 'Preview (A4)'}</span> <span style="font-size:.8rem;color:var(--muted)"
>{de ? "Vorschau (A4)" : "Preview (A4)"}</span
>
<div class="flex gap-2"> <div class="flex gap-2">
<span id="preview-save-status" class="save-status hidden text-xs">✓</span> <span id="preview-save-status" class="save-status hidden text-xs"
<button onclick="exportPDF()" class="btn btn-accent btn-sm">↓ PDF</button> >✓</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> </div>
<div class="preview-wrap" id="preview-wrap"> <div class="preview-wrap" id="preview-wrap">
@@ -173,7 +335,8 @@ function j(v: any) { return JSON.stringify(v).replace(/</g, '\\u003c'); }
</div> </div>
</div> </div>
<script define:vars={{ <script
define:vars={{
cvId: cv.id, cvId: cv.id,
cvHash: cv.hash, cvHash: cv.hash,
cvTitle: cv.title, cvTitle: cv.title,
@@ -184,151 +347,295 @@ function j(v: any) { return JSON.stringify(v).replace(/</g, '\\u003c'); }
isPublic: cv.public, isPublic: cv.public,
de, de,
origin: Astro.url.origin, origin: Astro.url.origin,
}}> }}
// ── State ──────────────────────────────────────────────────────── >
let cvSettings = JSON.parse(initSettings); let cvSettings = JSON.parse(initSettings);
let cvData = JSON.parse(initData); let cvData = JSON.parse(initData);
let template = initTemplate; let template = initTemplate;
let saveTimer = null; let saveTimer = null;
let isDirty = false; let isDirty = false;
// ── Settings ───────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────
document.getElementById('color-primary').addEventListener('input', function() { cvSettings.primaryColor = this.value; markDirty(); }); document
document.getElementById('color-accent').addEventListener('input', function() { cvSettings.accentColor = this.value; markDirty(); }); .getElementById("color-primary")
document.getElementById('font-family').addEventListener('change', function() { cvSettings.fontFamily = this.value; markDirty(); }); .addEventListener("input", function () {
document.getElementById('font-heading').addEventListener('change', function() { cvSettings.fontHeading = this.value; markDirty(); }); cvSettings.primaryColor = this.value;
document.getElementById('cv-lang').addEventListener('change', function() { cvSettings.language = this.value; markDirty(); }); markDirty();
document.getElementById('show-photo').addEventListener('change', function() { cvSettings.showPhoto = this.checked; markDirty(); }); });
document.getElementById('cv-public').addEventListener('change', async function() { 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 }); await saveNow({ public: this.checked });
const linkRow = document.getElementById('public-link'); if (this.checked && !document.getElementById("public-link")) {
if (this.checked) { document
if (!linkRow) { .getElementById("cv-public")
document.getElementById('cv-public').parentElement.insertAdjacentHTML('afterend', .parentElement.insertAdjacentHTML(
`<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>`); "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) { window.selectTemplate = function (el) {
document.querySelectorAll('.template-option').forEach(e => e.classList.remove('selected')); document
el.classList.add('selected'); .querySelectorAll(".template-option")
.forEach((e) => e.classList.remove("selected"));
el.classList.add("selected");
template = parseInt(el.dataset.id); template = parseInt(el.dataset.id);
markDirty(); markDirty();
}; };
window.copyLink = function() { window.copyLink = function () {
const link = document.getElementById('public-link'); const link = document.getElementById("public-link");
if (link) { navigator.clipboard.writeText(link.value); } if (link) navigator.clipboard.writeText(link.value);
}; };
// ── Save & Preview ─────────────────────────────────────────────── // ── Save & Preview ────────────────────────────────────────────
function markDirty() { function markDirty() {
isDirty = true; isDirty = true;
clearTimeout(saveTimer); clearTimeout(saveTimer);
saveTimer = setTimeout(saveNow, 2000); saveTimer = setTimeout(saveNow, 2000);
const s = document.getElementById('save-status'); const s = document.getElementById("save-status");
s.className = 'save-status saving'; s.className = "save-status saving";
s.textContent = de ? '⏳ Speichert...' : '⏳ Saving...'; s.textContent = de ? "⏳ Speichert..." : "⏳ Saving...";
s.classList.remove('hidden'); s.classList.remove("hidden");
refreshPreview(); refreshPreview();
} }
async function saveNow(extra = {}) { async function saveNow(extra = {}) {
const body = { const res = await fetch(`/api/cv/${cvId}/update`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: cvTitle, title: cvTitle,
template, template,
settings: cvSettings, settings: cvSettings,
...extra ...extra,
}; }),
const res = await fetch(`/api/cv/${cvId}/update`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}); });
if (res.ok) { if (res.ok) {
isDirty = false; isDirty = false;
const s = document.getElementById('save-status'); const s = document.getElementById("save-status");
s.className = 'save-status'; s.className = "save-status";
s.textContent = '' + (de ? 'Gespeichert' : 'Saved'); s.textContent = "" + (de ? "Gespeichert" : "Saved");
setTimeout(() => s.classList.add('hidden'), 3000); setTimeout(() => s.classList.add("hidden"), 3000);
} }
} }
async function refreshPreview() { async function refreshPreview() {
const preview = document.getElementById('cv-preview');
// Using a new parameter format: profile_id
const res = await fetch(`/api/cv/${cvId}/preview`, { const res = await fetch(`/api/cv/${cvId}/preview`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, settings: cvSettings, profile_id: profileId }) body: JSON.stringify({
template,
settings: cvSettings,
profile_id: profileId,
}),
}); });
if (res.ok) { if (res.ok) {
preview.innerHTML = await res.text(); document.getElementById("cv-preview").innerHTML = await res.text();
} }
} }
// Scale preview
function scalePreview() { function scalePreview() {
const wrap = document.getElementById('preview-wrap'); const wrap = document.getElementById("preview-wrap");
const scale = document.getElementById('preview-scale'); const scale = document.getElementById("preview-scale");
if (!wrap || !scale) return; if (!wrap || !scale) return;
const available = wrap.clientWidth - 48; const factor = Math.min(1, (wrap.clientWidth - 48) / 794);
const factor = Math.min(1, available / 794);
scale.style.transform = `scale(${factor})`; scale.style.transform = `scale(${factor})`;
scale.style.transformOrigin = 'top center'; scale.style.transformOrigin = "top center";
scale.style.marginBottom = `${(1 - factor) * -1123}px`; scale.style.marginBottom = `${(1 - factor) * -1123}px`;
} }
window.addEventListener('resize', scalePreview); window.addEventListener("resize", scalePreview);
scalePreview(); scalePreview();
// ── Export ─────────────────────────────────────────────────────── // ── Export Menu ───────────────────────────────────────────────
document.getElementById('export-btn').addEventListener('click', () => { document.getElementById("export-btn").addEventListener("click", () => {
document.getElementById('export-menu').classList.toggle('hidden'); document.getElementById("export-menu").classList.toggle("hidden");
}); });
document.addEventListener('click', e => { document.addEventListener("click", (e) => {
if (!e.target.closest('#export-btn') && !e.target.closest('#export-menu')) { if (
document.getElementById('export-menu').classList.add('hidden'); !e.target.closest("#export-btn") &&
!e.target.closest("#export-menu")
) {
document.getElementById("export-menu").classList.add("hidden");
} }
}); });
window.exportPDF = function() { // ── printCV (inline, kein externer Script nötig) ─────────────
const el = document.getElementById('cv-preview'); async function printCV(el, title, settings, tpl) {
const opt = { let globalCss = "";
margin: 0, try {
filename: (cvTitle || 'lebenslauf') + '.pdf', globalCss = await fetch("/styles/global.css").then((r) => r.text());
image: { type: 'jpeg', quality: 0.98 }, } catch (e) {}
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
};
window.html2pdf().set(opt).from(el).save();
};
window.exportHTML = function() { const inlineStyles = Array.from(document.querySelectorAll("style"))
const el = document.getElementById('cv-preview'); .map((s) => s.textContent)
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>`; .join("\n");
download(html, cvTitle + '.html', 'text/html');
};
function getAllStyles() { const fontUrl =
return Array.from(document.styleSheets).map(ss => { "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";
try { return Array.from(ss.cssRules).map(r => r.cssText).join('\n'); } catch { return ''; }
}).join('\n'); 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);
});
} }
function download(content, filename, mime) { // ── PDF via Browser-Druck ─────────────────────────────────────
const a = document.createElement('a'); window.exportPDFPrint = function () {
a.href = URL.createObjectURL(new Blob([content], { type: mime })); document.getElementById("export-menu").classList.add("hidden");
a.download = filename; a.click(); const el = document.getElementById("cv-preview");
} printCV(el, cvTitle, cvSettings, template);
};
// ── Mobile toggle ───────────────────────────────────────────────── // ── 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("/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) { if (window.innerWidth <= 900) {
document.getElementById('toggle-preview').style.display = 'inline-flex'; document.getElementById("toggle-preview").style.display = "inline-flex";
} }
document.getElementById('toggle-preview')?.addEventListener('click', () => { document
document.getElementById('editor-layout').classList.toggle('show-preview'); .getElementById("toggle-preview")
?.addEventListener("click", () => {
document
.getElementById("editor-layout")
.classList.toggle("show-preview");
}); });
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,11 @@
/* ── Reset & Base ─────────────────────────────────────────────────── */ /* ── Reset & Base ─────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root { :root {
--navy: #1B2A5E; --navy: #1B2A5E;
@@ -12,16 +18,40 @@
--danger: #E53E3E; --danger: #E53E3E;
--success: #38A169; --success: #38A169;
--radius: 8px; --radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,.1), 0 1px 2px rgba(0,0,0,.06); --shadow: 0 1px 3px rgba(0, 0, 0, .1), 0 1px 2px rgba(0, 0, 0, .06);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -1px rgba(0,0,0,.06); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -1px rgba(0, 0, 0, .06);
} }
html { font-size: 16px; } html {
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; } font-size: 16px;
a { color: var(--accent); text-decoration: none; } }
a:hover { text-decoration: underline; }
button { cursor: pointer; font-family: inherit; } body {
input, textarea, select { font-family: inherit; } font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
cursor: pointer;
font-family: inherit;
}
input,
textarea,
select {
font-family: inherit;
}
/* ── Layout ───────────────────────────────────────────────────────── */ /* ── Layout ───────────────────────────────────────────────────────── */
.app-header { .app-header {
@@ -37,13 +67,37 @@ input, textarea, select { font-family: inherit; }
z-index: 100; z-index: 100;
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
.app-header .logo { font-size: 1.1rem; font-weight: 700; color: white; letter-spacing: -0.3px; }
.app-header .logo span { color: var(--accent); } .app-header .logo {
.app-header nav { display: flex; align-items: center; gap: 16px; } font-size: 1.1rem;
.app-header nav a { color: rgba(255,255,255,.8); font-size: .875rem; transition: color .2s; } font-weight: 700;
.app-header nav a:hover { color: white; text-decoration: none; } color: white;
letter-spacing: -0.3px;
}
.app-header .logo span {
color: var(--accent);
}
.app-header nav {
display: flex;
align-items: center;
gap: 16px;
}
.app-header nav a {
color: rgba(255, 255, 255, .8);
font-size: .875rem;
transition: color .2s;
}
.app-header nav a:hover {
color: white;
text-decoration: none;
}
.app-header .btn-logout { .app-header .btn-logout {
background: rgba(255,255,255,.12); background: rgba(255, 255, 255, .12);
color: white; color: white;
border: none; border: none;
padding: 6px 14px; padding: 6px 14px;
@@ -51,56 +105,182 @@ input, textarea, select { font-family: inherit; }
font-size: .875rem; font-size: .875rem;
transition: background .2s; transition: background .2s;
} }
.app-header .btn-logout:hover { background: rgba(255,255,255,.22); }
.container { max-width: 1200px; margin: 0 auto; padding: 0 24px; } .app-header .btn-logout:hover {
.page { padding: 32px 24px; max-width: 1200px; margin: 0 auto; } background: rgba(255, 255, 255, .22);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
.page {
padding: 32px 24px;
max-width: 1200px;
margin: 0 auto;
}
/* ── Buttons ──────────────────────────────────────────────────────── */ /* ── Buttons ──────────────────────────────────────────────────────── */
.btn { .btn {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex;
padding: 8px 16px; border-radius: var(--radius); align-items: center;
font-size: .875rem; font-weight: 500; gap: 6px;
border: none; transition: all .15s; padding: 8px 16px;
border-radius: var(--radius);
font-size: .875rem;
font-weight: 500;
border: none;
transition: all .15s;
text-decoration: none; text-decoration: none;
} }
.btn-primary { background: var(--navy); color: white; }
.btn-primary:hover { background: #243572; text-decoration: none; } .btn-primary {
.btn-accent { background: var(--accent); color: white; } background: var(--navy);
.btn-accent:hover { background: #3a6ab5; text-decoration: none; } color: white;
.btn-ghost { background: transparent; color: var(--text); border: 1px solid var(--border); } }
.btn-ghost:hover { background: var(--bg); text-decoration: none; }
.btn-danger { background: var(--danger); color: white; border: none; } .btn-primary:hover {
.btn-danger:hover { background: #c53030; } background: #243572;
.btn-sm { padding: 5px 10px; font-size: .8rem; } text-decoration: none;
.btn-lg { padding: 12px 24px; font-size: 1rem; } }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-accent {
background: var(--accent);
color: white;
}
.btn-accent:hover {
background: #3a6ab5;
text-decoration: none;
}
.btn-ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.btn-ghost:hover {
background: var(--bg);
text-decoration: none;
}
.btn-danger {
background: var(--danger);
color: white;
border: none;
}
.btn-danger:hover {
background: #c53030;
}
.btn-sm {
padding: 5px 10px;
font-size: .8rem;
}
.btn-lg {
padding: 12px 24px;
font-size: 1rem;
}
.btn:disabled {
opacity: .5;
cursor: not-allowed;
}
/* ── Cards ────────────────────────────────────────────────────────── */ /* ── Cards ────────────────────────────────────────────────────────── */
.card { .card {
background: var(--surface); border: 1px solid var(--border); background: var(--surface);
border-radius: var(--radius); box-shadow: var(--shadow); border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden; overflow: hidden;
} }
.card-body { padding: 24px; }
.card-header { padding: 16px 24px; border-bottom: 1px solid var(--border); background: var(--bg); } .card-body {
.card-footer { padding: 16px 24px; border-top: 1px solid var(--border); background: var(--bg); } padding: 24px;
}
.card-header {
padding: 16px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.card-footer {
padding: 16px 24px;
border-top: 1px solid var(--border);
background: var(--bg);
}
/* ── Forms ────────────────────────────────────────────────────────── */ /* ── Forms ────────────────────────────────────────────────────────── */
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; } .form-group {
.form-label { font-size: .875rem; font-weight: 500; color: var(--text); } display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
.form-label {
font-size: .875rem;
font-weight: 500;
color: var(--text);
}
.form-input { .form-input {
padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 12px;
font-size: .875rem; background: var(--surface); color: var(--text); border: 1px solid var(--border);
border-radius: var(--radius);
font-size: .875rem;
background: var(--surface);
color: var(--text);
transition: border-color .15s, box-shadow .15s; transition: border-color .15s, box-shadow .15s;
} }
.form-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(74,123,197,.15); }
.form-textarea { min-height: 80px; resize: vertical; } .form-input:focus {
.form-select { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; padding-right: 32px; } outline: none;
.form-hint { font-size: .75rem; color: var(--muted); } border-color: var(--accent);
.form-error { font-size: .75rem; color: var(--danger); } box-shadow: 0 0 0 3px rgba(74, 123, 197, .15);
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } }
@media (max-width: 600px) { .form-row { grid-template-columns: 1fr; } }
.form-textarea {
min-height: 80px;
resize: vertical;
}
.form-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 32px;
}
.form-hint {
font-size: .75rem;
color: var(--muted);
}
.form-error {
font-size: .75rem;
color: var(--danger);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
}
/* ── Auth Page ────────────────────────────────────────────────────── */ /* ── Auth Page ────────────────────────────────────────────────────── */
.auth-page { .auth-page {
@@ -110,24 +290,50 @@ input, textarea, select { font-family: inherit; }
background: linear-gradient(135deg, var(--navy) 0%, #2d4a9e 100%); background: linear-gradient(135deg, var(--navy) 0%, #2d4a9e 100%);
padding: 24px; padding: 24px;
} }
.auth-card { .auth-card {
width: 100%; width: 100%;
max-width: 420px; max-width: 420px;
background: white; background: white;
border-radius: 12px; border-radius: 12px;
padding: 40px 36px; padding: 40px 36px;
box-shadow: 0 20px 60px rgba(0,0,0,.3); box-shadow: 0 20px 60px rgba(0, 0, 0, .3);
} }
.auth-logo { font-size: 1.5rem; font-weight: 800; color: var(--navy); margin-bottom: 8px; }
.auth-logo span { color: var(--accent); } .auth-logo {
.auth-subtitle { color: var(--muted); font-size: .875rem; margin-bottom: 32px; } font-size: 1.5rem;
font-weight: 800;
color: var(--navy);
margin-bottom: 8px;
}
.auth-logo span {
color: var(--accent);
}
.auth-subtitle {
color: var(--muted);
font-size: .875rem;
margin-bottom: 32px;
}
.otp-input { .otp-input {
width: 100%; text-align: center; font-size: 2rem; font-weight: 700; width: 100%;
letter-spacing: 12px; padding: 16px; border: 2px solid var(--border); text-align: center;
border-radius: var(--radius); font-family: monospace; font-size: 2rem;
font-weight: 700;
letter-spacing: 12px;
padding: 16px;
border: 2px solid var(--border);
border-radius: var(--radius);
font-family: monospace;
transition: border-color .15s; transition: border-color .15s;
} }
.otp-input:focus { outline: none; border-color: var(--accent); }
.otp-input:focus {
outline: none;
border-color: var(--accent);
}
/* ── Dashboard ────────────────────────────────────────────────────── */ /* ── Dashboard ────────────────────────────────────────────────────── */
.cv-grid { .cv-grid {
@@ -136,13 +342,21 @@ input, textarea, select { font-family: inherit; }
gap: 20px; gap: 20px;
margin-top: 24px; margin-top: 24px;
} }
.cv-card { .cv-card {
background: var(--surface); border: 1px solid var(--border); background: var(--surface);
border-radius: var(--radius); overflow: hidden; border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: box-shadow .2s, transform .2s; transition: box-shadow .2s, transform .2s;
cursor: pointer; cursor: pointer;
} }
.cv-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
.cv-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.cv-card-preview { .cv-card-preview {
height: 180px; height: 180px;
background: linear-gradient(135deg, var(--navy) 0%, #243572 40%, #3a6ab5 100%); background: linear-gradient(135deg, var(--navy) 0%, #243572 40%, #3a6ab5 100%);
@@ -151,30 +365,50 @@ input, textarea, select { font-family: inherit; }
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.cv-card-preview .t-badge { .cv-card-preview .t-badge {
position: absolute; position: absolute;
top: 10px; top: 10px;
right: 10px; right: 10px;
background: rgba(255,255,255,.2); background: rgba(255, 255, 255, .2);
color: white; color: white;
font-size: .7rem; font-size: .7rem;
padding: 2px 8px; padding: 2px 8px;
border-radius: 20px; border-radius: 20px;
} }
.cv-card-preview .preview-mini { .cv-card-preview .preview-mini {
width: 90px; width: 90px;
height: 127px; height: 127px;
background: white; background: white;
border-radius: 3px; border-radius: 3px;
box-shadow: 0 4px 20px rgba(0,0,0,.3); box-shadow: 0 4px 20px rgba(0, 0, 0, .3);
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.cv-card-body { padding: 16px; }
.cv-card-title { font-weight: 600; font-size: .95rem; margin-bottom: 4px; } .cv-card-body {
.cv-card-meta { font-size: .75rem; color: var(--muted); } padding: 16px;
.cv-card-actions { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); } }
.cv-card-title {
font-weight: 600;
font-size: .95rem;
margin-bottom: 4px;
}
.cv-card-meta {
font-size: .75rem;
color: var(--muted);
}
.cv-card-actions {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
}
/* ── Editor ───────────────────────────────────────────────────────── */ /* ── Editor ───────────────────────────────────────────────────────── */
.editor-layout { .editor-layout {
@@ -183,6 +417,7 @@ input, textarea, select { font-family: inherit; }
height: calc(100vh - 56px); height: calc(100vh - 56px);
overflow: hidden; overflow: hidden;
} }
.editor-sidebar { .editor-sidebar {
background: var(--surface); background: var(--surface);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
@@ -190,6 +425,7 @@ input, textarea, select { font-family: inherit; }
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.editor-tabs { .editor-tabs {
display: flex; display: flex;
overflow-x: auto; overflow-x: auto;
@@ -197,7 +433,11 @@ input, textarea, select { font-family: inherit; }
background: var(--bg); background: var(--bg);
scrollbar-width: none; scrollbar-width: none;
} }
.editor-tabs::-webkit-scrollbar { display: none; }
.editor-tabs::-webkit-scrollbar {
display: none;
}
.editor-tab { .editor-tab {
padding: 10px 14px; padding: 10px 14px;
font-size: .8rem; font-size: .8rem;
@@ -210,13 +450,22 @@ input, textarea, select { font-family: inherit; }
cursor: pointer; cursor: pointer;
transition: all .15s; transition: all .15s;
} }
.editor-tab.active { color: var(--navy); border-bottom-color: var(--navy); }
.editor-tab:hover { color: var(--text); } .editor-tab.active {
color: var(--navy);
border-bottom-color: var(--navy);
}
.editor-tab:hover {
color: var(--text);
}
.editor-content { .editor-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 20px; padding: 20px;
} }
.editor-preview { .editor-preview {
background: #E8ECF0; background: #E8ECF0;
display: flex; display: flex;
@@ -224,6 +473,7 @@ input, textarea, select { font-family: inherit; }
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
.editor-preview-toolbar { .editor-preview-toolbar {
background: var(--surface); background: var(--surface);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@@ -234,6 +484,7 @@ input, textarea, select { font-family: inherit; }
gap: 8px; gap: 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.preview-wrap { .preview-wrap {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
@@ -242,11 +493,13 @@ input, textarea, select { font-family: inherit; }
justify-content: center; justify-content: center;
padding: 24px; padding: 24px;
} }
.preview-scale-wrap { .preview-scale-wrap {
transform-origin: top center; transform-origin: top center;
width: 794px; width: 794px;
flex-shrink: 0; flex-shrink: 0;
} }
.save-status { .save-status {
font-size: .75rem; font-size: .75rem;
color: var(--success); color: var(--success);
@@ -254,7 +507,10 @@ input, textarea, select { font-family: inherit; }
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }
.save-status.saving { color: var(--muted); }
.save-status.saving {
color: var(--muted);
}
/* ── Section Items ────────────────────────────────────────────────── */ /* ── Section Items ────────────────────────────────────────────────── */
.item-card { .item-card {
@@ -263,6 +519,7 @@ input, textarea, select { font-family: inherit; }
margin-bottom: 12px; margin-bottom: 12px;
overflow: hidden; overflow: hidden;
} }
.item-card-header { .item-card-header {
padding: 10px 14px; padding: 10px 14px;
background: var(--bg); background: var(--bg);
@@ -272,29 +529,79 @@ input, textarea, select { font-family: inherit; }
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
} }
.item-card-title { font-size: .875rem; font-weight: 500; }
.item-card-body { padding: 14px; display: none; } .item-card-title {
.item-card.open .item-card-body { display: block; } font-size: .875rem;
.item-card-actions { display: flex; gap: 6px; } font-weight: 500;
}
.item-card-body {
padding: 14px;
display: none;
}
.item-card.open .item-card-body {
display: block;
}
.item-card-actions {
display: flex;
gap: 6px;
}
/* ── Skill level ──────────────────────────────────────────────────── */ /* ── Skill level ──────────────────────────────────────────────────── */
.skill-dots { display: flex; gap: 4px; align-items: center; } .skill-dots {
display: flex;
gap: 4px;
align-items: center;
}
.skill-dot { .skill-dot {
width: 14px; height: 14px; border-radius: 50%; width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--accent); border: 2px solid var(--accent);
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
transition: background .15s; transition: background .15s;
} }
.skill-dot.filled { background: var(--accent); }
.skill-dot.filled {
background: var(--accent);
}
/* ── Color picker ─────────────────────────────────────────────────── */ /* ── Color picker ─────────────────────────────────────────────────── */
.color-row { display: flex; align-items: center; gap: 10px; } .color-row {
.color-swatch { width: 36px; height: 36px; border-radius: 6px; border: 2px solid var(--border); cursor: pointer; overflow: hidden; padding: 0; } display: flex;
.color-swatch input[type=color] { width: 200%; height: 200%; margin: -50%; border: none; cursor: pointer; } align-items: center;
gap: 10px;
}
.color-swatch {
width: 36px;
height: 36px;
border-radius: 6px;
border: 2px solid var(--border);
cursor: pointer;
overflow: hidden;
padding: 0;
}
.color-swatch input[type=color] {
width: 200%;
height: 200%;
margin: -50%;
border: none;
cursor: pointer;
}
/* ── Template selector ────────────────────────────────────────────── */ /* ── Template selector ────────────────────────────────────────────── */
.template-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .template-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.template-option { .template-option {
border: 2px solid var(--border); border: 2px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
@@ -302,15 +609,28 @@ input, textarea, select { font-family: inherit; }
cursor: pointer; cursor: pointer;
transition: border-color .15s; transition: border-color .15s;
} }
.template-option.selected { border-color: var(--accent); }
.template-option:hover { border-color: var(--accent); } .template-option.selected {
border-color: var(--accent);
}
.template-option:hover {
border-color: var(--accent);
}
.template-thumb { .template-thumb {
height: 100px; height: 100px;
background: linear-gradient(135deg, #1B2A5E, #4A7BC5); background: linear-gradient(135deg, #1B2A5E, #4A7BC5);
display: grid; display: grid;
place-items: center; place-items: center;
} }
.template-label { padding: 8px; font-size: .75rem; font-weight: 500; text-align: center; }
.template-label {
padding: 8px;
font-size: .75rem;
font-weight: 500;
text-align: center;
}
/* ── CV Templates (A4) ────────────────────────────────────────────── */ /* ── CV Templates (A4) ────────────────────────────────────────────── */
.cv-a4 { .cv-a4 {
@@ -320,7 +640,7 @@ input, textarea, select { font-family: inherit; }
position: relative; position: relative;
font-size: 13px; font-size: 13px;
line-height: 1.4; line-height: 1.4;
box-shadow: 0 8px 32px rgba(0,0,0,.15); box-shadow: 0 8px 32px rgba(0, 0, 0, .15);
} }
/* ── Public CV Page ───────────────────────────────────────────────── */ /* ── Public CV Page ───────────────────────────────────────────────── */
@@ -333,6 +653,7 @@ input, textarea, select { font-family: inherit; }
align-items: center; align-items: center;
gap: 20px; gap: 20px;
} }
.cv-public-actions { .cv-public-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -341,45 +662,183 @@ input, textarea, select { font-family: inherit; }
} }
/* ── Alerts ───────────────────────────────────────────────────────── */ /* ── Alerts ───────────────────────────────────────────────────────── */
.alert { padding: 12px 16px; border-radius: var(--radius); font-size: .875rem; margin-bottom: 16px; } .alert {
.alert-success { background: #f0fff4; border: 1px solid #9ae6b4; color: #276749; } padding: 12px 16px;
.alert-error { background: #fff5f5; border: 1px solid #fed7d7; color: #9b2c2c; } border-radius: var(--radius);
.alert-info { background: #ebf8ff; border: 1px solid #bee3f8; color: #2a4a6b; } font-size: .875rem;
margin-bottom: 16px;
}
.alert-success {
background: #f0fff4;
border: 1px solid #9ae6b4;
color: #276749;
}
.alert-error {
background: #fff5f5;
border: 1px solid #fed7d7;
color: #9b2c2c;
}
.alert-info {
background: #ebf8ff;
border: 1px solid #bee3f8;
color: #2a4a6b;
}
/* ── Utils ────────────────────────────────────────────────────────── */ /* ── Utils ────────────────────────────────────────────────────────── */
.flex { display: flex; } .flex {
.items-center { align-items: center; } display: flex;
.justify-between { justify-content: space-between; } }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; } .items-center {
.mt-1 { margin-top: 4px; } align-items: center;
.mt-2 { margin-top: 8px; } }
.mt-4 { margin-top: 16px; }
.mt-6 { margin-top: 24px; } .justify-between {
.mb-2 { margin-bottom: 8px; } justify-content: space-between;
.mb-4 { margin-bottom: 16px; } }
.text-sm { font-size: .875rem; }
.text-xs { font-size: .75rem; } .gap-2 {
.text-muted { color: var(--muted); } gap: 8px;
.font-bold { font-weight: 700; } }
.font-semibold { font-weight: 600; }
.w-full { width: 100%; } .gap-3 {
.hidden { display: none !important; } gap: 12px;
.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; } }
.mt-1 {
margin-top: 4px;
}
.mt-2 {
margin-top: 8px;
}
.mt-4 {
margin-top: 16px;
}
.mt-6 {
margin-top: 24px;
}
.mb-2 {
margin-bottom: 8px;
}
.mb-4 {
margin-bottom: 16px;
}
.text-sm {
font-size: .875rem;
}
.text-xs {
font-size: .75rem;
}
.text-muted {
color: var(--muted);
}
.font-bold {
font-weight: 700;
}
.font-semibold {
font-weight: 600;
}
.w-full {
width: 100%;
}
.hidden {
display: none !important;
}
.divider {
border: none;
border-top: 1px solid var(--border);
margin: 20px 0;
}
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 900px) {
.editor-layout {
grid-template-columns: 1fr;
}
.editor-preview {
display: none;
}
.editor-layout.show-preview .editor-sidebar {
display: none;
}
.editor-layout.show-preview .editor-preview {
display: flex;
}
.preview-scale-wrap {
width: 100%;
transform: none !important;
}
.cv-a4 {
width: 100% !important;
}
}
/* ── Print ────────────────────────────────────────────────────────── */ /* ── Print ────────────────────────────────────────────────────────── */
@media print { @media print {
body { background: white !important; } @page {
.cv-public-actions, .app-header { display: none !important; } size: A4 portrait;
.cv-public-wrap { padding: 0 !important; background: white !important; } margin: 10mm 0 0 0;
.cv-a4 { box-shadow: none !important; } }
}
@media (max-width: 900px) { @page :first {
.editor-layout { grid-template-columns: 1fr; } margin: 0 0 0 0;
.editor-preview { display: none; } }
.editor-layout.show-preview .editor-sidebar { display: none; }
.editor-layout.show-preview .editor-preview { display: flex; } body,
.preview-scale-wrap { width: 100%; transform: none !important; } .cv-public-wrap,
.cv-a4 { width: 100% !important; } .editor-preview {
background: white !important;
padding: 0 !important;
}
.app-header,
.cv-public-bar,
.cv-public-actions,
.editor-sidebar,
.editor-preview-toolbar {
display: none !important;
}
.cv-a4 {
width: 210mm !important;
box-shadow: none !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;
page-break-inside: avoid;
}
} }