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

+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,