feat(profil): eigenes Profilbild hochladen
Jeder setzt sein Bild unter "Mein Konto", Vorgesetzte und Administratoren auch fuer die Benutzer, die sie verwalten. Wo bisher die Initialen standen, steht jetzt das Bild - Seitenleiste, Kopfzeile, Team, Dashboard, Reports, Projekte, Kunden und die Buchungsliste. Ohne Bild bleiben die Initialen. Der Browser verkleinert das Bild vor dem Hochladen auf ein Quadrat von 256 px und schneidet mittig zu (lib/imageCompress.ts): aus einem 4-MB-Foto werden wenige Kilobyte, und der Server muss kein Bild verarbeiten. Gespeichert wird es als Data-URL in pulse_users.avatar (LONGTEXT). Kein Upload-Verzeichnis, das gesichert und beim Deployment am Leben gehalten werden muesste. Geprueft wird serverseitig in lib/avatar.js: nur PNG, JPEG, GIF, WebP und AVIF als Data-URL, hoechstens 2 MB. SVG ist bewusst nicht erlaubt, es kann Skripte tragen. Externe http(s)-Adressen ebenso wenig - sonst laedt jeder Seitenaufruf ein Bild von einem fremden Server und verraet, wer wann mit Pulse arbeitet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2d3d843988
commit
9576d18abc
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Profilbilder werden als Data-URL in der Datenbank gespeichert. Angezeigt
|
||||
* werden sie höchstens mit 96 px Kantenlänge, deshalb wird vor dem Hochladen
|
||||
* im Browser auf ein Quadrat verkleinert - das drückt ein 4-MB-Handyfoto auf
|
||||
* wenige Kilobyte, und der Server muss kein Bild verarbeiten.
|
||||
*/
|
||||
|
||||
/** Zielkantenlänge; 256 px reicht für die größte Anzeige auch auf Retina. */
|
||||
const TARGET_SIZE = 256;
|
||||
|
||||
/** WebP ist deutlich kleiner als JPEG; ohne Unterstützung wird JPEG genutzt. */
|
||||
const CANDIDATES: { type: string; quality: number }[] = [
|
||||
{ type: 'image/webp', quality: 0.82 },
|
||||
{ type: 'image/jpeg', quality: 0.82 },
|
||||
];
|
||||
|
||||
/** Quelldateien werden vor dem Dekodieren begrenzt - ein 100-MB-TIFF soll den Browser nicht lahmlegen. */
|
||||
export const MAX_SOURCE_BYTES = 25_000_000;
|
||||
|
||||
export interface CompressedImage {
|
||||
dataUrl: string;
|
||||
sourceBytes: number;
|
||||
resultBytes: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
function loadImage(file: File): Promise<HTMLImageElement> {
|
||||
const url = URL.createObjectURL(file);
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Das Bild konnte nicht gelesen werden.'));
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/** Länge einer Data-URL in Bytes (Base64 trägt 4 Zeichen pro 3 Bytes). */
|
||||
function dataUrlBytes(dataUrl: string): number {
|
||||
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
|
||||
return Math.round((base64.length * 3) / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verkleinert das Bild auf ein Quadrat und schneidet dabei mittig zu - genau
|
||||
* wie die Anzeige später, damit das Ergebnis dem entspricht, was man sieht.
|
||||
*/
|
||||
export async function compressImage(file: File): Promise<CompressedImage> {
|
||||
if (!file.type.startsWith('image/')) throw new Error('Bitte eine Bilddatei wählen.');
|
||||
if (file.size > MAX_SOURCE_BYTES) throw new Error('Die Datei ist zu groß (max. 25 MB).');
|
||||
|
||||
const image = await loadImage(file);
|
||||
const { naturalWidth: width, naturalHeight: height } = image;
|
||||
if (!width || !height) throw new Error('Das Bild hat keine lesbaren Abmessungen.');
|
||||
|
||||
// Kleine Bilder werden nicht vergrößert.
|
||||
const side = Math.min(TARGET_SIZE, width, height);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = side;
|
||||
canvas.height = side;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Der Browser stellt keinen Canvas bereit.');
|
||||
|
||||
const crop = Math.min(width, height);
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, (width - crop) / 2, (height - crop) / 2, crop, crop, 0, 0, side, side);
|
||||
|
||||
for (const candidate of CANDIDATES) {
|
||||
const dataUrl = canvas.toDataURL(candidate.type, candidate.quality);
|
||||
// Liefert der Browser das Format nicht, fällt er still auf PNG zurück.
|
||||
if (dataUrl.startsWith(`data:${candidate.type}`)) {
|
||||
return { dataUrl, sourceBytes: file.size, resultBytes: dataUrlBytes(dataUrl), type: candidate.type };
|
||||
}
|
||||
}
|
||||
|
||||
const png = canvas.toDataURL('image/png');
|
||||
return { dataUrl: png, sourceBytes: file.size, resultBytes: dataUrlBytes(png), type: 'image/png' };
|
||||
}
|
||||
|
||||
/** "2,4 MB" bzw. "18 KB" */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1).replace('.', ',')} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1000))} KB`;
|
||||
}
|
||||
Reference in New Issue
Block a user