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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
This commit is contained in:
1 parent
8b043c4809
commit
444442fdf0
13 files changed
+367
-65
No files matched your search
@@ -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. 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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user