From 9576d18abcd1af15e2bad383cb97335ccf5b60b4 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Sat, 19 Sep 2026 23:06:02 +0200 Subject: [PATCH] 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) --- README.md | 18 +++++ server/package.json | 2 +- server/src/db.js | 16 ++++ server/src/lib/avatar.js | 35 +++++++++ server/src/lib/entries.js | 2 +- server/src/lib/projects.js | 4 +- server/src/middleware/auth.js | 3 +- server/src/routes/auth.js | 8 +- server/src/routes/dashboard.js | 4 +- server/src/routes/entries.js | 1 + server/src/routes/reports.js | 6 +- server/src/routes/users.js | 8 +- server/src/schema.sql | 1 + server/test/avatar.test.mjs | 37 +++++++++ src/components/booking/Booking.tsx | 2 +- src/components/booking/EntryList.tsx | 2 +- src/components/customers/Customers.tsx | 2 +- src/components/dashboard/Dashboard.tsx | 4 +- src/components/layout/MobileNav.tsx | 2 +- src/components/layout/Sidebar.tsx | 2 +- src/components/projects/ProjectDetail.tsx | 4 +- src/components/projects/ProjectForm.tsx | 2 +- src/components/reports/ReportTable.tsx | 2 +- src/components/reports/Reports.tsx | 2 +- src/components/settings/AccountDialog.tsx | 24 +++++- src/components/team/Team.tsx | 4 +- src/components/team/UserForm.tsx | 12 +++ src/components/ui/AvatarPicker.tsx | 94 +++++++++++++++++++++++ src/components/ui/controls.tsx | 39 ++++++---- src/lib/api.ts | 5 +- src/lib/imageCompress.ts | 89 +++++++++++++++++++++ src/types.ts | 5 ++ 32 files changed, 397 insertions(+), 44 deletions(-) create mode 100644 server/src/lib/avatar.js create mode 100644 server/test/avatar.test.mjs create mode 100644 src/components/ui/AvatarPicker.tsx create mode 100644 src/lib/imageCompress.ts diff --git a/README.md b/README.md index 571d651..6be9c2c 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,23 @@ Ein Klick auf eine Zeile öffnet dieselbe Detailansicht wie eine Kachel, hier un mindestens Vorgesetzter ist. +## Profilbilder + +Jeder kann sein eigenes Bild hochladen (*Mein Konto*), Vorgesetzte und Administratoren +auch für die Benutzer, die sie verwalten (*Team → Bearbeiten*). Der Browser verkleinert +das Bild vor dem Hochladen auf ein Quadrat von 256 px und schneidet mittig zu +(`src/lib/imageCompress.ts`); aus einem 4-MB-Handyfoto werden so wenige Kilobyte. + +Gespeichert wird es als Data-URL in `pulse_users.avatar` (LONGTEXT) – kein Upload-Verzeichnis, +das gesichert und beim Deployment am Leben gehalten werden müsste. Der Server prüft in +`server/src/lib/avatar.js`: nur PNG, JPEG, GIF, WebP und AVIF als Data-URL, höchstens 2 MB. +**SVG ist bewusst nicht erlaubt** – es kann Skripte tragen. Externe http(s)-Adressen ebenso +wenig: sonst lädt jeder Seitenaufruf ein Bild von einem fremden Server und verrät, wer wann +mit Pulse arbeitet. + +Angezeigt wird es überall dort, wo bisher die Initialen standen: Seitenleiste, Kopfzeile, +Team, Dashboard, Reports, Projekt- und Kundenansicht. Ohne Bild bleiben die Initialen. + ## SSO / Active Directory **Einrichtung** (nur Administrator, *Einstellungen → SSO / Entra ID*): Microsoft Entra ID per OpenID Connect (Authorization Code, Client Secret AES-verschlüsselt in der DB). App-Registrierung mit Redirect-URI `https://pulse.perinco.de/api/auth/sso/callback`, delegiert `openid profile email`, Anwendung `User.Read.All` mit Administratorzustimmung. @@ -155,6 +172,7 @@ Aus Auftrag und Call-Mitschrift. | Buchung: zusätzliche Listenansicht (Kunde, Projekt, Task, Billable/Non-billable Zeit) für Woche/Monat/Jahr | ✅ | Buchung → Kalender/Liste | | Klick auf einen Mitarbeiter im Zeitreport zeigt dessen Buchungen des Zeitraums als Liste | ✅ | Reports → Zeitreport → Mitarbeiter | | Kunden: zusätzliche Listenansicht | ✅ | Kunden → Karten/Liste | +| Eigenes Profilbild hochladen | ✅ | Mein Konto, Team → Bearbeiten | | Projekte: zusätzliche Listenansicht | ✅ | Projekte → Karten/Liste | | Grünes Plus → Projekt, Task, optionaler Freitext, Zeit rechts daneben, `0:00`, `1:23` und `1,5` | ✅ | Buchungsdialog | | Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog | diff --git a/server/package.json b/server/package.json index 22f5dbc..1d406e9 100644 --- a/server/package.json +++ b/server/package.json @@ -8,7 +8,7 @@ "scripts": { "start": "node src/server.js", "dev": "node --watch src/server.js", - "test": "node --test test/sso-filter.test.mjs" + "test": "node --test \"test/*.test.mjs\"" }, "dependencies": { "bcryptjs": "^2.4.3", diff --git a/server/src/db.js b/server/src/db.js index a83e855..03db3bd 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -40,6 +40,7 @@ export async function initDatabase() { } await migrateAbrufType(); + await migrateAvatarColumn(); await ensureAdminUser(); await ensureInternalCustomer(); } @@ -69,6 +70,21 @@ async function migrateAbrufType() { } } +/* + * Profilbild am Benutzer. Steht als Data-URL in der Spalte - siehe + * lib/avatar.js, warum kein Dateisystem. + */ +async function migrateAvatarColumn() { + const [columns] = await pool.query( + `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pulse_users' AND COLUMN_NAME = 'avatar'` + ); + if (!columns.length) { + await pool.query('ALTER TABLE pulse_users ADD COLUMN avatar LONGTEXT NULL AFTER color'); + console.log('[Pulse] Spalte pulse_users.avatar ergaenzt.'); + } +} + async function ensureAdminUser() { const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users'); if (rows[0].n > 0) return; diff --git a/server/src/lib/avatar.js b/server/src/lib/avatar.js new file mode 100644 index 0000000..a201466 --- /dev/null +++ b/server/src/lib/avatar.js @@ -0,0 +1,35 @@ +/* + * Profilbild eines Benutzers. + * + * Das Bild liegt als Data-URL in der Datenbank (pulse_users.avatar, LONGTEXT). + * Kein Dateisystem: der Dienst laeuft als eigener Benutzer in /var/www/pulse, + * ein Upload-Verzeichnis muesste gesichert, gesichert-gesichert und beim Deploy + * am Leben gehalten werden. Die Bilder sind nach dem Verkleinern im Browser + * wenige Kilobyte gross, das traegt eine Spalte problemlos. + * + * Erlaubt sind nur Bild-Data-URLs. Eine externe http(s)-Adresse waere bequem, + * wuerde aber bedeuten, dass jeder Seitenaufruf ein Bild von einem fremden + * Server nachlaedt - das gibt die Benutzung von Pulse nach draussen preis. + */ + +/** Data-URL eines Bildes. SVG ist bewusst nicht dabei: es kann Skripte tragen. */ +const AVATAR_RE = /^data:image\/(png|jpe?g|gif|webp|avif);base64,[A-Za-z0-9+/=\s]+$/i; + +/** + * Obergrenze der Data-URL. Das JSON-Limit der Anwendung ist 4 MB; hier bleibt + * bewusst Luft fuer den Rest des Requests. + */ +export const AVATAR_MAX_LENGTH = 2_000_000; + +/** Prueft ein Profilbild; null bzw. '' entfernt es. */ +export function normalizeAvatar(value) { + if (value === null || value === undefined || String(value).trim() === '') return { value: null }; + const avatar = String(value).trim(); + if (avatar.length > AVATAR_MAX_LENGTH) { + return { error: 'Das Bild ist zu groß. Bitte ein kleineres verwenden.' }; + } + if (!AVATAR_RE.test(avatar)) { + return { error: 'Ungültiges Bild (erwartet PNG, JPEG, GIF, WebP oder AVIF).' }; + } + return { value: avatar }; +} diff --git a/server/src/lib/entries.js b/server/src/lib/entries.js index 295baae..d875cec 100644 --- a/server/src/lib/entries.js +++ b/server/src/lib/entries.js @@ -43,7 +43,7 @@ export async function loadEntries(settings, { from, to, userIds = null, customer const [rows] = await pool.query( `SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable, e.created_at, e.updated_at, - u.name AS user_name, u.color AS user_color, u.is_external, u.company AS user_company, u.weekly_hours, + u.name AS user_name, u.color AS user_color, u.avatar AS user_avatar, u.is_external, u.company AS user_company, u.weekly_hours, u.manager_id, p.name AS project_name, p.code AS project_code, p.type AS project_type, p.billing_unit, p.rate AS project_rate, diff --git a/server/src/lib/projects.js b/server/src/lib/projects.js index 77dc85f..10ec4d0 100644 --- a/server/src/lib/projects.js +++ b/server/src/lib/projects.js @@ -186,7 +186,7 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {} pool.query(`SELECT * FROM pulse_budget_pots WHERE project_id IN (${inList}) ORDER BY valid_from, id`, ids), pool.query(`SELECT * FROM pulse_tasks WHERE project_id IN (${inList}) ORDER BY name`, ids), pool.query( - `SELECT m.project_id, u.id, u.name, u.color, u.is_external + `SELECT m.project_id, u.id, u.name, u.color, u.avatar, u.is_external FROM pulse_project_members m JOIN pulse_users u ON u.id = m.user_id WHERE m.project_id IN (${inList}) AND u.role <> 'admin' ORDER BY u.name`, ids @@ -242,7 +242,7 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {} .map((t) => ({ ...t, billable: Boolean(t.billable), is_active: Boolean(t.is_active), used_minutes: taskMinutes.get(t.id) || 0 })), members: members .filter((m) => m.project_id === p.id) - .map((m) => ({ id: m.id, name: m.name, color: m.color, is_external: Boolean(m.is_external) })), + .map((m) => ({ id: m.id, name: m.name, color: m.color, avatar: m.avatar ?? null, is_external: Boolean(m.is_external) })), used_minutes: usedMinutes, billable_minutes: Number(usage?.billable_minutes || 0), // Was gegen das Budget zaehlt (bei Billable nur abrechenbare Stunden). diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js index dccdfa2..353c006 100644 --- a/server/src/middleware/auth.js +++ b/server/src/middleware/auth.js @@ -25,7 +25,7 @@ export function verifyShort(token) { } export const USER_COLUMNS = `id, username, email, name, role, manager_id, job_title, is_external, company, - source, external_id, weekly_hours, color, theme, theme_mode, is_active, last_login_at, created_at, updated_at`; + source, external_id, weekly_hours, color, avatar, theme, theme_mode, is_active, last_login_at, created_at, updated_at`; /** * Prueft das Bearer-Token und laedt den Benutzer bei jeder Anfrage frisch aus @@ -84,6 +84,7 @@ export function publicUser(row) { source: row.source, weekly_hours: row.weekly_hours == null ? null : Number(row.weekly_hours), color: row.color ?? null, + avatar: row.avatar ?? null, theme: row.theme ?? null, theme_mode: row.theme_mode ?? null, is_active: Boolean(row.is_active), diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index c1fd806..905f3e1 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -5,6 +5,7 @@ import { pool } from '../db.js'; import { publicUser, requireAuth, signShort, signToken, verifyShort } from '../middleware/auth.js'; import { getSettings } from '../lib/settings.js'; import { authorizeUrl, exchangeCode, ssoConfigured } from '../lib/sso.js'; +import { normalizeAvatar } from '../lib/avatar.js'; const router = express.Router(); @@ -70,7 +71,7 @@ router.post('/login', async (req, res, next) => { router.get('/me', requireAuth, (req, res) => res.json({ user: publicUser(req.user) })); -// Eigene Darstellung: Design, Modus, Farbe. +// Eigene Darstellung: Design, Modus, Farbe, Profilbild. router.patch('/me', requireAuth, async (req, res, next) => { const updates = {}; if (req.body?.theme !== undefined) { @@ -90,6 +91,11 @@ router.patch('/me', requireAuth, async (req, res, next) => { if (color !== null && !/^#[0-9a-f]{6}$/i.test(color)) return res.status(400).json({ error: 'Ungültige Farbe.' }); updates.color = color; } + if (req.body?.avatar !== undefined) { + const avatar = normalizeAvatar(req.body.avatar); + if (avatar.error) return res.status(400).json({ error: avatar.error }); + updates.avatar = avatar.value; + } const keys = Object.keys(updates); if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' }); try { diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js index 48b2116..ec90aa5 100644 --- a/server/src/routes/dashboard.js +++ b/server/src/routes/dashboard.js @@ -61,7 +61,7 @@ router.get('/', async (req, res, next) => { const filter = userFilter(userIds, 'id'); const [users] = await pool.query( - `SELECT id, name, color, role, weekly_hours, is_external, company, job_title FROM pulse_users + `SELECT id, name, color, avatar, role, weekly_hours, is_external, company, job_title FROM pulse_users WHERE is_active = 1 AND role <> 'admin' AND ${filter.sql} ORDER BY name`, filter.params ); @@ -86,6 +86,7 @@ router.get('/', async (req, res, next) => { user_id: u.id, name: u.name, color: u.color, + avatar: u.avatar ?? null, job_title: u.job_title, is_external: Boolean(u.is_external), ...totals, @@ -127,6 +128,7 @@ router.get('/', async (req, res, next) => { user_id: e.user_id, user_name: e.user_name, user_color: e.user_color, + user_avatar: e.user_avatar ?? null, project_display: e.project_display, customer_color: e.customer_color, task_name: e.task_name, diff --git a/server/src/routes/entries.js b/server/src/routes/entries.js index f575cb9..26beb62 100644 --- a/server/src/routes/entries.js +++ b/server/src/routes/entries.js @@ -14,6 +14,7 @@ function publicEntry(entry, showAmounts) { id: entry.id, user_id: entry.user_id, user_name: entry.user_name, + user_avatar: entry.user_avatar ?? null, project_id: entry.project_id, project_name: entry.project_name, project_code: entry.project_code, diff --git a/server/src/routes/reports.js b/server/src/routes/reports.js index 05f7272..dba22f0 100644 --- a/server/src/routes/reports.js +++ b/server/src/routes/reports.js @@ -50,7 +50,7 @@ export async function buildTimeReport(viewer, query) { const filter = userFilter(userIds, 'id'); const [users] = await pool.query( - `SELECT id, name, color, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`, + `SELECT id, name, color, avatar, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`, filter.params ); const capTo = to > today() ? today() : to; @@ -60,12 +60,12 @@ export async function buildTimeReport(viewer, query) { const amount = entries.reduce((s, e) => s + e.amount, 0); const fixedAmount = entries.reduce((s, e) => s + e.fixed_amount, 0); - const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, is_external: e.is_external }), includeFixed); + const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, avatar: e.user_avatar ?? null, is_external: e.is_external }), includeFixed); // Mitarbeiter ohne Buchung trotzdem zeigen (nur aktive, nicht bei Kunden-/Projektfilter). if (!query.customer_id && !query.project_id) { for (const u of users) { if (u.is_active && !userRows.some((row) => row.user_id === u.id)) { - userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 }); + userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, avatar: u.avatar ?? null, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 }); } } } diff --git a/server/src/routes/users.js b/server/src/routes/users.js index 7f0e3fb..cd1ca0b 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs'; import { pool } from '../db.js'; import { ROLES, USER_COLUMNS, publicUser, requireRole } from '../middleware/auth.js'; import { visibleUserIds } from '../lib/access.js'; +import { normalizeAvatar } from '../lib/avatar.js'; const router = express.Router(); @@ -81,6 +82,11 @@ function normalize(body, { creating }) { if (body.is_external !== undefined) out.is_external = body.is_external ? 1 : 0; if (body.company !== undefined) out.company = str(body.company, 160); if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null; + if (body.avatar !== undefined) { + const avatar = normalizeAvatar(body.avatar); + if (avatar.error) errors.push(avatar.error); + else out.avatar = avatar.value; + } if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0; if (body.weekly_hours !== undefined) { out.weekly_hours = body.weekly_hours === '' || body.weekly_hours == null ? null : Number(body.weekly_hours); @@ -93,7 +99,7 @@ function normalize(body, { creating }) { router.get('/directory', async (req, res, next) => { try { const [rows] = await pool.query( - 'SELECT id, name, color, role, manager_id, is_external, company, job_title, is_active FROM pulse_users ORDER BY name' + 'SELECT id, name, color, avatar, role, manager_id, is_external, company, job_title, is_active FROM pulse_users ORDER BY name' ); res.json(rows.map((r) => ({ ...r, is_external: Boolean(r.is_external), is_active: Boolean(r.is_active) }))); } catch (err) { diff --git a/server/src/schema.sql b/server/src/schema.sql index a557c7c..7c268e7 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS pulse_users ( external_id VARCHAR(64) NULL, weekly_hours DECIMAL(5,2) NULL, color VARCHAR(7) NULL, + avatar LONGTEXT NULL, theme VARCHAR(20) NULL, theme_mode VARCHAR(10) NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, diff --git a/server/test/avatar.test.mjs b/server/test/avatar.test.mjs new file mode 100644 index 0000000..75bc82f --- /dev/null +++ b/server/test/avatar.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeAvatar, AVATAR_MAX_LENGTH } from '../src/lib/avatar.js'; + +const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=='; + +test('nimmt Bild-Data-URLs an', () => { + for (const typ of ['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif']) { + const url = `data:image/${typ};base64,AAAA`; + assert.equal(normalizeAvatar(url).value, url, typ); + } +}); + +test('leer entfernt das Bild', () => { + for (const wert of [null, undefined, '', ' ']) { + assert.deepEqual(normalizeAvatar(wert), { value: null }); + } +}); + +test('weist alles ab, was kein Bild ist', () => { + // SVG kann Skripte tragen, eine externe Adresse verraet die Benutzung nach draussen. + for (const wert of [ + 'data:image/svg+xml;base64,PHN2Zy8+', + 'data:text/html;base64,PGgxPmhpPC9oMT4', + 'https://example.com/foto.png', + 'javascript:alert(1)', + 'hallo', + ]) { + assert.ok(normalizeAvatar(wert).error, wert); + } +}); + +test('begrenzt die Groesse', () => { + const zuGross = `data:image/png;base64,${'A'.repeat(AVATAR_MAX_LENGTH)}`; + assert.match(normalizeAvatar(zuGross).error, /zu groß/); + assert.equal(normalizeAvatar(PNG).error, undefined); +}); diff --git a/src/components/booking/Booking.tsx b/src/components/booking/Booking.tsx index ab43a8f..39cbea4 100644 --- a/src/components/booking/Booking.tsx +++ b/src/components/booking/Booking.tsx @@ -192,7 +192,7 @@ export const Booking: React.FC = () => { {readOnly && viewedUser && (
- + Du siehst die Zeiten von {viewedUser.name} (nur lesen). + )} + +
+ {!disabled && ( +
+
+ + {value && ( + + )} +
+

+ JPEG, PNG, GIF, WebP oder AVIF. Das Bild wird mittig auf ein Quadrat zugeschnitten und verkleinert. +

+
+ )} + + ); +}; diff --git a/src/components/ui/controls.tsx b/src/components/ui/controls.tsx index 85ff726..8448878 100644 --- a/src/components/ui/controls.tsx +++ b/src/components/ui/controls.tsx @@ -194,19 +194,30 @@ export const ErrorBox: React.FC<{ message: string; onRetry?: () => void }> = ({ ); -export const Avatar: React.FC<{ name: string; color?: string | null; size?: number; id?: number; external?: boolean; className?: string }> = ({ - name, - color, - size = 32, - id, - external, - className = '', -}) => ( +export const Avatar: React.FC<{ + name: string; + color?: string | null; + /** Profilbild als Data-URL; ohne Bild bleiben die Initialen stehen. */ + avatar?: string | null; + size?: number; + id?: number; + external?: boolean; + className?: string; +}> = ({ name, color, avatar, size = 32, id, external, className = '' }) => ( + {avatar ? ( + /* + * Rund wird das Bild selbst, nicht der Rahmen: ein overflow-hidden am + * Rahmen wuerde die Kennzeichnung fuer externe Mitarbeiter abschneiden, + * die bewusst ueber den Rand ragt. Die Initialen bleiben darunter + * stehen - laedt das Bild nicht, sieht man sie. + */ + + ) : null} {initials(name)} {external && ( @@ -216,14 +227,14 @@ export const Avatar: React.FC<{ name: string; color?: string | null; size?: numb ); -export const AvatarStack: React.FC<{ people: { id: number; name: string; color: string | null; is_external?: boolean }[]; max?: number; size?: number }> = ({ - people, - max = 5, - size = 26, -}) => ( +export const AvatarStack: React.FC<{ + people: { id: number; name: string; color: string | null; avatar?: string | null; is_external?: boolean }[]; + max?: number; + size?: number; +}> = ({ people, max = 5, size = 26 }) => (
{people.slice(0, max).map((p) => ( - + ))} {people.length > max && ( send<{ token: string; user: AppUser }>('POST', '/auth/login', { identifier, password }), me: () => get<{ user: AppUser }>('/auth/me'), - updateMe: (changes: Partial>) => + updateMe: (changes: Partial>) => send<{ user: AppUser }>('PATCH', '/auth/me', changes), changePassword: (current_password: string, new_password: string) => send<{ ok: true }>('POST', '/auth/change-password', { current_password, new_password }), @@ -278,6 +278,7 @@ export interface DashboardData { user_id: number; name: string; color: string | null; + avatar: string | null; job_title: string | null; is_external: boolean; minutes: number; @@ -293,6 +294,7 @@ export interface DashboardData { user_id: number; user_name: string; user_color: string | null; + user_avatar: string | null; project_display: string; customer_color: string | null; task_name: string | null; @@ -312,6 +314,7 @@ export interface GroupRow { key: string | number; name: string; color?: string | null; + avatar?: string | null; minutes: number; billable_minutes: number; non_billable_minutes: number; diff --git a/src/lib/imageCompress.ts b/src/lib/imageCompress.ts new file mode 100644 index 0000000..8e408f9 --- /dev/null +++ b/src/lib/imageCompress.ts @@ -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 { + 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 { + 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`; +} diff --git a/src/types.ts b/src/types.ts index 07c3a33..ed912ee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,8 @@ export interface AppUser { source: 'local' | 'sso'; weekly_hours: number | null; color: string | null; + /** Profilbild als Data-URL; null = Initialen. */ + avatar: string | null; theme: ThemePalette | null; theme_mode: 'light' | 'dark' | null; is_active: boolean; @@ -57,6 +59,7 @@ export interface DirectoryUser { id: number; name: string; color: string | null; + avatar: string | null; role: Role; manager_id: number | null; is_external: boolean; @@ -133,6 +136,7 @@ export interface MemberRef { id: number; name: string; color: string | null; + avatar: string | null; is_external: boolean; } @@ -174,6 +178,7 @@ export interface TimeEntry { id: number; user_id: number; user_name: string; + user_avatar: string | null; project_id: number; project_name: string; project_code: string;