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
+28
View File
@@ -519,6 +519,27 @@ Das Backend akzeptiert nur Bild-Data-URLs und http(s)-Adressen und begrenzt die
Länge; die Quelldatei darf bis 25 MB groß sein, weil sie ohnehin Länge; die Quelldatei darf bis 25 MB groß sein, weil sie ohnehin
komprimiert wird. komprimiert wird.
### Profilfoto von LinkedIn
Trägt man beim Anlegen eines Kontakts eine LinkedIn-Adresse ein, holt das CRM
das öffentliche Profilbild und setzt es als Kontaktfoto - beim Verlassen des
Feldes von selbst (einmal je Adresse, nur solange kein Foto gesetzt ist) oder
auf Klick über den Knopf *Foto* daneben.
Den Abruf macht das Backend (`server/src/linkedinPhoto.js`, Route
`POST /api/leads/linkedin-photo`): aus dem Browser heraus stünde die
Same-Origin-Policy davor. Gelesen wird die Open-Graph-Vorschau der öffentlichen
Profilseite (`og:image`), das Bild kommt anschließend von `media.licdn.com`.
Geprüft wird die Herkunft (nur `https` von `licdn.com` mit `/dms/image/` im
Pfad), die Größe (max. 5 MB) und die Antwortzeit (12 s); die graue
Platzhalter-Silhouette der Profile ohne eigenes Foto wird abgewiesen. Verkleinert
wird danach im Browser über denselben Weg wie ein Upload
(`compressImageSource()`).
Der Abruf ist bewusst als **Komfort** gebaut, nicht als Zusage: LinkedIn weist
Anfragen aus Rechenzentren häufiger ab als solche aus einem Büronetz. Schlägt er
fehl, steht der Grund unter dem Feld und das Foto wird wie bisher hochgeladen.
Benutzerfotos pflegt jeder für sich unter *Einstellungen → Mein Benutzerfotos pflegt jeder für sich unter *Einstellungen → Mein
Konto*, Admins für alle in der Nutzerverwaltung. Angezeigt werden sie in der Konto*, Admins für alle in der Nutzerverwaltung. Angezeigt werden sie in der
Sidebar, bei Terminteilnehmern und Erstellern, im Benutzer-Detailfenster, in den Sidebar, bei Terminteilnehmern und Erstellern, im Benutzer-Detailfenster, in den
@@ -544,6 +565,13 @@ Knoten ziehen fixiert ihn (grüner Punkt), Doppelklick löst die Fixierung.
Über *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen; Über *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen;
Esc bricht ab. Die Ansicht respektiert Suche und Statusfilter. Esc bricht ab. Die Ansicht respektiert Suche und Statusfilter.
Ein Klick auf einen Knoten hebt ihn samt Nachbarn hervor und blendet den Rest
auf 22 % Deckkraft ab. Ein Klick auf eine **freie Fläche** hebt die Auswahl
wieder auf - der Ausgangszustand der Ansicht. Damit das Verschieben nicht
versehentlich die Auswahl verwirft, gilt eine Geste erst ab 4 px Weg als Ziehen;
Knoten stoppen das `pointerdown` ohnehin, ihre Klicks erreichen den Hintergrund
nicht.
## Lokale Entwicklung ## Lokale Entwicklung
```bash ```bash
+9 -3
View File
@@ -33,15 +33,21 @@ die Postfachanbindung unter `/api/mail` (siehe
## Ausgehende Verbindungen ## Ausgehende Verbindungen
Das Backend spricht von sich aus mit drei fremden Diensten. Alle drei sind Das Backend spricht von sich aus mit vier fremden Diensten. Alle sind
optional - fehlen die Zugangsdaten, läuft die Anwendung ohne den jeweiligen optional - fehlen die Zugangsdaten oder ist das Ziel nicht erreichbar, läuft die
Teil weiter. Anwendung ohne den jeweiligen Teil weiter.
| Ziel | Wofür | Ausgelöst von | | Ziel | Wofür | Ausgelöst von |
| --- | --- | --- | | --- | --- | --- |
| Projektbörsen | Scraping der Projektangebote | Scanner-Scheduler | | Projektbörsen | Scraping der Projektangebote | Scanner-Scheduler |
| `api.openai.com` | Bewertung Lebenslauf gegen Projekt | Scanner-Pipeline | | `api.openai.com` | Bewertung Lebenslauf gegen Projekt | Scanner-Pipeline |
| `login.microsoftonline.com`, `graph.microsoft.com` | Postfach `crm@perinco.de` lesen | Mail-Scheduler | | `login.microsoftonline.com`, `graph.microsoft.com` | Postfach `crm@perinco.de` lesen | Mail-Scheduler |
| `linkedin.com`, `media.licdn.com` | Profilfoto zu einem Kontakt holen | Benutzer im Kontaktformular |
Der LinkedIn-Abruf ist der einzige, den ein Benutzer direkt auslöst: Er liest die
öffentliche Vorschau (`og:image`) einer Profilseite und lädt das Bild vom
LinkedIn-CDN. Keine Anmeldung, kein Zugriff auf geschützte Inhalte - schlägt der
Abruf fehl, bleibt es beim Hochladen von Hand.
Die Postfachanbindung schickt **keine** Mailinhalte an Dritte: Kontaktdaten Die Postfachanbindung schickt **keine** Mailinhalte an Dritte: Kontaktdaten
werden auf dem Server aus Graph-Feldern und Signaturen gelesen, ohne werden auf dem Server aus Graph-Feldern und Signaturen gelesen, ohne
+1 -1
View File
@@ -117,7 +117,7 @@ src/
whisper.tsx Modell, Worker-Lebenszyklus whisper.tsx Modell, Worker-Lebenszyklus
dictation.tsx Aufnahme-Hook dictation.tsx Aufnahme-Hook
audio.ts Aufnahme zu 16-kHz-Mono-PCM audio.ts Aufnahme zu 16-kHz-Mono-PCM
imageCompress.ts Bilder vor dem Upload verkleinern imageCompress.ts Bilder vor dem Upload verkleinern (auch das LinkedIn-Foto)
components/ components/
Sidebar.tsx Menü inkl. aufklappbarer Gruppen (Kontakte, Scanner) Sidebar.tsx Menü inkl. aufklappbarer Gruppen (Kontakte, Scanner)
Header.tsx Titel, Untertitel, Design-Auswahl, Hell/Dunkel-Schalter Header.tsx Titel, Untertitel, Design-Auswahl, Hell/Dunkel-Schalter
+20
View File
@@ -491,6 +491,21 @@ braucht, pflegt sie im Kontakt nach.
![Dialog "Neuer Kontakt" mit allen Feldern](bilder/kontakt-dialog.png) ![Dialog "Neuer Kontakt" mit allen Feldern](bilder/kontakt-dialog.png)
Zwei Handgriffe nimmt der Dialog beim **Anlegen** ab:
* **Zuständig** steht schon auf dem angemeldeten Benutzer. Wer für jemand
anderen anlegt, stellt das Feld um; beim späteren Bearbeiten bleibt immer die
gespeicherte Zuständigkeit stehen.
* **Profilfoto von LinkedIn.** Wird eine LinkedIn-Adresse eingetragen und der
Kontakt hat noch kein Foto, holt das CRM beim Verlassen des Feldes das
öffentliche Profilbild und setzt es als Kontaktfoto. Der Knopf **Foto** neben
dem Feld macht dasselbe auf Zuruf - etwa, wenn die Adresse nachgetragen wird.
Ein selbst hochgeladenes Foto wird dabei nie überschrieben.
Klappt es nicht, steht der Grund unter dem Feld: Das Profil ist nicht
öffentlich, es hat gar kein eigenes Foto, oder LinkedIn hat die Anfrage
abgewiesen. Dann bleibt der gewohnte Weg über *Ändern* am Foto.
Im **Netzwerk** sind Kreise Kontakte (Farbe = Art, Größe = Anzahl Im **Netzwerk** sind Kreise Kontakte (Farbe = Art, Größe = Anzahl
Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien
verbinden Kontakte untereinander, graue gestrichelte den Ersteller mit seinem verbinden Kontakte untereinander, graue gestrichelte den Ersteller mit seinem
@@ -503,6 +518,11 @@ setzt ihn fest (grüner Punkt), Doppelklick löst ihn wieder. Über *Verbindung
erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab. erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab.
Suche und Artfilter wirken auch hier. Suche und Artfilter wirken auch hier.
Ein Klick auf einen Knoten hebt ihn und seine Nachbarn hervor, alles andere wird
blass. **Ein Klick auf eine freie Stelle nimmt die Hervorhebung wieder zurück** -
das Bild sieht danach aus wie beim Wechsel in die Netzwerkansicht. Verschieben
zählt nicht als Klick: Wer den Hintergrund packt und zieht, behält die Auswahl.
Sobald Kontakte aus dem Postfach übernommen wurden, erscheint neben dem Sobald Kontakte aus dem Postfach übernommen wurden, erscheint neben dem
Artfilter ein **Herkunftsfilter**: *Aus Mails übernommen* zeigt nur die aus Artfilter ein **Herkunftsfilter**: *Aus Mails übernommen* zeigt nur die aus
dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten. dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten.
+1 -1
View File
@@ -53,7 +53,7 @@ ansehen - siehe *Oberfläche prüfen ohne Datenbank* weiter unten.
```bash ```bash
npm run lint # = tsc --noEmit, das ist die Wahrheit npm run lint # = tsc --noEmit, das ist die Wahrheit
npm run build # muss ohne Fehler durchlaufen npm run build # muss ohne Fehler durchlaufen
cd server && npm test # Postfachanbindung und Berater-Liste cd server && npm test # Postfachanbindung, Berater-Liste, Benutzerfelder, LinkedIn-Foto
``` ```
Automatische Tests gibt es für zwei Bereiche, beide ohne Test-Framework, reines Automatische Tests gibt es für zwei Bereiche, beide ohne Test-Framework, reines
+34 -1
View File
@@ -123,6 +123,36 @@ unbekannter Name kommt so nie in die Datenbank.
Bewusst getrennt vom Setting-Modell des Scanners (andere Datenbank) und bewusst Bewusst getrennt vom Setting-Modell des Scanners (andere Datenbank) und bewusst
**ohne Geheimnisse**: Zugangsdaten stehen in `server/.env`. **ohne Geheimnisse**: Zugangsdaten stehen in `server/.env`.
### `server/src/linkedinPhoto.js`
Holt das Profilfoto zu einer LinkedIn-Adresse. Bedient wird damit
`POST /api/leads/linkedin-photo`.
| Export | Zweck |
| --- | --- |
| `normalizeProfileUrl(eingabe)` | macht aus `linkedin.com/in/name` eine `https`-URL, wirft bei fremden Hosts |
| `fetchLinkedInPhoto(eingabe)` | Profilseite lesen, `og:image` ziehen, Bild laden, als Data-URL zurückgeben |
| `PhotoError` | Fehler mit `status`; die Route reicht Status und Text unverändert durch |
Der Weg in Stichpunkten und die Gründe dafür:
* **Über das Backend, nicht aus dem Browser.** Ein `fetch` auf linkedin.com aus
der Seite heraus scheitert an der Same-Origin-Policy.
* **Browser-Kennung im Kopf.** Ohne `User-Agent` antwortet LinkedIn oft gar
nicht erst.
* **Nur `og:image`/`twitter:image`**, ersatzweise eine `media.licdn.com`-Adresse
aus dem Quelltext. Es wird nichts angemeldet und nichts Geschütztes gelesen.
* **Herkunft wird geprüft.** Das Bild muss über `https` von `licdn.com` kommen
und `/dms/image/` im Pfad haben - sonst könnte eine manipulierte Seite eine
beliebige Adresse unterschieben.
* **Platzhalter werden abgewiesen.** Profile ohne eigenes Foto liefern eine
graue Silhouette (`ghost` im Pfad); die soll kein Kontaktfoto werden.
* **Grenzen:** 12 s Zeitlimit je Anfrage, höchstens 5 MB Bild.
Gibt LinkedIn die Seite nicht heraus (Status 999 oder 403 bei zu vielen
Anfragen von einer Adresse), kommt eine Meldung im Klartext zurück - das ist
der Normalfall, mit dem die Oberfläche rechnet, kein Serverfehler.
### `server/src/mail/` ### `server/src/mail/`
Postfachanbindung, fachlich in [11 Postfachanbindung](11-postfach.md). Postfachanbindung, fachlich in [11 Postfachanbindung](11-postfach.md).
@@ -212,6 +242,7 @@ Zusagen, auf die sich der Rest verlässt:
| `ApiError` | Fehler mit `status` | | `ApiError` | Fehler mit `status` |
| `authApi`, `usersApi`, `leadsApi`, `opportunitiesApi`, `eventsApi`, `connectionsApi` | typisierte Endpunkte | | `authApi`, `usersApi`, `leadsApi`, `opportunitiesApi`, `eventsApi`, `connectionsApi` | typisierte Endpunkte |
| `leadsApi.mailPoolSources` / `.promote` / `.promoteMany` | Vorbereich „Aus Mails": Herkunft lesen, einzeln bzw. gesammelt übernehmen | | `leadsApi.mailPoolSources` / `.promote` / `.promoteMany` | Vorbereich „Aus Mails": Herkunft lesen, einzeln bzw. gesammelt übernehmen |
| `leadsApi.linkedinPhoto(url)` | Profilfoto zu einer LinkedIn-Adresse als Data-URL; speichert nichts, das erledigt das Formular beim Sichern |
Zwei Feinheiten: Der `Content-Type` wird **nicht** gesetzt, wenn der Body Zwei Feinheiten: Der `Content-Type` wird **nicht** gesetzt, wenn der Body
`FormData` ist. Und ein Netzwerkfehler wird zu `ApiError(status 0)` mit `FormData` ist. Und ein Netzwerkfehler wird zu `ApiError(status 0)` mit
@@ -278,6 +309,8 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit `<datalist>`. Dazu `ConsultantSelect` (mehrere Berater: Auswahlfeld hängt an, Plaketten nehmen weg) und `ConsultantList` für die Anzeige in der Liste | | `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit `<datalist>`. Dazu `ConsultantSelect` (mehrere Berater: Auswahlfeld hängt an, Plaketten nehmen weg) und `ConsultantList` für die Anzeige in der Liste |
| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen | | `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass` (Beschriftung als eigene Zeile), `labelTextClass` + `labelRowClass` (Zeile mit Knopf rechts, z. B. Diktat - `min-h-7` haelt sie so hoch wie den Knopf, damit Felder in Nachbarspalten auf einer Linie beginnen), `KindBadge`, `Avatar`, Farben der Kontaktart | | `components/leads/leadUi.tsx` | `inputClass`, `labelClass` (Beschriftung als eigene Zeile), `labelTextClass` + `labelRowClass` (Zeile mit Knopf rechts, z. B. Diktat - `min-h-7` haelt sie so hoch wie den Knopf, damit Felder in Nachbarspalten auf einer Linie beginnen), `KindBadge`, `Avatar`, Farben der Kontaktart |
| `components/leads/LeadModal.tsx` | Kontaktformular. Beim Neuanlegen ist der angemeldete Benutzer *Zuständig* (`useAuth()`, Rückfallebene `users[0]`) - beim Bearbeiten bleibt die gespeicherte Zuständigkeit stehen. Neben dem LinkedIn-Feld holt *Foto* das Profilbild; beim Verlassen des Feldes geschieht das einmal je Adresse von selbst, aber nur solange noch kein Bild gesetzt ist (`triedProfiles`) - ein hochgeladenes Foto wird nie überschrieben. Fehler des automatischen Versuchs stehen als Hinweis unter dem Feld, nur der Klick auf *Foto* meldet sich als Toast |
| `components/leads/LeadNetwork.tsx` | Netzwerkdiagramm (d3-force). Ein Klick auf leere Fläche hebt die Auswahl auf, das Diagramm ist danach wieder so hell wie beim Wechsel in die Ansicht; ein Ziehen des Hintergrunds (mehr als 4 px) verschiebt nur und lässt die Auswahl stehen. Knoten stoppen das `pointerdown`, ihre Klicks erreichen den Hintergrund also nicht |
| `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs | | `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
| `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme | | `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme |
| `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergänzen eines vorhandenen Kontakts | | `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergänzen eines vorhandenen Kontakts |
@@ -288,7 +321,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/ui/Modal.tsx` | `Modal` und `ConfirmDialog`; `useDismiss()` schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel `openModals`), der Hintergrundklick muss dort beginnen **und** enden. `ConfirmDialog` kennt zwei Tonlagen: `tone="danger"` (Standard, rot - es geht etwas verloren) und `tone="neutral"` (grün, bloße Rückfrage, z. B. beim Wiederherstellen) | | `components/ui/Modal.tsx` | `Modal` und `ConfirmDialog`; `useDismiss()` schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel `openModals`), der Hintergrundklick muss dort beginnen **und** enden. `ConfirmDialog` kennt zwei Tonlagen: `tone="danger"` (Standard, rot - es geht etwas verloren) und `tone="neutral"` (grün, bloße Rückfrage, z. B. beim Wiederherstellen) |
| `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` | | `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` |
| `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat | | `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat |
| `lib/imageCompress.ts` | `compressImage()` auf 256 px, WebP → JPEG → PNG | | `lib/imageCompress.ts` | `compressImage(datei)` auf 256 px, WebP → JPEG → PNG; `compressImageSource(dataUrl)` geht denselben Weg für ein Bild, das nicht aus einer Datei kommt (LinkedIn-Foto) |
### `src/types.ts` ### `src/types.ts`
+1 -1
View File
@@ -12,7 +12,7 @@
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"migrate": "node src/migrate.js", "migrate": "node src/migrate.js",
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs" "test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
+191
View File
@@ -0,0 +1,191 @@
/*
* Profilfoto zu einem LinkedIn-Profil besorgen.
*
* LinkedIn liefert oeffentlichen Profilseiten eine Open-Graph-Vorschau mit,
* die das Profilfoto enthaelt (og:image, ausgeliefert ueber media.licdn.com).
* Genau diese Vorschau wird hier gelesen - es findet keine Anmeldung und kein
* Auslesen geschuetzter Inhalte statt. Ist das Profil nicht oeffentlich oder
* blockt LinkedIn die Anfrage, gibt es eine verstaendliche Fehlermeldung
* statt eines Bildes; der Benutzer kann das Foto dann wie gehabt hochladen.
*
* Der Aufruf laeuft bewusst ueber das Backend: aus dem Browser heraus waere er
* an der Same-Origin-Policy gescheitert.
*/
/** Ohne Browser-Kennung antwortet LinkedIn haeufig gar nicht erst. */
const BROWSER_HEADERS = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/140.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
};
/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */
const TIMEOUT_MS = 12_000;
/** Groessere Bilder sind kein Profilfoto mehr; der Client verkleinert ohnehin. */
const MAX_IMAGE_BYTES = 5_000_000;
/** Bilder liefert LinkedIn ausschliesslich ueber dieses CDN aus. */
const IMAGE_HOSTS = ['licdn.com'];
class PhotoError extends Error {
constructor(message, status = 502) {
super(message);
this.status = status;
}
}
function hostMatches(hostname, domain) {
return hostname === domain || hostname.endsWith(`.${domain}`);
}
/**
* Eingabe des Benutzers zu einer aufrufbaren Profil-URL machen. Akzeptiert
* auch "linkedin.com/in/vorname" ohne Schema.
*/
export function normalizeProfileUrl(input) {
const raw = String(input ?? '').trim();
if (!raw) throw new PhotoError('Es ist kein LinkedIn-Profil hinterlegt.', 400);
let url;
try {
url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
} catch {
throw new PhotoError('Die LinkedIn-Adresse ist keine gültige URL.', 400);
}
if (!hostMatches(url.hostname.toLowerCase(), 'linkedin.com')) {
throw new PhotoError('Die Adresse zeigt nicht auf ein LinkedIn-Profil.', 400);
}
// Tracking-Parameter stoeren nur und aendern die Seite nicht.
url.protocol = 'https:';
url.search = '';
url.hash = '';
return url.toString();
}
/**
* Bild-URL aus dem HTML der Profilseite ziehen. og:image ist der
* zuverlaessigste Treffer; als Rueckfallebene wird nach einer Foto-URL des
* LinkedIn-CDN im Seitenquelltext gesucht.
*/
function extractImageUrl(html) {
const metaPattern = /<meta[^>]+>/gi;
for (const tag of html.match(metaPattern) ?? []) {
const isImageTag = /(property|name)\s*=\s*["'](og:image|twitter:image)["']/i.test(tag);
if (!isImageTag) continue;
const content = tag.match(/content\s*=\s*["']([^"']+)["']/i);
if (content?.[1]) return decodeEntities(content[1]);
}
const direct = html.match(/https:\/\/media[^"'\s\\]*\.licdn\.com\/dms\/image\/[^"'\s\\]+/i);
return direct ? decodeEntities(direct[0]) : null;
}
/** Im HTML stehen die URLs mit maskierten Und-Zeichen. */
function decodeEntities(value) {
return value
.replace(/&amp;/g, '&')
.replace(/&#x2F;/gi, '/')
.replace(/&#38;/g, '&')
.replace(/&quot;/g, '"');
}
async function requestWithTimeout(url, headers) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
return await fetch(url, { headers, redirect: 'follow', signal: controller.signal });
} catch (err) {
if (err?.name === 'AbortError') {
throw new PhotoError('LinkedIn hat nicht rechtzeitig geantwortet.', 504);
}
throw new PhotoError('LinkedIn ist gerade nicht erreichbar.', 502);
} finally {
clearTimeout(timer);
}
}
/**
* Holt das Profilfoto und gibt es als Data-URL zurueck - so wie ein
* hochgeladenes Foto auch gespeichert wird.
*/
export async function fetchLinkedInPhoto(profileInput) {
const profileUrl = normalizeProfileUrl(profileInput);
const page = await requestWithTimeout(profileUrl, BROWSER_HEADERS);
if (page.status === 404 || page.status === 410) {
throw new PhotoError('Unter dieser Adresse gibt es kein LinkedIn-Profil.', 404);
}
if (!page.ok) {
// 999 ist LinkedIns Kennzeichen fuer "Anfrage abgewiesen".
throw new PhotoError(
'LinkedIn hat die Anfrage abgewiesen. Bitte das Foto manuell hochladen.',
502
);
}
const html = await page.text();
const imageUrl = extractImageUrl(html);
if (!imageUrl) {
throw new PhotoError(
'Das Profil gibt kein öffentliches Foto preis. Bitte das Foto manuell hochladen.',
404
);
}
let parsedImage;
try {
parsedImage = new URL(imageUrl);
} catch {
throw new PhotoError('Die gefundene Bildadresse ist unbrauchbar.', 502);
}
const isTrustedHost =
parsedImage.protocol === 'https:' &&
IMAGE_HOSTS.some((domain) => hostMatches(parsedImage.hostname.toLowerCase(), domain));
if (!isTrustedHost) {
throw new PhotoError('Das gefundene Bild stammt nicht von LinkedIn.', 502);
}
/*
* Profile ohne eigenes Foto bekommen von LinkedIn eine graue Platzhalter-
* Silhouette als Vorschaubild. Die soll nicht als Kontaktfoto landen.
*/
const path = parsedImage.pathname.toLowerCase();
if (path.includes('ghost') || !path.includes('/dms/image/')) {
throw new PhotoError(
'Das Profil hat kein eigenes Foto hinterlegt.',
404
);
}
const image = await requestWithTimeout(parsedImage.toString(), {
'User-Agent': BROWSER_HEADERS['User-Agent'],
'Accept': 'image/avif,image/webp,image/png,image/jpeg,*/*;q=0.8',
'Referer': profileUrl,
});
if (!image.ok) throw new PhotoError('Das Profilfoto konnte nicht geladen werden.', 502);
const contentType = (image.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (!contentType.startsWith('image/')) {
throw new PhotoError('LinkedIn hat statt eines Bildes etwas anderes geliefert.', 502);
}
const buffer = Buffer.from(await image.arrayBuffer());
if (buffer.length === 0) throw new PhotoError('Das Profilfoto ist leer.', 502);
if (buffer.length > MAX_IMAGE_BYTES) {
throw new PhotoError('Das Profilfoto ist zu groß.', 502);
}
return {
avatar: `data:${contentType};base64,${buffer.toString('base64')}`,
bytes: buffer.length,
source_url: parsedImage.toString(),
profile_url: profileUrl,
};
}
export { PhotoError, extractImageUrl, decodeEntities };
+19
View File
@@ -1,5 +1,6 @@
import express from 'express'; import express from 'express';
import { pool } from '../db.js'; import { pool } from '../db.js';
import { fetchLinkedInPhoto } from '../linkedinPhoto.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
const router = express.Router(); const router = express.Router();
@@ -110,6 +111,24 @@ router.get('/', async (req, res, next) => {
} }
}); });
/*
* POST /api/leads/linkedin-photo { linkedin: "https://www.linkedin.com/in/..." }
*
* Liest das oeffentliche Profilfoto und gibt es als Data-URL zurueck. Gespeichert
* wird hier nichts - der Client legt das Bild ins Formular, verkleinert es wie
* ein hochgeladenes Foto und speichert es erst beim Sichern des Kontakts.
* Steht die Route vor '/:id', weil sonst die ID-Routen greifen wuerden.
*/
router.post('/linkedin-photo', async (req, res, next) => {
try {
const result = await fetchLinkedInPhoto(req.body?.linkedin);
res.json(result);
} catch (err) {
if (err?.status) return res.status(err.status).json({ error: err.message });
next(err);
}
});
// POST /api/leads // POST /api/leads
router.post('/', async (req, res, next) => { router.post('/', async (req, res, next) => {
const firstName = emptyToNull(req.body?.first_name); const firstName = emptyToNull(req.body?.first_name);
+82
View File
@@ -0,0 +1,82 @@
/*
* Pruefungen des LinkedIn-Profilfotos: laeuft mit `npm test` im Verzeichnis
* server/. Geprueft wird alles, was ohne Netz geht - die Adresspruefung und das
* Herausloesen der Bildadresse aus dem Seitenquelltext. Der Abruf selbst haengt
* an LinkedIn und taugt nicht als Test: er kann jederzeit abgewiesen werden.
*/
const { normalizeProfileUrl, extractImageUrl } = await import('../src/linkedinPhoto.js');
let failures = 0;
function check(name, condition, detail = '') {
if (condition) {
console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`);
} else {
console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`);
failures += 1;
}
}
function throws(fn) {
try {
fn();
return null;
} catch (err) {
return err;
}
}
console.log('\nAdresse pruefen');
check(
'Vollstaendige Adresse bleibt',
normalizeProfileUrl('https://www.linkedin.com/in/max') === 'https://www.linkedin.com/in/max'
);
check(
'Ohne Schema wird https ergaenzt',
normalizeProfileUrl('linkedin.com/in/max') === 'https://linkedin.com/in/max'
);
check(
'Landeskennung ist erlaubt',
normalizeProfileUrl('https://de.linkedin.com/in/max') === 'https://de.linkedin.com/in/max'
);
check(
'Nachverfolgung faellt weg',
normalizeProfileUrl('https://www.linkedin.com/in/max?trk=abc#top') ===
'https://www.linkedin.com/in/max'
);
check(
'http wird zu https',
normalizeProfileUrl('http://www.linkedin.com/in/max') === 'https://www.linkedin.com/in/max'
);
const fremd = throws(() => normalizeProfileUrl('https://linkedin.com.example.org/in/max'));
check('Aehnlich aussehender Host wird abgewiesen', fremd?.status === 400, fremd?.message);
check('Fremder Host wird abgewiesen', throws(() => normalizeProfileUrl('https://xing.com/x'))?.status === 400);
check('Leere Eingabe wird abgewiesen', throws(() => normalizeProfileUrl(''))?.status === 400);
check('Unsinn wird abgewiesen', throws(() => normalizeProfileUrl('kein ://link'))?.status === 400);
console.log('\nBildadresse aus dem Quelltext');
check(
'og:image wird gefunden',
extractImageUrl('<meta property="og:image" content="https://media.licdn.com/dms/image/v2/abc" />') ===
'https://media.licdn.com/dms/image/v2/abc'
);
check(
'Maskierte Und-Zeichen werden zurueckgesetzt',
extractImageUrl('<meta property="og:image" content="https://media.licdn.com/dms/image/a?x=1&amp;y=2">') ===
'https://media.licdn.com/dms/image/a?x=1&y=2'
);
check(
'twitter:image als Ersatz',
extractImageUrl('<meta name="twitter:image" content="https://media.licdn.com/dms/image/t">') ===
'https://media.licdn.com/dms/image/t'
);
check(
'Ohne Vorschau greift die Suche im Quelltext',
extractImageUrl('var x = "https://media.licdn.com/dms/image/v2/zzz/profile-displayphoto";') ===
'https://media.licdn.com/dms/image/v2/zzz/profile-displayphoto'
);
check('Seite ohne Bild liefert null', extractImageUrl('<html><body>nichts</body></html>') === null);
console.log(failures === 0 ? '\nAlle Pruefungen bestanden.' : `\n${failures} Pruefung(en) fehlgeschlagen.`);
process.exit(failures === 0 ? 0 : 1);
+84 -9
View File
@@ -1,8 +1,10 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useRef, useState } from 'react';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { AvatarPicker } from '../ui/AvatarPicker'; import { AvatarPicker } from '../ui/AvatarPicker';
import { Avatar, inputClass, labelClass } from './leadUi'; import { Avatar, inputClass, labelClass } from './leadUi';
import { connectionsApi, leadsApi } from '../../lib/api'; import { connectionsApi, leadsApi } from '../../lib/api';
import { useAuth } from '../../lib/auth';
import { compressImageSource } from '../../lib/imageCompress';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { import {
CONNECTION_TYPES, CONNECTION_TYPES,
@@ -92,10 +94,27 @@ export const LeadModal: React.FC<LeadModalProps> = ({
onCreated, onCreated,
}) => { }) => {
const { addToast } = useToast(); const { addToast } = useToast();
const [form, setForm] = useState<FormState>(() => toFormState(lead, users[0]?.id ?? null)); const { user: currentUser } = useAuth();
/*
* Beim Neuanlegen ist der angemeldete Benutzer zustaendig - das ist in fast
* allen Faellen richtig und laesst sich im Feld darunter aendern. Beim
* Bearbeiten bleibt die gespeicherte Zustaendigkeit unangetastet.
*/
const [form, setForm] = useState<FormState>(() =>
toFormState(lead, currentUser?.id ?? users[0]?.id ?? null)
);
const [tagInput, setTagInput] = useState(''); const [tagInput, setTagInput] = useState('');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
// LinkedIn-Profilfoto
const [isFetchingPhoto, setIsFetchingPhoto] = useState(false);
const [photoHint, setPhotoHint] = useState<string | null>(null);
/*
* Merkt sich die Adressen, zu denen schon automatisch geholt wurde. Ohne das
* wuerde jeder Klick aus dem Feld heraus einen weiteren Versuch ausloesen.
*/
const triedProfiles = useRef<Set<string>>(new Set());
// Verbindungen dieses Kontakts // Verbindungen dieses Kontakts
const [newConnectionId, setNewConnectionId] = useState(''); const [newConnectionId, setNewConnectionId] = useState('');
const [newConnectionType, setNewConnectionType] = useState<ConnectionType>('Netzwerk'); const [newConnectionType, setNewConnectionType] = useState<ConnectionType>('Netzwerk');
@@ -134,6 +153,45 @@ export const LeadModal: React.FC<LeadModalProps> = ({
setTagInput(''); setTagInput('');
}; };
/**
* Holt das oeffentliche Profilfoto zur hinterlegten LinkedIn-Adresse und legt
* es als Kontaktfoto ins Formular. Automatisch geschieht das nur einmal je
* Adresse und nur, solange noch kein Foto gesetzt ist - ein bewusst
* hochgeladenes Bild wird nie ueberschrieben.
*/
const importLinkedInPhoto = async (profile: string, automatic: boolean) => {
const url = profile.trim();
if (!url || isFetchingPhoto) return;
triedProfiles.current.add(url);
setIsFetchingPhoto(true);
setPhotoHint(null);
try {
const { avatar } = await leadsApi.linkedinPhoto(url);
// Derselbe Weg wie beim Upload: quadratisch zuschneiden und verkleinern.
const compressed = await compressImageSource(avatar);
set('avatar', compressed.dataUrl);
addToast('Profilfoto von LinkedIn übernommen.', 'success');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Das Profilfoto konnte nicht geholt werden.';
setPhotoHint(message);
// Beim automatischen Versuch reicht der Hinweis unter dem Feld; nur der
// ausdrueckliche Klick bekommt eine Meldung.
if (!automatic) addToast(message, 'error');
} finally {
setIsFetchingPhoto(false);
}
};
/** Beim Verlassen des Feldes: einmalig versuchen, wenn noch kein Foto da ist. */
const handleLinkedInBlur = () => {
const url = form.linkedin.trim();
if (!url || form.avatar || triedProfiles.current.has(url)) return;
if (!url.includes('linkedin.com/')) return;
void importLinkedInPhoto(url, true);
};
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!form.first_name.trim() || !form.last_name.trim()) { if (!form.first_name.trim() || !form.last_name.trim()) {
@@ -305,13 +363,30 @@ export const LeadModal: React.FC<LeadModalProps> = ({
</div> </div>
<div> <div>
<label className={labelClass}>LinkedIn</label> <label className={labelClass}>LinkedIn</label>
<input <div className="flex gap-2">
type="url" <input
placeholder="https://linkedin.com/in/..." type="url"
value={form.linkedin} placeholder="https://linkedin.com/in/..."
onChange={(e) => set('linkedin', e.target.value)} value={form.linkedin}
className={inputClass} onChange={(e) => set('linkedin', e.target.value)}
/> onBlur={handleLinkedInBlur}
className={inputClass}
/>
<button
type="button"
onClick={() => importLinkedInPhoto(form.linkedin, false)}
disabled={!form.linkedin.trim() || isFetchingPhoto}
title="Profilfoto von LinkedIn übernehmen"
className="shrink-0 rounded-xl border border-ink-200 px-3 text-xs font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700 disabled:opacity-40"
>
{isFetchingPhoto ? (
<span className="block h-4 w-4 animate-spin rounded-full border-2 border-ink-300 border-t-brand-500" />
) : (
'Foto'
)}
</button>
</div>
{photoHint && <p className="mt-1.5 text-xs font-semibold text-ink-400">{photoHint}</p>}
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className={labelClass}>Website</label> <label className={labelClass}>Website</label>
+35 -3
View File
@@ -340,7 +340,15 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
return () => element.removeEventListener('wheel', onWheel); return () => element.removeEventListener('wheel', onWheel);
}, []); }, []);
const panStateRef = useRef<{ pointerId: number; startX: number; startY: number } | null>(null); const panStateRef = useRef<{
pointerId: number;
startX: number;
startY: number;
moved: boolean;
} | null>(null);
/** Ab dieser Verschiebung gilt die Geste als Ziehen und nicht mehr als Klick. */
const CLICK_TOLERANCE = 4;
const handleBackgroundPointerDown = (event: React.PointerEvent) => { const handleBackgroundPointerDown = (event: React.PointerEvent) => {
if (event.button !== 0) return; if (event.button !== 0) return;
@@ -348,6 +356,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
pointerId: event.pointerId, pointerId: event.pointerId,
startX: event.clientX - transform.x, startX: event.clientX - transform.x,
startY: event.clientY - transform.y, startY: event.clientY - transform.y,
moved: false,
}; };
(event.currentTarget as Element).setPointerCapture(event.pointerId); (event.currentTarget as Element).setPointerCapture(event.pointerId);
}; };
@@ -355,10 +364,33 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
const handleBackgroundPointerMove = (event: React.PointerEvent) => { const handleBackgroundPointerMove = (event: React.PointerEvent) => {
const pan = panStateRef.current; const pan = panStateRef.current;
if (!pan || pan.pointerId !== event.pointerId) return; if (!pan || pan.pointerId !== event.pointerId) return;
setTransform((prev) => ({ ...prev, x: event.clientX - pan.startX, y: event.clientY - pan.startY })); const x = event.clientX - pan.startX;
const y = event.clientY - pan.startY;
if (Math.abs(x - transform.x) > CLICK_TOLERANCE || Math.abs(y - transform.y) > CLICK_TOLERANCE) {
pan.moved = true;
}
setTransform((prev) => ({ ...prev, x, y }));
}; };
/*
* Ein Klick ins Leere hebt die Auswahl auf. Damit sieht das Netz wieder so
* aus wie beim Wechsel auf die Ansicht: nichts ist ausgegraut. Ein Ziehen
* (Verschieben der Ansicht) darf die Auswahl dagegen nicht verwerfen,
* deshalb der Vergleich mit der Toleranz. Knoten stoppen das Ereignis
* bereits beim pointerdown, ihre Klicks landen hier gar nicht erst.
*/
const handleBackgroundPointerUp = (event: React.PointerEvent) => { const handleBackgroundPointerUp = (event: React.PointerEvent) => {
const pan = panStateRef.current;
if (pan?.pointerId !== event.pointerId) return;
panStateRef.current = null;
if (pan.moved) return;
setSelectedId(null);
setHoveredId(null);
if (linkMode) setLinkSourceId(null);
};
/** Verlaesst der Zeiger die Flaeche, endet die Geste ohne Auswirkung auf die Auswahl. */
const handleBackgroundPointerLeave = (event: React.PointerEvent) => {
if (panStateRef.current?.pointerId === event.pointerId) panStateRef.current = null; if (panStateRef.current?.pointerId === event.pointerId) panStateRef.current = null;
}; };
@@ -654,7 +686,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
handleNodePointerMove(event); handleNodePointerMove(event);
}} }}
onPointerUp={handleBackgroundPointerUp} onPointerUp={handleBackgroundPointerUp}
onPointerLeave={handleBackgroundPointerUp} onPointerLeave={handleBackgroundPointerLeave}
className="h-full w-full cursor-grab active:cursor-grabbing" className="h-full w-full cursor-grab active:cursor-grabbing"
style={{ touchAction: 'none' }} style={{ touchAction: 'none' }}
> >
+11
View File
@@ -170,6 +170,17 @@ export const leadsApi = {
request<Lead>(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), request<Lead>(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/leads/${id}`, { method: 'DELETE' }), 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. */ /** Herkunftsangaben aller Eintraege aus "Aus Mails", nach Lead-ID. */
mailPoolSources: () => request<Record<string, MailPoolSource>>('/leads/mail-pool/sources'), mailPoolSources: () => request<Record<string, MailPoolSource>>('/leads/mail-pool/sources'),
+24 -7
View File
@@ -27,22 +27,25 @@ export interface CompressedImage {
type: string; type: string;
} }
function loadImage(file: File): Promise<HTMLImageElement> { function loadImageFromUrl(url: string, revoke: boolean): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const image = new Image(); const image = new Image();
image.onload = () => { image.onload = () => {
URL.revokeObjectURL(url); if (revoke) URL.revokeObjectURL(url);
resolve(image); resolve(image);
}; };
image.onerror = () => { image.onerror = () => {
URL.revokeObjectURL(url); if (revoke) URL.revokeObjectURL(url);
reject(new Error('Das Bild konnte nicht gelesen werden.')); reject(new Error('Das Bild konnte nicht gelesen werden.'));
}; };
image.src = url; 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). */ /** Laenge einer Data-URL in Bytes (Base64 traegt 4 Zeichen pro 3 Bytes). */
function dataUrlBytes(dataUrl: string): number { function dataUrlBytes(dataUrl: string): number {
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1); 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).'); 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; const { naturalWidth: width, naturalHeight: height } = image;
if (!width || !height) throw new Error('Das Bild hat keine lesbaren Abmessungen.'); 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}`)) { if (dataUrl.startsWith(`data:${candidate.type}`)) {
return { return {
dataUrl, dataUrl,
sourceBytes: file.size, sourceBytes,
resultBytes: dataUrlBytes(dataUrl), resultBytes: dataUrlBytes(dataUrl),
type: candidate.type, type: candidate.type,
}; };
@@ -91,7 +108,7 @@ export async function compressImage(file: File): Promise<CompressedImage> {
} }
const png = canvas.toDataURL('image/png'); 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" */ /** "2,4 MB" bzw. "18 KB" */