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:
schakal00783
2026-09-19 23:06:02 +02:00
co-authored by Claude Opus 5
parent 2d3d843988
commit 9576d18abc
32 changed files with 397 additions and 44 deletions
+1 -1
View File
@@ -192,7 +192,7 @@ export const Booking: React.FC = () => {
{readOnly && viewedUser && (
<div className="animate-fade mb-4 flex items-center gap-3 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm font-semibold text-sky-700">
<Avatar name={viewedUser.name} color={viewedUser.color} id={viewedUser.id} size={28} />
<Avatar name={viewedUser.name} color={viewedUser.color} avatar={viewedUser.avatar} id={viewedUser.id} size={28} />
Du siehst die Zeiten von {viewedUser.name} (nur lesen).
<button className="ml-auto text-xs font-bold underline" onClick={() => setViewUserId(user!.id)}>
Zurück zu meinen Zeiten
+1 -1
View File
@@ -111,7 +111,7 @@ export const EntryList: React.FC<{
{showUser && (
<td className="px-3 py-2.5 align-top">
<span className="flex items-center gap-2">
<Avatar id={e.user_id} name={e.user_name} size={22} />
<Avatar id={e.user_id} name={e.user_name} avatar={e.user_avatar} size={22} />
<span className="truncate font-semibold text-ink-700">{e.user_name}</span>
</span>
</td>
+1 -1
View File
@@ -296,7 +296,7 @@ const CustomerCard: React.FC<{
<div className="mt-2 flex flex-wrap gap-1.5">
{p.members.map((m) => (
<span key={m.id} className="flex items-center gap-1.5 rounded-full bg-ink-50 py-0.5 pl-0.5 pr-2 text-[11px] font-semibold text-ink-600">
<Avatar id={m.id} name={m.name} color={m.color} size={18} external={m.is_external} />
<Avatar id={m.id} name={m.name} color={m.color} avatar={m.avatar} size={18} external={m.is_external} />
{m.name}
</span>
))}
+2 -2
View File
@@ -203,7 +203,7 @@ const Ranking: React.FC<{ data: DashboardData; rank: RankPeriod; onRank: (r: Ran
style={{ height: ROW, transform: `translateY(${pos * ROW}px)` }}
>
<span className="tabular w-5 shrink-0 text-center text-xs font-extrabold text-ink-300">{pos + 1}</span>
<Avatar id={r.user_id} name={r.name} color={r.color} size={34} external={r.is_external} />
<Avatar id={r.user_id} name={r.name} color={r.color} avatar={r.avatar} size={34} external={r.is_external} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<button onClick={() => navigate('reports')} className="truncate text-left text-sm font-bold text-ink-800 hover:text-brand-700" title="Zu den Reports">
@@ -240,7 +240,7 @@ const RecentFeed: React.FC<{ data: DashboardData }> = ({ data }) => (
<ol className="relative space-y-1 before:absolute before:bottom-3 before:left-[17px] before:top-3 before:w-px before:bg-ink-100">
{data.recent.map((e, i) => (
<li key={e.id} className="animate-rise relative flex gap-3 rounded-xl p-1.5 hover:bg-ink-50" style={{ '--i': i } as React.CSSProperties}>
<Avatar id={e.user_id} name={e.user_name} color={e.user_color} size={34} />
<Avatar id={e.user_id} name={e.user_name} color={e.user_color} avatar={e.user_avatar} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate text-sm font-bold text-ink-800">{e.user_name}</p>
+1 -1
View File
@@ -37,7 +37,7 @@ export const MobileNav: React.FC<{ current: ViewKey; onNavigate: (view: ViewKey)
>
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-ink-200" />
<button onClick={() => { setMoreOpen(false); onAccount(); }} className="mb-3 flex w-full items-center gap-3 rounded-2xl bg-ink-50 p-3 text-left">
<Avatar name={user.name} color={user.color} id={user.id} size={40} />
<Avatar name={user.name} color={user.color} avatar={user.avatar} id={user.id} size={40} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink-900">{user.name}</span>
<span className="block text-xs text-ink-400">{ROLE_LABELS[user.role]} · Mein Konto</span>
+1 -1
View File
@@ -89,7 +89,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ current, onNavigate, collapsed
<div className={`shrink-0 border-t border-ink-100 ${collapsed ? 'flex flex-col items-center gap-2 p-3' : 'p-4'}`}>
<div className="flex w-full min-w-0 items-center justify-between gap-2">
<button onClick={onAccount} className="flex min-w-0 items-center gap-3 rounded-xl text-left transition hover:opacity-80" title="Mein Konto">
<Avatar name={user.name} color={user.color} id={user.id} size={36} external={user.is_external} />
<Avatar name={user.name} color={user.color} avatar={user.avatar} id={user.id} size={36} external={user.is_external} />
{!collapsed && (
<span className="min-w-0">
<span className="block truncate text-sm font-bold text-ink-900">{user.name}</span>
+2 -2
View File
@@ -311,7 +311,7 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
<div className="flex flex-wrap gap-2">
{p.members.map((m) => (
<span key={m.id} className="flex items-center gap-2 rounded-full border border-ink-200 py-1 pl-1 pr-3 text-xs font-bold text-ink-700">
<Avatar id={m.id} name={m.name} color={m.color} size={22} external={m.is_external} />
<Avatar id={m.id} name={m.name} color={m.color} avatar={m.avatar} size={22} external={m.is_external} />
{m.name}
</span>
))}
@@ -446,7 +446,7 @@ const MembersDialog: React.FC<{ project: Project; users: DirectoryUser[]; onClos
return (
<label key={u.id} className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-transparent hover:bg-ink-50'}`}>
<input type="checkbox" checked={checked} onChange={() => setIds(checked ? ids.filter((id) => id !== u.id) : [...ids, u.id])} />
<Avatar id={u.id} name={u.name} color={u.color} size={30} external={u.is_external} />
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={30} external={u.is_external} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink-800">{u.name}</span>
<span className="block truncate text-xs text-ink-400">{u.is_external ? `Extern · ${u.company ?? ''}` : u.job_title ?? ''}</span>
+1 -1
View File
@@ -372,7 +372,7 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
return (
<label key={u.id} className={`flex cursor-pointer items-center gap-2 rounded-xl border px-2.5 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-ink-200 hover:bg-ink-50'}`}>
<input type="checkbox" className="accent-[var(--color-brand-500)]" checked={checked} onChange={() => setMembers(checked ? members.filter((id) => id !== u.id) : [...members, u.id])} />
<Avatar id={u.id} name={u.name} color={u.color} size={24} external={u.is_external} />
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={24} external={u.is_external} />
<span className="min-w-0 truncate text-xs font-bold text-ink-700">{u.name}</span>
</label>
);
+1 -1
View File
@@ -77,7 +77,7 @@ export const ReportTable: React.FC<{
<td className="py-3 pl-4 pr-3">
<div className="flex min-w-0 items-center gap-2.5">
{kind === 'users' || kind === 'team' ? (
<Avatar id={r.user_id} name={r.name} color={r.color} size={28} external={r.is_external} />
<Avatar id={r.user_id} name={r.name} color={r.color} avatar={r.avatar} size={28} external={r.is_external} />
) : kind !== 'tasks' ? (
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: colorFor(r.customer_id ?? r.project_id ?? r.name, r.color) }} />
) : null}
+1 -1
View File
@@ -300,7 +300,7 @@ const UserEntriesDialog: React.FC<{
}
>
<div className="flex items-center gap-3 pb-4">
<Avatar id={row.user_id} name={row.name} color={row.color} size={40} external={row.is_external} />
<Avatar id={row.user_id} name={row.name} color={row.color} avatar={row.avatar} size={40} external={row.is_external} />
<p className="text-sm font-semibold text-ink-500">
{entries.data ? `${entries.data.length} Buchungen` : 'Lade Buchungen…'}
</p>
+20 -4
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { Avatar, Button, Field, inputClass } from '../ui/controls';
import { Button, Field, inputClass } from '../ui/controls';
import { AvatarPicker } from '../ui/AvatarPicker';
import { useAuth } from '../../lib/auth';
import { authApi } from '../../lib/api';
import { useToast } from '../Toast';
@@ -8,7 +9,7 @@ import { ROLE_LABELS } from '../../types';
const COLORS = ['#6ba04a', '#0ea5e9', '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#eab308', '#6366f1', '#10b981', '#ec4899'];
/** Mein Konto: Farbe, Passwort, Abmelden - fuer jede Rolle. */
/** Mein Konto: Profilbild, Farbe, Passwort, Abmelden - fuer jede Rolle. */
export const AccountDialog: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const { user, updateUser, logout, settings } = useAuth();
const { addToast } = useToast();
@@ -27,6 +28,21 @@ export const AccountDialog: React.FC<{ onClose: () => void }> = ({ onClose }) =>
}
};
/*
* Das Bild wird sofort gespeichert, nicht erst beim Schliessen: der Dialog
* hat keinen Speichern-Knopf, und eine halb uebernommene Aenderung waere
* schlimmer als eine, die sofort steht.
*/
const changeAvatar = async (avatar: string) => {
try {
const { user: saved } = await authApi.updateMe({ avatar: avatar || null });
updateUser(saved);
addToast(avatar ? 'Profilbild gespeichert.' : 'Profilbild entfernt.', 'success');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
}
};
const changePassword = async (event: React.FormEvent) => {
event.preventDefault();
if (next !== repeat) return addToast('Die neuen Passwörter stimmen nicht überein.', 'error');
@@ -46,8 +62,8 @@ export const AccountDialog: React.FC<{ onClose: () => void }> = ({ onClose }) =>
return (
<Modal title="Mein Konto" subtitle={`${ROLE_LABELS[user.role]} · ${user.username}`} onClose={onClose}>
<div className="flex items-center gap-4">
<Avatar name={user.name} color={user.color} id={user.id} size={56} external={user.is_external} />
<div className="flex flex-wrap items-center gap-4">
<AvatarPicker value={user.avatar ?? ''} onChange={changeAvatar} name={user.name} color={user.color} id={user.id} size={72} />
<div className="min-w-0">
<p className="truncate text-lg font-extrabold text-ink-900">{user.name}</p>
<p className="truncate text-sm text-ink-500">{user.email || '–'}</p>
+2 -2
View File
@@ -135,7 +135,7 @@ export const Team: React.FC<{ embedded?: boolean }> = ({ embedded }) => {
<tr key={u.id} className={`animate-rise border-b border-ink-100 last:border-0 hover:bg-ink-50 ${u.is_active ? '' : 'opacity-50'}`} style={{ '--i': Math.min(i, 12) } as React.CSSProperties}>
<td className="px-4 py-2.5">
<div className="flex items-center gap-3">
<Avatar id={u.id} name={u.name} color={u.color} size={34} external={u.is_external} />
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={34} external={u.is_external} />
<div className="min-w-0">
<p className="truncate font-bold text-ink-900">{u.name}</p>
<p className="truncate text-xs text-ink-400">{u.email || u.username}</p>
@@ -236,7 +236,7 @@ const OrgNode: React.FC<{
u.id === meId ? 'border-brand-300' : 'border-ink-200'
} ${u.is_active ? '' : 'opacity-50'}`}
>
<Avatar id={u.id} name={u.name} color={u.color} size={40} external={u.is_external} />
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={40} external={u.is_external} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-extrabold text-ink-900">
{u.name}
+12
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { Button, Field, Toggle, inputClass } from '../ui/controls';
import { AvatarPicker } from '../ui/AvatarPicker';
import { useAuth } from '../../lib/auth';
import { usersApi } from '../../lib/api';
import { useToast } from '../Toast';
@@ -51,6 +52,7 @@ export const UserForm: React.FC<{
company: user?.company ?? '',
weekly_hours: user?.weekly_hours == null ? '' : String(user.weekly_hours),
color: user?.color ?? COLORS[Math.floor(Math.random() * COLORS.length)],
avatar: user?.avatar ?? '',
is_active: user?.is_active ?? true,
});
const [busy, setBusy] = useState(false);
@@ -75,6 +77,7 @@ export const UserForm: React.FC<{
company: form.is_external ? form.company : null,
weekly_hours: form.weekly_hours === '' ? null : Number(form.weekly_hours.replace(',', '.')),
color: form.color,
avatar: form.avatar || null,
is_active: form.is_active,
};
if (!sso) {
@@ -190,6 +193,15 @@ export const UserForm: React.FC<{
)}
</div>
<AvatarPicker
value={form.avatar}
onChange={(avatar) => set({ avatar })}
name={form.name || 'Neuer Benutzer'}
color={form.color}
id={user?.id}
size={72}
/>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap gap-2">
{COLORS.map((c) => (
+94
View File
@@ -0,0 +1,94 @@
import React, { useRef, useState } from 'react';
import { Avatar } from './controls';
import { Icon } from './Icon';
import { useToast } from '../Toast';
import { compressImage, formatBytes } from '../../lib/imageCompress';
/**
* Profilbild auswählen, mit Vorschau. Das Bild wird im Browser auf ein Quadrat
* von 256 px verkleinert, bevor es zum Server geht - so bleiben die Data-URLs
* in der Datenbank klein und das Ergebnis entspricht genau dem Ausschnitt, den
* die runde Anzeige später zeigt.
*/
export const AvatarPicker: React.FC<{
/** Data-URL oder '' für kein Bild. */
value: string;
onChange: (value: string) => void;
name: string;
color?: string | null;
id?: number;
size?: number;
disabled?: boolean;
}> = ({ value, onChange, name, color, id, size = 96, disabled }) => {
const { addToast } = useToast();
const [busy, setBusy] = useState(false);
const input = useRef<HTMLInputElement>(null);
const pick = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
// Zurücksetzen, damit dieselbe Datei nach einem Entfernen erneut geht.
event.target.value = '';
if (!file) return;
setBusy(true);
try {
const result = await compressImage(file);
onChange(result.dataUrl);
addToast(`Bild übernommen (${formatBytes(result.sourceBytes)} → ${formatBytes(result.resultBytes)}).`, 'success');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Das Bild konnte nicht verarbeitet werden.', 'error');
} finally {
setBusy(false);
}
};
return (
<div className="flex items-center gap-4">
<div className="group relative" style={{ width: size, height: size }}>
<Avatar name={name} color={color} avatar={value || null} id={id} size={size} />
{!disabled && (
<button
type="button"
onClick={() => input.current?.click()}
disabled={busy}
aria-label="Profilbild wählen"
className={`absolute inset-0 flex items-center justify-center rounded-full bg-ink-950/50 text-[10px] font-extrabold uppercase tracking-wider text-white transition ${
busy ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100'
}`}
>
{busy ? <span className="h-5 w-5 animate-spin rounded-full border-2 border-white/40 border-t-white" /> : 'Ändern'}
</button>
)}
<input ref={input} type="file" accept="image/*" className="hidden" onChange={pick} disabled={disabled || busy} />
</div>
{!disabled && (
<div className="min-w-0">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => input.current?.click()}
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-xl border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-700 transition hover:bg-ink-50 disabled:opacity-60"
>
<Icon name="user" className="h-3.5 w-3.5" />
Bild wählen
</button>
{value && (
<button
type="button"
onClick={() => onChange('')}
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-xl border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-60"
>
<Icon name="trash" className="h-3.5 w-3.5" />
Entfernen
</button>
)}
</div>
<p className="mt-1.5 text-[11px] leading-snug text-ink-400">
JPEG, PNG, GIF, WebP oder AVIF. Das Bild wird mittig auf ein Quadrat zugeschnitten und verkleinert.
</p>
</div>
)}
</div>
);
};
+25 -14
View File
@@ -194,19 +194,30 @@ export const ErrorBox: React.FC<{ message: string; onRetry?: () => void }> = ({
</div>
);
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 = '' }) => (
<span
title={external ? `${name} (extern)` : name}
className={`relative inline-flex shrink-0 items-center justify-center rounded-full font-extrabold text-white ring-2 ring-surface ${className}`}
style={{ width: size, height: size, fontSize: Math.max(9, size * 0.36), backgroundColor: colorFor(id ?? name, color) }}
>
{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.
*/
<img src={avatar} alt="" className="absolute inset-0 h-full w-full rounded-full object-cover" loading="lazy" />
) : null}
{initials(name)}
{external && (
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-amber-400 text-[7px] text-white ring-2 ring-surface">
@@ -216,14 +227,14 @@ export const Avatar: React.FC<{ name: string; color?: string | null; size?: numb
</span>
);
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 }) => (
<div className="flex items-center -space-x-1">
{people.slice(0, max).map((p) => (
<Avatar key={p.id} id={p.id} name={p.name} color={p.color} size={size} external={p.is_external} />
<Avatar key={p.id} id={p.id} name={p.name} color={p.color} avatar={p.avatar} size={size} external={p.is_external} />
))}
{people.length > max && (
<span
+4 -1
View File
@@ -125,7 +125,7 @@ export const authApi = {
login: (identifier: string, password: string) =>
send<{ token: string; user: AppUser }>('POST', '/auth/login', { identifier, password }),
me: () => get<{ user: AppUser }>('/auth/me'),
updateMe: (changes: Partial<Pick<AppUser, 'theme' | 'theme_mode' | 'color'>>) =>
updateMe: (changes: Partial<Pick<AppUser, 'theme' | 'theme_mode' | 'color' | 'avatar'>>) =>
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;
+89
View File
@@ -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`;
}
+5
View File
@@ -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;