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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -491,6 +491,21 @@ braucht, pflegt sie im Kontakt nach.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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(/&/g, '&')
|
||||||
|
.replace(///gi, '/')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/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 };
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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&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);
|
||||||
@@ -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>
|
||||||
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="https://linkedin.com/in/..."
|
placeholder="https://linkedin.com/in/..."
|
||||||
value={form.linkedin}
|
value={form.linkedin}
|
||||||
onChange={(e) => set('linkedin', e.target.value)}
|
onChange={(e) => set('linkedin', e.target.value)}
|
||||||
|
onBlur={handleLinkedInBlur}
|
||||||
className={inputClass}
|
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>
|
||||||
|
|||||||
@@ -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' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|
||||||
|
|||||||
@@ -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" */
|
||||||
|
|||||||
Reference in New Issue
Block a user