feat: integrate user profile pictures with circular avatar display and upload in settings
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s

This commit is contained in:
sandrodach committed 2026-10-25 21:37:06 +01:00
1 parent 8b043c4809
commit 444442fdf0
13 files changed
+367 -65

No files matched your search

+13 -15
View File
@@ -46,6 +46,7 @@ import { HelpView } from './features/help/HelpView';
import { GroupsView } from './features/teams/GroupsView';
import { useMobileActions } from './context/MobileActionContext';
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
import { UserAvatar } from './components/UserAvatar';
type ActiveTab =
| 'triage'
@@ -588,9 +589,7 @@ export const App: React.FC = () => {
}`}
title="Profil & Einstellungen"
>
<div className="w-8 h-8 rounded-full bg-accent text-on-accent text-xs font-bold shadow-sm flex items-center justify-center">
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
</div>
<UserAvatar user={user} size="sm" className="shadow-sm" />
</button>
</div>
</div>
@@ -632,9 +631,7 @@ export const App: React.FC = () => {
className="h-8 flex items-center gap-2 text-xs font-medium text-main hover:opacity-85 transition-opacity cursor-pointer group"
title="Benutzermenü öffnen"
>
<div className="w-7 h-7 rounded-full bg-accent text-on-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0">
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
</div>
<UserAvatar user={user} size="sm" />
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
{user.displayName}
</span>
@@ -644,12 +641,15 @@ export const App: React.FC = () => {
{showProfileMenu && (
<div className="absolute right-0 top-full mt-1.5 w-64 panel-surface shadow-panel z-50 divide-y divide-border text-xs">
{/* User Info Header */}
<div className="p-3 bg-canvas">
<p className="font-bold text-main truncate">{user.displayName}</p>
<p className="text-[11px] text-muted truncate">{user.email}</p>
<span className="inline-block mt-1.5 px-1.5 py-0.5 bg-panel border border-border rounded-btn text-[10px] text-accent font-semibold">
{roleLabel}
</span>
<div className="p-3 bg-canvas flex items-center gap-2.5">
<UserAvatar user={user} size="md" className="border border-border/70" />
<div className="min-w-0 flex-1">
<p className="font-bold text-main truncate">{user.displayName}</p>
<p className="text-[11px] text-muted truncate">{user.email}</p>
<span className="inline-block mt-1 px-1.5 py-0.5 bg-panel border border-border rounded-btn text-[10px] text-accent font-semibold">
{roleLabel}
</span>
</div>
</div>
{/* Theme Quick Switcher */}
@@ -1070,9 +1070,7 @@ export const App: React.FC = () => {
{/* User Info Header */}
<div className="flex items-center justify-between pb-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent text-on-accent text-sm font-bold flex items-center justify-center flex-shrink-0">
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
</div>
<UserAvatar user={user} size="lg" className="border border-border/80 shadow-sm" />
<div>
<p className="font-bold text-sm text-main">{user.displayName}</p>
<p className="text-xs text-muted">{user.email}</p>
+13 -9
View File
@@ -10,6 +10,7 @@ import {
} from 'lucide-react';
import { User, Team, UnifiedShare } from '../lib/types';
import { UserAutocomplete } from '../features/UserAutocomplete';
import { UserAvatar } from './UserAvatar';
export interface UnifiedShareModalProps {
isOpen: boolean;
@@ -284,15 +285,18 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs hover:border-border/80 transition-colors"
>
<div className="flex items-center gap-2.5 min-w-0">
<div
className={`w-7 h-7 rounded-btn flex items-center justify-center shrink-0 ${
isTeam
? 'bg-accent/15 text-accent'
: 'bg-surface-2 text-main border border-border'
}`}
>
{isTeam ? <Users className="w-3.5 h-3.5" /> : <UserIcon className="w-3.5 h-3.5" />}
</div>
{isTeam ? (
<div className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 bg-accent/15 text-accent border border-accent/25">
<Users className="w-3.5 h-3.5" />
</div>
) : (
<UserAvatar
avatarUrl={sh.avatarUrl || collaborators.find((c) => c.id === sh.targetId)?.avatarUrl}
name={sh.targetName || sh.targetEmail}
size="sm"
className="border border-border/80"
/>
)}
<div className="truncate">
<div className="font-medium text-main truncate flex items-center gap-1.5">
<span className="truncate">{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')}</span>
+157
View File
@@ -0,0 +1,157 @@
import React, { useState } from 'react';
export interface UserAvatarProps {
user?: {
displayName?: string;
email?: string;
avatarUrl?: string;
} | null;
name?: string;
avatarUrl?: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
className?: string;
alt?: string;
}
const SIZE_MAP = {
xs: 'w-5 h-5 text-[10px]',
sm: 'w-7 h-7 text-xs',
md: 'w-9 h-9 text-sm',
lg: 'w-12 h-12 text-base',
xl: 'w-20 h-20 text-2xl',
};
const BG_COLORS = [
'bg-primary/20 text-primary border-primary/30',
'bg-secondary/20 text-secondary border-secondary/30',
'bg-accent/20 text-accent border-accent/30',
'bg-info/20 text-info border-info/30',
'bg-success/20 text-success border-success/30',
'bg-warning/20 text-warning border-warning/30',
];
function getInitials(name?: string, email?: string): string {
const cleanName = (name || '').trim();
if (cleanName) {
const parts = cleanName.split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return cleanName.slice(0, 2).toUpperCase();
}
const cleanEmail = (email || '').trim();
if (cleanEmail) {
return cleanEmail.slice(0, 2).toUpperCase();
}
return '?';
}
function getColorIndex(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return Math.abs(hash) % BG_COLORS.length;
}
export function isEmoji(str?: string): boolean {
if (!str) return false;
const trimmed = str.trim();
// Simple heuristic: length <= 8 and not a URL or base64 image
return !trimmed.startsWith('data:') && !trimmed.startsWith('http') && !trimmed.startsWith('/') && trimmed.length <= 8;
}
export const UserAvatar: React.FC<UserAvatarProps> = ({
user,
name,
avatarUrl,
size = 'md',
className = '',
alt,
}) => {
const [imageError, setImageError] = useState(false);
const effectiveAvatarUrl = avatarUrl !== undefined ? avatarUrl : user?.avatarUrl;
const effectiveName = name !== undefined ? name : (user?.displayName || user?.email || '');
const effectiveEmail = user?.email || '';
const sizeClass = SIZE_MAP[size] || SIZE_MAP.md;
const initials = getInitials(effectiveName, effectiveEmail);
const colorIndex = getColorIndex(effectiveName || effectiveEmail || 'user');
const colorClass = BG_COLORS[colorIndex];
const hasImage = Boolean(
effectiveAvatarUrl &&
!imageError &&
(effectiveAvatarUrl.startsWith('data:image/') ||
effectiveAvatarUrl.startsWith('http://') ||
effectiveAvatarUrl.startsWith('https://') ||
effectiveAvatarUrl.startsWith('/'))
);
const isEmojiAvatar = Boolean(effectiveAvatarUrl && isEmoji(effectiveAvatarUrl));
return (
<div
className={`inline-flex items-center justify-center rounded-full aspect-square shrink-0 select-none overflow-hidden font-semibold transition-all ${sizeClass} ${className}`}
title={effectiveName || alt}
>
{hasImage ? (
<img
src={effectiveAvatarUrl}
alt={alt || effectiveName || 'Avatar'}
onError={() => setImageError(true)}
className="w-full h-full object-cover rounded-full"
/>
) : isEmojiAvatar ? (
<div className="w-full h-full flex items-center justify-center bg-base-300/80 rounded-full">
<span className="leading-none">{effectiveAvatarUrl}</span>
</div>
) : (
<div className={`w-full h-full flex items-center justify-center rounded-full border ${colorClass}`}>
<span className="leading-none tracking-tight">{initials}</span>
</div>
)}
</div>
);
};
/**
* Resizes and center-crops an uploaded image file to a compact base64 Data URL.
*/
export async function resizeImageToDataUrl(file: File, maxSize = 256): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Fehler beim Lesen der Datei'));
reader.onload = (e) => {
const img = new Image();
img.onerror = () => reject(new Error('Fehler beim Laden des Bildes'));
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = maxSize;
canvas.height = maxSize;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Canvas Context konnte nicht erstellt werden'));
return;
}
// Center crop calculation
const minDim = Math.min(img.width, img.height);
const sx = (img.width - minDim) / 2;
const sy = (img.height - minDim) / 2;
ctx.drawImage(img, sx, sy, minDim, minDim, 0, 0, maxSize, maxSize);
// Try webp first, fallback to jpeg
let dataUrl = canvas.toDataURL('image/webp', 0.85);
if (!dataUrl.startsWith('data:image/webp')) {
dataUrl = canvas.toDataURL('image/jpeg', 0.85);
}
resolve(dataUrl);
};
img.src = e.target?.result as string;
};
reader.readAsDataURL(file);
});
}
+3 -6
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { Search, X, Check } from 'lucide-react';
import { User } from '../lib/types';
import { UserAvatar } from '../components/UserAvatar';
interface UserAutocompleteProps {
users: User[];
@@ -101,9 +102,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
{selectedUser ? (
<div className="h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 min-w-0">
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
{(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()}
</div>
<UserAvatar user={selectedUser} size="xs" />
<span className="font-semibold text-main truncate">
{selectedUser.displayName || selectedUser.email}
</span>
@@ -170,9 +169,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
}`}
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
{(u.displayName || u.email || '?')[0].toUpperCase()}
</div>
<UserAvatar user={u} size="xs" />
<div className="truncate">
<span className="font-semibold text-main">
{u.displayName || u.email}
@@ -21,6 +21,7 @@ import {
UserRole,
} from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { UserAvatar } from '../../components/UserAvatar';
interface AdminSettingsViewProps {
currentUser: User;
@@ -556,16 +557,19 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{users.map((u) => (
<div key={u.id} className="p-3 space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-main">{u.displayName}</span>
{!u.isActive && (
<span className="badge-err text-[10px]">
Gesperrt
</span>
)}
<div className="flex items-center gap-2.5">
<UserAvatar user={u} size="sm" className="border border-border/80" />
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-main">{u.displayName}</span>
{!u.isActive && (
<span className="badge-err text-[10px]">
Gesperrt
</span>
)}
</div>
<p className="text-muted text-[11px]">{u.email}</p>
</div>
<p className="text-muted text-[11px]">{u.email}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import {
User as UserIcon,
Bell,
@@ -15,10 +15,13 @@ import {
Send,
AlertCircle,
Palette,
Upload,
Camera,
} from 'lucide-react';
import { APIKey, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
interface UserSettingsViewProps {
currentUser: User;
@@ -42,9 +45,12 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
// --- Profile State ---
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
const [email, setEmail] = useState(currentUser.email || '');
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
const [newPassword, setNewPassword] = useState('');
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// --- Multi-Channel Alarms State ---
const [alarmChannels, setAlarmChannels] = useState<string[]>(
@@ -69,6 +75,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
useEffect(() => {
setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || '');
setAvatarUrl(currentUser.avatarUrl || '');
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
@@ -95,6 +102,34 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
);
};
// --- Handlers: Avatar ---
const handleAvatarFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsProcessingAvatar(true);
setProfileMsg(null);
try {
const dataUrl = await resizeImageToDataUrl(file, 256);
setAvatarUrl(dataUrl);
triggerHaptic(10);
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Laden des Bildes' });
} finally {
setIsProcessingAvatar(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleSelectPresetAvatar = (preset: string) => {
setAvatarUrl(preset);
triggerHaptic(10);
};
const handleRemoveAvatar = () => {
setAvatarUrl('');
triggerHaptic(10);
};
// --- Handlers: Profile ---
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
@@ -104,6 +139,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const patch: any = {
displayName: displayName.trim(),
email: email.trim(),
avatarUrl: avatarUrl.trim(),
};
if (newPassword.trim()) {
patch.password = newPassword.trim();
@@ -358,7 +394,88 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
)}
<form onSubmit={handleSaveProfile} className="space-y-4">
<form onSubmit={handleSaveProfile} className="space-y-5">
{/* Avatar Management */}
<div className="p-3.5 bg-panel-elevated/40 border border-border rounded-box space-y-3">
<label className="block text-muted font-medium">Profilbild</label>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4">
<div className="relative group shrink-0">
<UserAvatar
user={{ displayName, email, avatarUrl }}
size="xl"
className="border-2 border-border/80 shadow-md ring-2 ring-primary/20"
/>
{isProcessingAvatar && (
<div className="absolute inset-0 bg-base-100/70 rounded-full flex items-center justify-center text-[10px] font-medium text-main">
Lädt…
</div>
)}
</div>
<div className="flex-1 space-y-2.5 text-center sm:text-left">
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarFileSelected}
className="hidden"
id="avatar-upload-input"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isProcessingAvatar}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
<span>Bild hochladen</span>
</button>
{avatarUrl && (
<button
type="button"
onClick={handleRemoveAvatar}
className="btn-ghost h-8 px-2.5 text-xs text-status-err hover:bg-status-err/10 flex items-center gap-1"
title="Profilbild entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Entfernen</span>
</button>
)}
</div>
{/* Emoji Presets */}
<div className="space-y-1">
<span className="text-[11px] text-muted">Oder schnelles Emoji wählen:</span>
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-1.5 pt-0.5">
{['🦊', '🐱', '🐶', '🐼', '🦁', '🦉', '🚀', '☕', '⚡', '🎨', '🌟', '🎯', '💡'].map(
(preset) => (
<button
key={preset}
type="button"
onClick={() => handleSelectPresetAvatar(preset)}
className={`w-7 h-7 rounded-full text-sm flex items-center justify-center transition-transform hover:scale-110 active:scale-95 ${
avatarUrl === preset
? 'bg-primary/20 border-2 border-primary scale-105'
: 'bg-panel hover:bg-panel-hover border border-border'
}`}
title={`Emoji ${preset}`}
>
{preset}
</button>
)
)}
</div>
</div>
<p className="text-[10px] text-faint">
Wird überall kreisförmig dargestellt (z.&nbsp;B. Menüleiste oben, Freigabelisten, Aufgaben).
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-muted font-medium mb-1">Anzeigename</label>
+2 -3
View File
@@ -15,6 +15,7 @@ import {
import { Team, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { UserAutocomplete } from '../UserAutocomplete';
import { UserAvatar } from '../../components/UserAvatar';
interface GroupsViewProps {
currentUser: User;
@@ -487,9 +488,7 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
className="p-2.5 bg-panel border border-border rounded-btn flex items-center justify-between gap-2 text-xs"
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-full bg-accent/20 text-accent text-[10px] font-bold flex items-center justify-center flex-shrink-0">
{(m.displayName || 'U').slice(0, 1).toUpperCase()}
</div>
<UserAvatar user={{ displayName: m.displayName, email: m.email, avatarUrl: m.avatarUrl }} size="xs" />
<div className="min-w-0">
<p className="text-main font-medium truncate text-xs">
{m.displayName} {isSelf && <span className="text-faint">(Du)</span>}
+7 -2
View File
@@ -1249,8 +1249,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
return (
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
{otherMembers.slice(0, 2).map((m) => (
<span key={m.userId} className="w-3.5 h-3.5 rounded-pill bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center">
{getInitials(m.displayName || m.email)[0]}
<span key={m.userId} className="w-3.5 h-3.5 rounded-full overflow-hidden bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center shrink-0">
{m.avatarUrl ? (
<img src={m.avatarUrl} alt="" className="w-full h-full object-cover rounded-full" />
) : (
getInitials(m.displayName || m.email)[0]
)}
</span>
))}
</div>
@@ -1563,6 +1567,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
targetId: sh.targetId,
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
targetEmail: sh.email,
avatarUrl: sh.avatarUrl,
permission: 'editor' as const,
role: 'editor',
createdAt: sh.createdAt,
+1
View File
@@ -163,6 +163,7 @@ export const api = {
displayName?: string;
email?: string;
password?: string;
avatarUrl?: string;
alarmChannels?: string[];
alarmEmail?: string;
discordWebhookUrl?: string;
+6
View File
@@ -14,6 +14,7 @@ export interface User {
email: string;
username?: string;
displayName: string;
avatarUrl?: string;
role: UserRole;
isActive: boolean;
alarmChannels?: string[];
@@ -79,6 +80,7 @@ export interface TaskListShare {
targetId: string;
targetName?: string;
email?: string;
avatarUrl?: string;
createdAt: string;
}
@@ -86,6 +88,7 @@ export interface TaskListMember {
userId: string;
displayName: string;
email: string;
avatarUrl?: string;
role: string;
}
@@ -132,6 +135,7 @@ export interface TeamMember {
userId: string;
displayName: string;
email: string;
avatarUrl?: string;
role: 'admin' | 'member';
joinedAt: string;
}
@@ -240,6 +244,7 @@ export interface SpaceMember {
userId: string;
displayName: string;
email: string;
avatarUrl?: string;
role: 'owner' | 'editor' | 'viewer';
joinedAt: string;
}
@@ -250,6 +255,7 @@ export interface UnifiedShare {
targetId: string;
targetName?: string;
targetEmail?: string;
avatarUrl?: string;
permission?: 'editor' | 'viewer';
role?: string;
createdBy?: string;