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:
co-authored by
Claude Opus 5
parent
3c9d83161e
commit
0a2cf52e8a
@@ -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'),
|
||||
|
||||
|
||||
@@ -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" */
|
||||
|
||||
Reference in New Issue
Block a user