feat(kontakte): Zustaendigkeit, LinkedIn-Foto, Netzwerkauswahl

Drei Verbesserungen an den Kontakten:

* Beim Anlegen ist der angemeldete Benutzer voreingestellt zustaendig.
  Bisher stand dort der erste Benutzer der Liste - fast immer falsch.
* Wird eine LinkedIn-Adresse hinterlegt, holt das CRM das oeffentliche
  Profilfoto und setzt es als Kontaktfoto: beim Verlassen des Feldes
  einmal je Adresse, solange kein Foto gesetzt ist, und auf Klick ueber
  den Knopf daneben. Der Abruf laeuft ueber das Backend
  (server/src/linkedinPhoto.js, POST /api/leads/linkedin-photo), weil
  ihn die Same-Origin-Policy im Browser verhindert. Gelesen wird die
  Open-Graph-Vorschau der Profilseite; Herkunft, Groesse und Antwortzeit
  des Bildes werden geprueft, Platzhalter-Silhouetten abgewiesen.
  Schlaegt der Abruf fehl, steht der Grund unter dem Feld.
* In der Netzwerkansicht hebt ein Klick auf freie Flaeche die Auswahl
  auf - das Diagramm sieht danach aus wie beim Wechsel in die Ansicht.
  Ein Ziehen des Hintergrunds (ab 4 px) laesst die Auswahl stehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-09 23:25:37 +02:00
co-authored by Claude Opus 5
parent 3c9d83161e
commit 0a2cf52e8a
14 changed files with 540 additions and 26 deletions
+11
View File
@@ -170,6 +170,17 @@ export const leadsApi = {
request<Lead>(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/leads/${id}`, { method: 'DELETE' }),
/**
* Profilfoto zu einer LinkedIn-Adresse holen. Der Server liest das
* oeffentliche Vorschaubild des Profils und liefert es als Data-URL; das
* Speichern uebernimmt danach der normale Kontakt-Aufruf.
*/
linkedinPhoto: (linkedin: string) =>
request<{ avatar: string; bytes: number; source_url: string; profile_url: string }>(
'/leads/linkedin-photo',
{ method: 'POST', body: JSON.stringify({ linkedin }) }
),
/** Herkunftsangaben aller Eintraege aus "Aus Mails", nach Lead-ID. */
mailPoolSources: () => request<Record<string, MailPoolSource>>('/leads/mail-pool/sources'),
+24 -7
View File
@@ -27,22 +27,25 @@ export interface CompressedImage {
type: string;
}
function loadImage(file: File): Promise<HTMLImageElement> {
function loadImageFromUrl(url: string, revoke: boolean): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
if (revoke) URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
if (revoke) URL.revokeObjectURL(url);
reject(new Error('Das Bild konnte nicht gelesen werden.'));
};
image.src = url;
});
}
function loadImage(file: File): Promise<HTMLImageElement> {
return loadImageFromUrl(URL.createObjectURL(file), true);
}
/** Laenge einer Data-URL in Bytes (Base64 traegt 4 Zeichen pro 3 Bytes). */
function dataUrlBytes(dataUrl: string): number {
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
@@ -59,7 +62,21 @@ export async function compressImage(file: File): Promise<CompressedImage> {
throw new Error('Die Datei ist zu groß (max. 25 MB).');
}
const image = await loadImage(file);
return renderToSquare(await loadImage(file), file.size);
}
/**
* Wie compressImage(), nur fuer ein bereits geladenes Bild als Data-URL - etwa
* das Profilfoto, das der Server von LinkedIn geholt hat. Der Weg ueber den
* Canvas ist derselbe, damit ein importiertes Foto exakt so aussieht wie ein
* hochgeladenes.
*/
export async function compressImageSource(dataUrl: string): Promise<CompressedImage> {
const image = await loadImageFromUrl(dataUrl, false);
return renderToSquare(image, dataUrlBytes(dataUrl));
}
function renderToSquare(image: HTMLImageElement, sourceBytes: number): CompressedImage {
const { naturalWidth: width, naturalHeight: height } = image;
if (!width || !height) throw new Error('Das Bild hat keine lesbaren Abmessungen.');
@@ -83,7 +100,7 @@ export async function compressImage(file: File): Promise<CompressedImage> {
if (dataUrl.startsWith(`data:${candidate.type}`)) {
return {
dataUrl,
sourceBytes: file.size,
sourceBytes,
resultBytes: dataUrlBytes(dataUrl),
type: candidate.type,
};
@@ -91,7 +108,7 @@ export async function compressImage(file: File): Promise<CompressedImage> {
}
const png = canvas.toDataURL('image/png');
return { dataUrl: png, sourceBytes: file.size, resultBytes: dataUrlBytes(png), type: 'image/png' };
return { dataUrl: png, sourceBytes, resultBytes: dataUrlBytes(png), type: 'image/png' };
}
/** "2,4 MB" bzw. "18 KB" */