feat(invoices): M3 logo upload — embed in PDF header

Completes the Settings polish item left open after M2.

pdf/logo.ts
- loadLogo(mediaId): fetches the large variant from mana-media, sniffs
  content-type to pick 'png' vs 'jpg', returns null on any failure so
  the PDF still renders without a logo
- uploadLogo(file): multipart POST to /api/v1/media/upload with
  app=invoices, returns the new mediaId (or throws a user-facing msg)
- logoPreviewUrl(mediaId): thin helper so the settings form doesn't
  have to know the media-URL lookup pattern

Renderer wiring
- loadLogo runs in the same Promise.all as font embedding so it doesn't
  add a serial wait
- embedPng / embedJpg based on the sniffed kind; errors degrade silently
- renderHeader takes a PDFImage|null and, when present, draws it top-
  left above the sender name, max 25mm × 45% content-width, aspect
  preserved, 3mm breathing room below

Settings UI (SenderProfileForm)
- Logo slot at the top of the Absender section: preview when set,
  "Ersetzen" / "Entfernen" actions; "+ Logo hochladen" drop-style
  button when empty
- Upload persists immediately (no separate "Speichern" click for logo
  changes) — keeps the interaction one-handed
- Accepts PNG / JPEG; invalid types rejected client-side before the
  network round-trip

Closes one of the open items from docs/plans/invoices-module.md §M3.
Next open: M8 AI-tools (create_invoice / mark_paid / list / stats).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 18:06:40 +02:00
parent 5b7564b3a4
commit 76060b0632
3 changed files with 301 additions and 5 deletions

View file

