Karten-Editor: Upload-UI für Hintergrund-PDF & Schriften

- Assets-Leiste: Hintergrund-PDF hochladen/entfernen (+ "aktiv"-Badge),
  Schrift hochladen (TTF/OTF) mit Familien-Chips
- Schriftart-Auswahl pro Text/Feld (Helvetica/Times/Courier + eigene)
- Canvas-Hinweis bei aktivem Hintergrund (echte Darstellung in PDF-Vorschau)
- Uploads aktualisieren State ohne Verlust des aktuellen Layouts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-05-31 17:33:52 +02:00
parent b52d696cc5
commit f25ccefa48

View File

@ -8,6 +8,7 @@ interface Template {
id: string | null; isDefault: boolean; name: string id: string | null; isDefault: boolean; name: string
widthMm: number; heightMm: number; bleedMm: number; safeMm: number widthMm: number; heightMm: number; bleedMm: number; safeMm: number
front: El[]; back: El[] front: El[]; back: El[]
hasBackground?: boolean; fonts?: string[]
} }
interface Company { '@id': string; id: string; name: string; slug: string; brandingConfig: Record<string, string> | unknown[] } interface Company { '@id': string; id: string; name: string; slug: string; brandingConfig: Record<string, string> | unknown[] }
interface Employee { id: string; firstName: string; lastName: string; position: string | null; email: string | null; phone: string | null; mobile: string | null; company: string; department: string | null } interface Employee { id: string; firstName: string; lastName: string; position: string | null; email: string | null; phone: string | null; mobile: string | null; company: string; department: string | null }
@ -168,6 +169,38 @@ async function save() {
saved.value = true saved.value = true
} finally { saving.value = false } } finally { saving.value = false }
} }
// --- Druck-Assets: Hintergrund-PDF & Schriften ---
const uploading = ref(false)
function cidPath() { return selectedCompany.value?.id }
async function uploadBackground(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file || !tpl.value) return
uploading.value = true
try {
const fd = new FormData(); fd.append('file', file)
await client.post(`/companies/${cidPath()}/card-template/background`, fd)
tpl.value.hasBackground = true
} catch { alert('Upload fehlgeschlagen (nur PDF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' }
}
async function removeBackground() {
if (!tpl.value || !confirm('Hintergrund-PDF entfernen?')) return
await client.delete(`/companies/${cidPath()}/card-template/background`)
tpl.value.hasBackground = false
}
async function uploadFont(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file || !tpl.value) return
const family = (prompt('Name der Schriftart (z. B. „Brand")', file.name.replace(/\.(ttf|otf)$/i, '')) || '').trim()
if (!family) { (e.target as HTMLInputElement).value = ''; return }
uploading.value = true
try {
const fd = new FormData(); fd.append('file', file); fd.append('family', family)
await client.post(`/companies/${cidPath()}/card-template/font`, fd)
tpl.value.fonts = [...(tpl.value.fonts ?? []).filter((f) => f !== family), family]
} catch { alert('Upload fehlgeschlagen (nur TTF/OTF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' }
}
async function previewPdf() { async function previewPdf() {
if (!sample.value) { alert('Kein Mitarbeiter in dieser Firma für die Vorschau.'); return } if (!sample.value) { alert('Kein Mitarbeiter in dieser Firma für die Vorschau.'); return }
const res = await client.get(`/employees/${sample.value.id}/card.pdf`, { responseType: 'blob' }) const res = await client.get(`/employees/${sample.value.id}/card.pdf`, { responseType: 'blob' })
@ -210,11 +243,30 @@ onMounted(load)
</div> </div>
</div> </div>
<div class="assets">
<label class="btn btn-ghost btn-sm" :class="{ disabled: uploading }">
Hintergrund-PDF
<input type="file" accept="application/pdf,.pdf" hidden @change="uploadBackground" />
</label>
<template v-if="tpl.hasBackground">
<span class="badge badge-active">Hintergrund aktiv</span>
<button class="btn btn-ghost btn-sm" @click="removeBackground">Entfernen</button>
</template>
<span class="sep"></span>
<label class="btn btn-ghost btn-sm" :class="{ disabled: uploading }">
Schrift
<input type="file" accept=".ttf,.otf" hidden @change="uploadFont" />
</label>
<span v-for="f in (tpl.fonts || [])" :key="f" class="chip">{{ f }}</span>
</div>
<div class="stage"> <div class="stage">
<!-- Beschnitt-Fläche --> <!-- Beschnitt-Fläche -->
<div class="bleed" :style="{ width: (tpl.widthMm + 2 * tpl.bleedMm) * SCALE + 'px', height: (tpl.heightMm + 2 * tpl.bleedMm) * SCALE + 'px' }"> <div class="bleed" :style="{ width: (tpl.widthMm + 2 * tpl.bleedMm) * SCALE + 'px', height: (tpl.heightMm + 2 * tpl.bleedMm) * SCALE + 'px' }">
<!-- Endformat (Trim) --> <!-- Endformat (Trim) -->
<div class="trim" :style="{ left: tpl.bleedMm * SCALE + 'px', top: tpl.bleedMm * SCALE + 'px', width: tpl.widthMm * SCALE + 'px', height: tpl.heightMm * SCALE + 'px' }" @mousedown.self="selectedIndex = null"> <div class="trim" :style="{ left: tpl.bleedMm * SCALE + 'px', top: tpl.bleedMm * SCALE + 'px', width: tpl.widthMm * SCALE + 'px', height: tpl.heightMm * SCALE + 'px' }" @mousedown.self="selectedIndex = null">
<!-- Hinweis: Hintergrund-PDF aktiv (echtes Bild in der PDF-Vorschau) -->
<div v-if="tpl.hasBackground" class="bg-note">Hintergrund-PDF aktiv<br /><small>sichtbar in der PDF-Vorschau</small></div>
<!-- Sicherheitsabstand --> <!-- Sicherheitsabstand -->
<div class="safe" :style="{ inset: tpl.safeMm * SCALE + 'px' }"></div> <div class="safe" :style="{ inset: tpl.safeMm * SCALE + 'px' }"></div>
<!-- Elemente --> <!-- Elemente -->
@ -273,6 +325,15 @@ onMounted(load)
</div> </div>
<div class="field"><label>Fett</label><select class="input" v-model="selected.bold"><option :value="false">Nein</option><option :value="true">Ja</option></select></div> <div class="field"><label>Fett</label><select class="input" v-model="selected.bold"><option :value="false">Nein</option><option :value="true">Ja</option></select></div>
</div> </div>
<div class="field">
<label>Schriftart</label>
<select class="input" v-model="selected.fontFamily">
<option value="">Standard (Helvetica)</option>
<option value="times">Times</option>
<option value="courier">Courier</option>
<option v-for="f in (tpl.fonts || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
</template> </template>
<div class="field" v-if="selected.type !== 'qr' && selected.type !== 'image'"> <div class="field" v-if="selected.type !== 'qr' && selected.type !== 'image'">
@ -308,6 +369,12 @@ onMounted(load)
.tab.active { background: var(--dark); color: #fff; border-color: var(--dark); } .tab.active { background: var(--dark); color: #fff; border-color: var(--dark); }
.adds { display: flex; gap: .35rem; flex-wrap: wrap; } .adds { display: flex; gap: .35rem; flex-wrap: wrap; }
.assets { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: 1rem; padding: .7rem .9rem; background: #fff; border: 1px solid var(--line); border-radius: var(--radius-sm); }
.assets .btn.disabled { opacity: .6; pointer-events: none; }
.assets .sep { width: 1px; height: 20px; background: var(--line); margin: 0 .3rem; }
.chip { background: var(--psc-orange-soft); color: var(--psc-orange-dark); padding: .15rem .6rem; border-radius: 999px; font-size: .78rem; font-weight: 600; }
.bg-note { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: #b3b3b3; font-size: 12px; background: repeating-linear-gradient(45deg, #fafafa, #fafafa 8px, #f2f2f2 8px, #f2f2f2 16px); pointer-events: none; }
.bg-note small { font-size: 10px; }
.stage { background: #ececec; border-radius: var(--radius); padding: 1.5rem; display: flex; flex-direction: column; align-items: center; overflow-x: auto; } .stage { background: #ececec; border-radius: var(--radius); padding: 1.5rem; display: flex; flex-direction: column; align-items: center; overflow-x: auto; }
.bleed { position: relative; background: #fbe9da; box-shadow: var(--shadow-sm); } .bleed { position: relative; background: #fbe9da; box-shadow: var(--shadow-sm); }
.trim { position: absolute; background: #fff; overflow: hidden; } .trim { position: absolute; background: #fff; overflow: hidden; }