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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user