@ -6,10 +6,49 @@
import { invoiceSettingsStore } from '../stores/settings.svelte';
import type { InvoiceSettings, Currency } from '../types';
import { VAT_RATES_CH, VAT_RATES_DE, CURRENCIES } from '../constants';
import { uploadLogo, logoPreviewUrl } from '../pdf/logo';
let settings = $state<InvoiceSettings | null>(null);
let saving = $state(false);
let savedAt = $state<string | null>(null);
let uploadingLogo = $state(false);
let logoError = $state<string | null>(null);
let logoInput: HTMLInputElement | undefined = $state();
async function onLogoSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !settings) return;
uploadingLogo = true;
logoError = null;
try {
const mediaId = await uploadLogo(file);
// Persist immediately — user doesn't need to hit "Speichern" separately
// for logo changes. The in-memory settings state stays in sync so
// further edits don't lose the mediaId.
await invoiceSettingsStore.update({ logoMediaId: mediaId });
settings.logoMediaId = mediaId;
} catch (e) {
logoError = e instanceof Error ? e.message : 'Upload fehlgeschlagen';
} finally {
uploadingLogo = false;
input.value = '';
}
}
async function removeLogo() {
if (!settings) return;
uploadingLogo = true;
logoError = null;
try {
await invoiceSettingsStore.update({ logoMediaId: null });
settings.logoMediaId = null;
} catch (e) {
logoError = e instanceof Error ? e.message : 'Entfernen fehlgeschlagen';
} finally {
uploadingLogo = false;
}
}
$effect(() => {
invoiceSettingsStore.get().then((s) => {
@ -66,6 +105,56 @@
<h3>Absender</h3>
<p class="hint">Erscheint im Kopf jeder Rechnung.</p>
<div class="field logo-field">
<span>Logo</span>
<div class="logo-row">
{#if settings.logoMediaId}
<img
class="logo-preview"
src={logoPreviewUrl(settings.logoMediaId)}
alt="Logo-Vorschau"
/>
<div class="logo-actions">
<button
type="button"
class="btn-sm"
onclick={() => logoInput?.click()}
disabled={uploadingLogo}
>
Ersetzen
</button>
<button
type="button"
class="btn-sm btn-danger"
onclick={removeLogo}
disabled={uploadingLogo}
>
Entfernen
</button>
</div>
{:else}
<button
type="button"
class="logo-drop"
onclick={() => logoInput?.click()}
disabled={uploadingLogo}
>
{uploadingLogo ? 'Lädt hoch …' : '+ Logo hochladen'}
</button>
{/if}
<input
bind:this={logoInput}
type="file"
accept="image/png,image/jpeg"
hidden
onchange={onLogoSelect}
/>
</div>
{#if logoError}
<span class="hint-warn">{logoError}</span>
{/if}
</div>
<label class="field">
<span>Name *</span>
<input type="text" bind:value={settings.senderName} required />
@ -291,4 +380,75 @@
font-size: 0.85rem;
color: #059669;
}
.logo-field {
gap: 0.4rem;
}
.logo-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-preview {
max-width: 140px;
max-height: 64px;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.3rem;
padding: 0.25rem;
background: white;
object-fit: contain;
}
.logo-actions {
display: flex;
gap: 0.4rem;
}
.logo-drop {
display: inline-flex;
align-items: center;
padding: 0.75rem 1.25rem;
border: 1px dashed var(--color-border, #e2e8f0);
border-radius: 0.4rem;
background: var(--color-surface-muted, #f8fafc);
color: var(--color-text-muted, #64748b);
cursor: pointer;
font-size: 0.9rem;
}
.logo-drop:hover:not(:disabled) {
border-color: #059669;
color: #059669;
}
.logo-drop:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-sm {
padding: 0.35rem 0.75rem;
background: white;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.3rem;
cursor: pointer;
font-size: 0.85rem;
}
.btn-sm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-sm.btn-danger {
color: #b91c1c;
border-color: #fecaca;
}
.hint-warn {
font-size: 0.8rem;
color: #92400e;
}
</style>

View file

@ -0,0 +1,88 @@
/**
* Logo loader for the PDF renderer.
*
* Fetches the user's logo from mana-media by `mediaId`, sniffs the MIME
* type from the response so we route to pdf-lib's `embedPng` vs
* `embedJpg` correctly, and returns a compact { bytes, kind } tuple.
*
* Errors are intentionally non-throwing a missing / broken logo
* shouldn't prevent the invoice PDF from rendering. Caller treats the
* null return as "skip the logo slot".
*/
import { browser } from '$app/environment';
/** Resolve the mana-media base URL the same way other modules do. */
function getMediaUrl(): string {
if (browser) {
const fromWindow = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string })
.__PUBLIC_MANA_MEDIA_URL__;
if (fromWindow) return fromWindow;
}
return import.meta.env.PUBLIC_MANA_MEDIA_URL || 'http://localhost:3015';
}
export type LogoKind = 'png' | 'jpg';
export interface LoadedLogo {
bytes: Uint8Array;
kind: LogoKind;
}
/**
* Fetch the logo large variant and return the bytes + the format so the
* renderer can pick the right pdf-lib embed function. Returns null on
* any failure (offline, 404, unsupported format).
*/
export async function loadLogo(mediaId: string | null | undefined): Promise<LoadedLogo | null> {
if (!mediaId) return null;
const url = `${getMediaUrl()}/api/v1/media/${mediaId}/file/large`;
try {
const res = await fetch(url);
if (!res.ok) return null;
const contentType = res.headers.get('content-type') ?? '';
const kind: LogoKind | null = contentType.includes('png')
? 'png'
: contentType.includes('jpeg') || contentType.includes('jpg')
? 'jpg'
: null;
if (!kind) return null;
const buf = await res.arrayBuffer();
return { bytes: new Uint8Array(buf), kind };
} catch {
return null;
}
}
/**
* Upload a file to mana-media under `app=invoices` so it's scoped and
* discoverable. Returns the new mediaId on success, or throws with a
* user-facing message on failure (caller shows the error inline).
*
* Lives here rather than in a store because the upload is a one-shot
* side effect the setting write that follows it is the only sync-
* tracked state change.
*/
export async function uploadLogo(file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
throw new Error('Bitte wähle ein Bild aus (PNG oder JPG).');
}
const formData = new FormData();
formData.append('file', file);
formData.append('app', 'invoices');
const res = await fetch(`${getMediaUrl()}/api/v1/media/upload`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
throw new Error(`Upload fehlgeschlagen (${res.status})`);
}
const data = (await res.json()) as { id: string };
if (!data.id) throw new Error('Upload-Antwort ohne Media-ID.');
return data.id;
}
/** Convenience for the UI preview. Large variant matches what the PDF uses. */
export function logoPreviewUrl(mediaId: string): string {
return `${getMediaUrl()}/api/v1/media/${mediaId}/file/large`;
}

View file

@ -33,7 +33,14 @@
* Inter/Roboto via fetch in M7 when we care about brand typography.
*/
import { PDFDocument, StandardFonts, rgb, type PDFPage, type PDFFont } from 'pdf-lib';
import {
PDFDocument,
StandardFonts,
rgb,
type PDFPage,
type PDFFont,
type PDFImage,
} from 'pdf-lib';
import type { Invoice, InvoiceSettings } from '../types';
import { CURRENCIES } from '../constants';
import {
@ -45,8 +52,10 @@ import {
LINE_COLS,
SPACE,
LINE_HEIGHT,
mm,
} from './templates/default';
import { attachQRBillToPdf, buildQRBillData, QRBillError } from './qr-bill';
import { loadLogo } from './logo';
// ─── Small geometry helpers ────────────────────────────────
@ -178,9 +187,33 @@ function formatAmount(minor: number, currency: keyof typeof CURRENCIES): string
// ─── Section renderers ────────────────────────────────────
function renderHeader(ctx: RenderContext, invoice: Invoice, settings: InvoiceSettings): number {
// Left: sender
function renderHeader(
ctx: RenderContext,
invoice: Invoice,
settings: InvoiceSettings,
logo: PDFImage | null
): number {
let leftY = ctx.y;
// Logo sits top-left, above the sender block. Max 25mm tall, 45% of
// content-width wide; aspect ratio preserved (scale by the tighter
// constraint). Leaves ~3mm breathing room before sender name.
if (logo) {
const maxH = mm(25);
const maxW = ctx.contentWidth * 0.45;
const { width: naturalW, height: naturalH } = logo.scale(1);
const scale = Math.min(maxW / naturalW, maxH / naturalH, 1);
const w = naturalW * scale;
const h = naturalH * scale;
ctx.page.drawImage(logo, {
x: ctx.leftX,
y: leftY - h,
width: w,
height: h,
});
leftY -= h + mm(3);
}
drawText(ctx, settings.senderName || '—', ctx.leftX, leftY, {
size: FONT_SIZE.brand,
font: ctx.bold,
@ -508,11 +541,26 @@ export async function renderInvoicePdf(
doc.setProducer('pdf-lib');
doc.setCreationDate(new Date());
const [regular, bold] = await Promise.all([
const [regular, bold, logoData] = await Promise.all([
doc.embedFont(StandardFonts.Helvetica),
doc.embedFont(StandardFonts.HelveticaBold),
loadLogo(settings.logoMediaId),
]);
// Embed the logo once (shared image resource across any future pages
// that draw it). Silent on failure — missing logos just skip the slot.
let logo: PDFImage | null = null;
if (logoData) {
try {
logo =
logoData.kind === 'png'
? await doc.embedPng(logoData.bytes)
: await doc.embedJpg(logoData.bytes);
} catch {
logo = null;
}
}
const page = doc.addPage([A4.width, A4.height]);
const leftX = MARGIN.left;
const rightX = A4.width - MARGIN.right;
@ -528,7 +576,7 @@ export async function renderInvoicePdf(
contentWidth: rightX - leftX,
};
ctx.y = renderHeader(ctx, invoice, settings);
ctx.y = renderHeader(ctx, invoice, settings, logo);
ctx.y = renderRecipient(ctx, invoice, ctx.y);
ctx.y = renderSubject(ctx, invoice, ctx.y);
const linesResult = renderLinesTable(ctx, invoice);