Dev #5
No files matched your search
+9
-11
@@ -46,6 +46,7 @@ import { HelpView } from './features/help/HelpView';
|
|||||||
import { GroupsView } from './features/teams/GroupsView';
|
import { GroupsView } from './features/teams/GroupsView';
|
||||||
import { useMobileActions } from './context/MobileActionContext';
|
import { useMobileActions } from './context/MobileActionContext';
|
||||||
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
||||||
|
import { UserAvatar } from './components/UserAvatar';
|
||||||
|
|
||||||
type ActiveTab =
|
type ActiveTab =
|
||||||
| 'triage'
|
| 'triage'
|
||||||
@@ -588,9 +589,7 @@ export const App: React.FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
title="Profil & Einstellungen"
|
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">
|
<UserAvatar user={user} size="sm" className="shadow-sm" />
|
||||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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"
|
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">
|
<UserAvatar user={user} size="sm" />
|
||||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
|
||||||
</div>
|
|
||||||
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
||||||
{user.displayName}
|
{user.displayName}
|
||||||
</span>
|
</span>
|
||||||
@@ -644,13 +641,16 @@ export const App: React.FC = () => {
|
|||||||
{showProfileMenu && (
|
{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">
|
<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 */}
|
{/* User Info Header */}
|
||||||
<div className="p-3 bg-canvas">
|
<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="font-bold text-main truncate">{user.displayName}</p>
|
||||||
<p className="text-[11px] text-muted truncate">{user.email}</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">
|
<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}
|
{roleLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Theme Quick Switcher */}
|
{/* Theme Quick Switcher */}
|
||||||
<div className="p-2.5 space-y-1.5">
|
<div className="p-2.5 space-y-1.5">
|
||||||
@@ -1070,9 +1070,7 @@ export const App: React.FC = () => {
|
|||||||
{/* User Info Header */}
|
{/* User Info Header */}
|
||||||
<div className="flex items-center justify-between pb-2">
|
<div className="flex items-center justify-between pb-2">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<UserAvatar user={user} size="lg" className="border border-border/80 shadow-sm" />
|
||||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-sm text-main">{user.displayName}</p>
|
<p className="font-bold text-sm text-main">{user.displayName}</p>
|
||||||
<p className="text-xs text-muted">{user.email}</p>
|
<p className="text-xs text-muted">{user.email}</p>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { User, Team, UnifiedShare } from '../lib/types';
|
import { User, Team, UnifiedShare } from '../lib/types';
|
||||||
import { UserAutocomplete } from '../features/UserAutocomplete';
|
import { UserAutocomplete } from '../features/UserAutocomplete';
|
||||||
|
import { UserAvatar } from './UserAvatar';
|
||||||
|
|
||||||
export interface UnifiedShareModalProps {
|
export interface UnifiedShareModalProps {
|
||||||
isOpen: boolean;
|
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"
|
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="flex items-center gap-2.5 min-w-0">
|
||||||
<div
|
{isTeam ? (
|
||||||
className={`w-7 h-7 rounded-btn flex items-center justify-center shrink-0 ${
|
<div className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 bg-accent/15 text-accent border border-accent/25">
|
||||||
isTeam
|
<Users className="w-3.5 h-3.5" />
|
||||||
? '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>
|
</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="truncate">
|
||||||
<div className="font-medium text-main truncate flex items-center gap-1.5">
|
<div className="font-medium text-main truncate flex items-center gap-1.5">
|
||||||
<span className="truncate">{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')}</span>
|
<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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Search, X, Check } from 'lucide-react';
|
import { Search, X, Check } from 'lucide-react';
|
||||||
import { User } from '../lib/types';
|
import { User } from '../lib/types';
|
||||||
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
|
|
||||||
interface UserAutocompleteProps {
|
interface UserAutocompleteProps {
|
||||||
users: User[];
|
users: User[];
|
||||||
@@ -101,9 +102,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
{selectedUser ? (
|
{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="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="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">
|
<UserAvatar user={selectedUser} size="xs" />
|
||||||
{(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span className="font-semibold text-main truncate">
|
<span className="font-semibold text-main truncate">
|
||||||
{selectedUser.displayName || selectedUser.email}
|
{selectedUser.displayName || selectedUser.email}
|
||||||
</span>
|
</span>
|
||||||
@@ -170,9 +169,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<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">
|
<UserAvatar user={u} size="xs" />
|
||||||
{(u.displayName || u.email || '?')[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<span className="font-semibold text-main">
|
<span className="font-semibold text-main">
|
||||||
{u.displayName || u.email}
|
{u.displayName || u.email}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
UserRole,
|
UserRole,
|
||||||
} from '../../lib/types';
|
} from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
|
|
||||||
interface AdminSettingsViewProps {
|
interface AdminSettingsViewProps {
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
@@ -556,6 +557,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<div key={u.id} className="p-3 space-y-2">
|
<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 className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<UserAvatar user={u} size="sm" className="border border-border/80" />
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-bold text-main">{u.displayName}</span>
|
<span className="font-bold text-main">{u.displayName}</span>
|
||||||
@@ -567,6 +570,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-muted text-[11px]">{u.email}</p>
|
<p className="text-muted text-[11px]">{u.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{/* Role selector */}
|
{/* Role selector */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
Bell,
|
Bell,
|
||||||
@@ -15,10 +15,13 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Palette,
|
Palette,
|
||||||
|
Upload,
|
||||||
|
Camera,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { APIKey, User } from '../../lib/types';
|
import { APIKey, User } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
|
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
|
||||||
|
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||||
|
|
||||||
interface UserSettingsViewProps {
|
interface UserSettingsViewProps {
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
@@ -42,9 +45,12 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
// --- Profile State ---
|
// --- Profile State ---
|
||||||
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
||||||
const [email, setEmail] = useState(currentUser.email || '');
|
const [email, setEmail] = useState(currentUser.email || '');
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||||
const [savingProfile, setSavingProfile] = useState(false);
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
|
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// --- Multi-Channel Alarms State ---
|
// --- Multi-Channel Alarms State ---
|
||||||
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
||||||
@@ -69,6 +75,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayName(currentUser.displayName || '');
|
setDisplayName(currentUser.displayName || '');
|
||||||
setEmail(currentUser.email || '');
|
setEmail(currentUser.email || '');
|
||||||
|
setAvatarUrl(currentUser.avatarUrl || '');
|
||||||
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
||||||
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
||||||
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
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 ---
|
// --- Handlers: Profile ---
|
||||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -104,6 +139,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const patch: any = {
|
const patch: any = {
|
||||||
displayName: displayName.trim(),
|
displayName: displayName.trim(),
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
|
avatarUrl: avatarUrl.trim(),
|
||||||
};
|
};
|
||||||
if (newPassword.trim()) {
|
if (newPassword.trim()) {
|
||||||
patch.password = newPassword.trim();
|
patch.password = newPassword.trim();
|
||||||
@@ -358,7 +394,88 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-muted font-medium mb-1">Anzeigename</label>
|
<label className="block text-muted font-medium mb-1">Anzeigename</label>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Team, User } from '../../lib/types';
|
import { Team, User } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { UserAutocomplete } from '../UserAutocomplete';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
|
|
||||||
interface GroupsViewProps {
|
interface GroupsViewProps {
|
||||||
currentUser: User;
|
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"
|
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="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">
|
<UserAvatar user={{ displayName: m.displayName, email: m.email, avatarUrl: m.avatarUrl }} size="xs" />
|
||||||
{(m.displayName || 'U').slice(0, 1).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-main font-medium truncate text-xs">
|
<p className="text-main font-medium truncate text-xs">
|
||||||
{m.displayName} {isSelf && <span className="text-faint">(Du)</span>}
|
{m.displayName} {isSelf && <span className="text-faint">(Du)</span>}
|
||||||
|
|||||||
@@ -1249,8 +1249,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
|
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
|
||||||
{otherMembers.slice(0, 2).map((m) => (
|
{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">
|
<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">
|
||||||
{getInitials(m.displayName || m.email)[0]}
|
{m.avatarUrl ? (
|
||||||
|
<img src={m.avatarUrl} alt="" className="w-full h-full object-cover rounded-full" />
|
||||||
|
) : (
|
||||||
|
getInitials(m.displayName || m.email)[0]
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1563,6 +1567,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
targetId: sh.targetId,
|
targetId: sh.targetId,
|
||||||
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
|
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
|
||||||
targetEmail: sh.email,
|
targetEmail: sh.email,
|
||||||
|
avatarUrl: sh.avatarUrl,
|
||||||
permission: 'editor' as const,
|
permission: 'editor' as const,
|
||||||
role: 'editor',
|
role: 'editor',
|
||||||
createdAt: sh.createdAt,
|
createdAt: sh.createdAt,
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export const api = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
alarmChannels?: string[];
|
alarmChannels?: string[];
|
||||||
alarmEmail?: string;
|
alarmEmail?: string;
|
||||||
discordWebhookUrl?: string;
|
discordWebhookUrl?: string;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
alarmChannels?: string[];
|
alarmChannels?: string[];
|
||||||
@@ -79,6 +80,7 @@ export interface TaskListShare {
|
|||||||
targetId: string;
|
targetId: string;
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +88,7 @@ export interface TaskListMember {
|
|||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
avatarUrl?: string;
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +135,7 @@ export interface TeamMember {
|
|||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
avatarUrl?: string;
|
||||||
role: 'admin' | 'member';
|
role: 'admin' | 'member';
|
||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
}
|
}
|
||||||
@@ -240,6 +244,7 @@ export interface SpaceMember {
|
|||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
avatarUrl?: string;
|
||||||
role: 'owner' | 'editor' | 'viewer';
|
role: 'owner' | 'editor' | 'viewer';
|
||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
}
|
}
|
||||||
@@ -250,6 +255,7 @@ export interface UnifiedShare {
|
|||||||
targetId: string;
|
targetId: string;
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
targetEmail?: string;
|
targetEmail?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
permission?: 'editor' | 'viewer';
|
permission?: 'editor' | 'viewer';
|
||||||
role?: string;
|
role?: string;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
|
|||||||
@@ -1505,6 +1505,7 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
|||||||
DisplayName *string `json:"displayName"`
|
DisplayName *string `json:"displayName"`
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
|
AvatarURL *string `json:"avatarUrl"`
|
||||||
AlarmChannels *[]string `json:"alarmChannels"`
|
AlarmChannels *[]string `json:"alarmChannels"`
|
||||||
AlarmEmail *string `json:"alarmEmail"`
|
AlarmEmail *string `json:"alarmEmail"`
|
||||||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||||||
@@ -1534,6 +1535,7 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
|||||||
req.DiscordWebhookURL,
|
req.DiscordWebhookURL,
|
||||||
req.CustomWebhookURL,
|
req.CustomWebhookURL,
|
||||||
req.NotifyBeforeMinutes,
|
req.NotifyBeforeMinutes,
|
||||||
|
req.AvatarURL,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ type User struct {
|
|||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
Role string `json:"role"` // admin | member
|
Role string `json:"role"` // admin | member
|
||||||
IsActive bool `json:"isActive"`
|
IsActive bool `json:"isActive"`
|
||||||
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
||||||
@@ -179,6 +180,7 @@ type TaskListShare struct {
|
|||||||
TargetID string `json:"targetId"`
|
TargetID string `json:"targetId"`
|
||||||
TargetName string `json:"targetName,omitempty"`
|
TargetName string `json:"targetName,omitempty"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +188,7 @@ type TaskListMember struct {
|
|||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
Role string `json:"role"` // owner | member
|
Role string `json:"role"` // owner | member
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +241,7 @@ type TeamMember struct {
|
|||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
Role string `json:"role"` // admin | member
|
Role string `json:"role"` // admin | member
|
||||||
JoinedAt string `json:"joinedAt"`
|
JoinedAt string `json:"joinedAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-19
@@ -81,6 +81,7 @@ func (s *Store) migrate() error {
|
|||||||
discord_webhook_url TEXT NOT NULL DEFAULT '',
|
discord_webhook_url TEXT NOT NULL DEFAULT '',
|
||||||
custom_webhook_url TEXT NOT NULL DEFAULT '',
|
custom_webhook_url TEXT NOT NULL DEFAULT '',
|
||||||
notify_before_minutes INTEGER NOT NULL DEFAULT 30,
|
notify_before_minutes INTEGER NOT NULL DEFAULT 30,
|
||||||
|
avatar_url TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -313,6 +314,7 @@ func (s *Store) migrate() error {
|
|||||||
"ALTER TABLE users ADD COLUMN discord_webhook_url TEXT NOT NULL DEFAULT '';",
|
"ALTER TABLE users ADD COLUMN discord_webhook_url TEXT NOT NULL DEFAULT '';",
|
||||||
"ALTER TABLE users ADD COLUMN custom_webhook_url TEXT NOT NULL DEFAULT '';",
|
"ALTER TABLE users ADD COLUMN custom_webhook_url TEXT NOT NULL DEFAULT '';",
|
||||||
"ALTER TABLE users ADD COLUMN notify_before_minutes INTEGER NOT NULL DEFAULT 30;",
|
"ALTER TABLE users ADD COLUMN notify_before_minutes INTEGER NOT NULL DEFAULT 30;",
|
||||||
|
"ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT '';",
|
||||||
|
|
||||||
"ALTER TABLE thoughts ADD COLUMN task_list_id TEXT NOT NULL DEFAULT '';",
|
"ALTER TABLE thoughts ADD COLUMN task_list_id TEXT NOT NULL DEFAULT '';",
|
||||||
"ALTER TABLE thoughts ADD COLUMN kanban_status TEXT NOT NULL DEFAULT 'todo';",
|
"ALTER TABLE thoughts ADD COLUMN kanban_status TEXT NOT NULL DEFAULT 'todo';",
|
||||||
@@ -486,7 +488,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
|||||||
|
|
||||||
// --- USERS ---
|
// --- USERS ---
|
||||||
|
|
||||||
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at`
|
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at`
|
||||||
|
|
||||||
func parseAlarmChannels(raw string) []string {
|
func parseAlarmChannels(raw string) []string {
|
||||||
parts := strings.Split(raw, ",")
|
parts := strings.Split(raw, ",")
|
||||||
@@ -509,7 +511,7 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
|
|||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
|
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
|
||||||
&u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail,
|
&u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail,
|
||||||
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.CreatedAt,
|
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.CreatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -559,11 +561,12 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam
|
|||||||
DiscordWebhookURL: "",
|
DiscordWebhookURL: "",
|
||||||
CustomWebhookURL: "",
|
CustomWebhookURL: "",
|
||||||
NotifyBeforeMinutes: 30,
|
NotifyBeforeMinutes: 30,
|
||||||
|
AvatarURL: "",
|
||||||
CreatedAt: domain.NowISO(),
|
CreatedAt: domain.NowISO(),
|
||||||
}
|
}
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at)
|
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, '', ?)`,
|
||||||
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -714,7 +717,7 @@ func (s *Store) UpdateUser(
|
|||||||
discordWebhookURL *string,
|
discordWebhookURL *string,
|
||||||
notifyBeforeMinutes *int,
|
notifyBeforeMinutes *int,
|
||||||
) (*domain.User, error) {
|
) (*domain.User, error) {
|
||||||
return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes)
|
return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateUserFull(
|
func (s *Store) UpdateUserFull(
|
||||||
@@ -724,6 +727,7 @@ func (s *Store) UpdateUserFull(
|
|||||||
alarmChannels *[]string,
|
alarmChannels *[]string,
|
||||||
alarmEmail, discordWebhookURL, customWebhookURL *string,
|
alarmEmail, discordWebhookURL, customWebhookURL *string,
|
||||||
notifyBeforeMinutes *int,
|
notifyBeforeMinutes *int,
|
||||||
|
avatarURL *string,
|
||||||
) (*domain.User, error) {
|
) (*domain.User, error) {
|
||||||
u, err := s.GetUserByID(userID)
|
u, err := s.GetUserByID(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -756,14 +760,17 @@ func (s *Store) UpdateUserFull(
|
|||||||
if notifyBeforeMinutes != nil {
|
if notifyBeforeMinutes != nil {
|
||||||
u.NotifyBeforeMinutes = *notifyBeforeMinutes
|
u.NotifyBeforeMinutes = *notifyBeforeMinutes
|
||||||
}
|
}
|
||||||
|
if avatarURL != nil {
|
||||||
|
u.AvatarURL = strings.TrimSpace(*avatarURL)
|
||||||
|
}
|
||||||
activeInt := 0
|
activeInt := 0
|
||||||
if u.IsActive {
|
if u.IsActive {
|
||||||
activeInt = 1
|
activeInt = 1
|
||||||
}
|
}
|
||||||
channelsStr := strings.Join(u.AlarmChannels, ",")
|
channelsStr := strings.Join(u.AlarmChannels, ",")
|
||||||
_, err = s.db.Exec(
|
_, err = s.db.Exec(
|
||||||
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ? WHERE id = ?`,
|
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`,
|
||||||
u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, userID,
|
u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -2196,7 +2203,7 @@ func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error {
|
|||||||
|
|
||||||
func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
|
func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT id, email, display_name, role, is_active, created_at FROM users WHERE id != ? AND is_active = 1 ORDER BY display_name ASC`,
|
`SELECT id, email, display_name, role, is_active, avatar_url, created_at FROM users WHERE id != ? AND is_active = 1 ORDER BY display_name ASC`,
|
||||||
currentUserID,
|
currentUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2208,7 +2215,7 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u domain.User
|
var u domain.User
|
||||||
var activeInt int
|
var activeInt int
|
||||||
if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &activeInt, &u.CreatedAt); err == nil {
|
if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &activeInt, &u.AvatarURL, &u.CreatedAt); err == nil {
|
||||||
u.IsActive = activeInt == 1
|
u.IsActive = activeInt == 1
|
||||||
users = append(users, u)
|
users = append(users, u)
|
||||||
}
|
}
|
||||||
@@ -2936,7 +2943,7 @@ func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, er
|
|||||||
if shares[i].TargetType == "team" {
|
if shares[i].TargetType == "team" {
|
||||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName)
|
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName)
|
||||||
} else {
|
} else {
|
||||||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email)
|
_ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email, &shares[i].AvatarURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return shares, nil
|
return shares, nil
|
||||||
@@ -2946,25 +2953,26 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb
|
|||||||
memberMap := make(map[string]domain.TaskListMember)
|
memberMap := make(map[string]domain.TaskListMember)
|
||||||
|
|
||||||
// 1. Owner
|
// 1. Owner
|
||||||
var ownerName, ownerEmail string
|
var ownerName, ownerEmail, ownerAvatar string
|
||||||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail)
|
_ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail, &ownerAvatar)
|
||||||
memberMap[tl.UserID] = domain.TaskListMember{
|
memberMap[tl.UserID] = domain.TaskListMember{
|
||||||
UserID: tl.UserID,
|
UserID: tl.UserID,
|
||||||
DisplayName: ownerName,
|
DisplayName: ownerName,
|
||||||
Email: ownerEmail,
|
Email: ownerEmail,
|
||||||
|
AvatarURL: ownerAvatar,
|
||||||
Role: "owner",
|
Role: "owner",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Team members if tl.TeamID != ""
|
// 2. Team members if tl.TeamID != ""
|
||||||
if tl.TeamID != "" {
|
if tl.TeamID != "" {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT u.id, u.display_name, u.email FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`,
|
`SELECT u.id, u.display_name, u.email, u.avatar_url FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`,
|
||||||
tl.TeamID,
|
tl.TeamID,
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m domain.TaskListMember
|
var m domain.TaskListMember
|
||||||
if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil {
|
if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil {
|
||||||
m.Role = "member"
|
m.Role = "member"
|
||||||
memberMap[m.UserID] = m
|
memberMap[m.UserID] = m
|
||||||
}
|
}
|
||||||
@@ -2977,13 +2985,13 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb
|
|||||||
for _, sh := range tl.Shares {
|
for _, sh := range tl.Shares {
|
||||||
if sh.TargetType == "team" {
|
if sh.TargetType == "team" {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT u.id, u.display_name, u.email FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`,
|
`SELECT u.id, u.display_name, u.email, u.avatar_url FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`,
|
||||||
sh.TargetID,
|
sh.TargetID,
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m domain.TaskListMember
|
var m domain.TaskListMember
|
||||||
if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil {
|
if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil {
|
||||||
m.Role = "member"
|
m.Role = "member"
|
||||||
memberMap[m.UserID] = m
|
memberMap[m.UserID] = m
|
||||||
}
|
}
|
||||||
@@ -2992,7 +3000,7 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb
|
|||||||
}
|
}
|
||||||
} else if sh.TargetType == "user" {
|
} else if sh.TargetType == "user" {
|
||||||
var m domain.TaskListMember
|
var m domain.TaskListMember
|
||||||
if err := s.db.QueryRow(`SELECT id, display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil {
|
if err := s.db.QueryRow(`SELECT id, display_name, email, avatar_url FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil {
|
||||||
m.Role = "member"
|
m.Role = "member"
|
||||||
memberMap[m.UserID] = m
|
memberMap[m.UserID] = m
|
||||||
}
|
}
|
||||||
@@ -3466,7 +3474,7 @@ func (s *Store) RemoveTeamMember(teamID, userID string) error {
|
|||||||
|
|
||||||
func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
|
func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT tm.team_id, tm.user_id, u.display_name, u.email, tm.role, tm.joined_at
|
`SELECT tm.team_id, tm.user_id, u.display_name, u.email, u.avatar_url, tm.role, tm.joined_at
|
||||||
FROM team_members tm
|
FROM team_members tm
|
||||||
JOIN users u ON u.id = tm.user_id
|
JOIN users u ON u.id = tm.user_id
|
||||||
WHERE tm.team_id = ?
|
WHERE tm.team_id = ?
|
||||||
@@ -3481,7 +3489,7 @@ func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
|
|||||||
members := make([]domain.TeamMember, 0)
|
members := make([]domain.TeamMember, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m domain.TeamMember
|
var m domain.TeamMember
|
||||||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil {
|
if err := rows.Scan(&m.TeamID, &m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL, &m.Role, &m.JoinedAt); err == nil {
|
||||||
members = append(members, m)
|
members = append(members, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user