From 444442fdf0747f91826cb1cea1582a7e2baf0025 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 21:37:06 +0100 Subject: [PATCH] feat: integrate user profile pictures with circular avatar display and upload in settings --- frontend/src/App.tsx | 28 ++-- frontend/src/components/UnifiedShareModal.tsx | 22 ++- frontend/src/components/UserAvatar.tsx | 157 ++++++++++++++++++ frontend/src/features/UserAutocomplete.tsx | 9 +- .../features/settings/AdminSettingsView.tsx | 22 ++- .../features/settings/UserSettingsView.tsx | 121 +++++++++++++- frontend/src/features/teams/GroupsView.tsx | 5 +- frontend/src/features/triage/TasksView.tsx | 9 +- frontend/src/lib/api.ts | 1 + frontend/src/lib/types.ts | 6 + internal/api/server.go | 2 + internal/domain/models.go | 4 + internal/storage/sqlite.go | 46 ++--- 13 files changed, 367 insertions(+), 65 deletions(-) create mode 100644 frontend/src/components/UserAvatar.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8192b32..1ffc867 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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" > -
- {user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'} -
+ @@ -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" > -
- {user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'} -
+ {user.displayName} @@ -644,12 +641,15 @@ export const App: React.FC = () => { {showProfileMenu && (
{/* User Info Header */} -
-

{user.displayName}

-

{user.email}

- - {roleLabel} - +
+ +
+

{user.displayName}

+

{user.email}

+ + {roleLabel} + +
{/* Theme Quick Switcher */} @@ -1070,9 +1070,7 @@ export const App: React.FC = () => { {/* User Info Header */}
-
- {user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'} -
+

{user.displayName}

{user.email}

diff --git a/frontend/src/components/UnifiedShareModal.tsx b/frontend/src/components/UnifiedShareModal.tsx index d4e290e..77d8dba 100644 --- a/frontend/src/components/UnifiedShareModal.tsx +++ b/frontend/src/components/UnifiedShareModal.tsx @@ -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 = ({ 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" >
-
- {isTeam ? : } -
+ {isTeam ? ( +
+ +
+ ) : ( + c.id === sh.targetId)?.avatarUrl} + name={sh.targetName || sh.targetEmail} + size="sm" + className="border border-border/80" + /> + )}
{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')} diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx new file mode 100644 index 0000000..b0110be --- /dev/null +++ b/frontend/src/components/UserAvatar.tsx @@ -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 = ({ + 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 ( +
+ {hasImage ? ( + {alt setImageError(true)} + className="w-full h-full object-cover rounded-full" + /> + ) : isEmojiAvatar ? ( +
+ {effectiveAvatarUrl} +
+ ) : ( +
+ {initials} +
+ )} +
+ ); +}; + +/** + * Resizes and center-crops an uploaded image file to a compact base64 Data URL. + */ +export async function resizeImageToDataUrl(file: File, maxSize = 256): Promise { + 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); + }); +} diff --git a/frontend/src/features/UserAutocomplete.tsx b/frontend/src/features/UserAutocomplete.tsx index b818b01..c3bb558 100644 --- a/frontend/src/features/UserAutocomplete.tsx +++ b/frontend/src/features/UserAutocomplete.tsx @@ -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 = ({ {selectedUser ? (
-
- {(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()} -
+ {selectedUser.displayName || selectedUser.email} @@ -170,9 +169,7 @@ export const UserAutocomplete: React.FC = ({ }`} >
-
- {(u.displayName || u.email || '?')[0].toUpperCase()} -
+
{u.displayName || u.email} diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index d76aee3..55c0936 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -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 = ({ {users.map((u) => (
-
-
- {u.displayName} - {!u.isActive && ( - - Gesperrt - - )} +
+ +
+
+ {u.displayName} + {!u.isActive && ( + + Gesperrt + + )} +
+

{u.email}

-

{u.email}

diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 532b4d0..a90a97f 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -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 = ({ // --- 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(null); // --- Multi-Channel Alarms State --- const [alarmChannels, setAlarmChannels] = useState( @@ -69,6 +75,7 @@ export const UserSettingsView: React.FC = ({ 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 = ({ ); }; + // --- Handlers: Avatar --- + const handleAvatarFileSelected = async (e: React.ChangeEvent) => { + 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 = ({ 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 = ({
)} -
+ + {/* Avatar Management */} +
+ +
+
+ + {isProcessingAvatar && ( +
+ Lädt… +
+ )} +
+ +
+
+ + + + {avatarUrl && ( + + )} +
+ + {/* Emoji Presets */} +
+ Oder schnelles Emoji wählen: +
+ {['🦊', '🐱', '🐶', '🐼', '🦁', '🦉', '🚀', '☕', '⚡', '🎨', '🌟', '🎯', '💡'].map( + (preset) => ( + + ) + )} +
+
+ +

+ Wird überall kreisförmig dargestellt (z. B. Menüleiste oben, Freigabelisten, Aufgaben). +

+
+
+
+
diff --git a/frontend/src/features/teams/GroupsView.tsx b/frontend/src/features/teams/GroupsView.tsx index 7238ad2..205e8dc 100644 --- a/frontend/src/features/teams/GroupsView.tsx +++ b/frontend/src/features/teams/GroupsView.tsx @@ -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 = ({ currentUser, onBack }) = className="p-2.5 bg-panel border border-border rounded-btn flex items-center justify-between gap-2 text-xs" >
-
- {(m.displayName || 'U').slice(0, 1).toUpperCase()} -
+

{m.displayName} {isSelf && (Du)} diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 4d319be..f198564 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -1249,8 +1249,12 @@ export const TasksView: React.FC = ({ return (

{otherMembers.slice(0, 2).map((m) => ( - - {getInitials(m.displayName || m.email)[0]} + + {m.avatarUrl ? ( + + ) : ( + getInitials(m.displayName || m.email)[0] + )} ))}
@@ -1563,6 +1567,7 @@ export const TasksView: React.FC = ({ 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, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 492e6b8..c11c321 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -163,6 +163,7 @@ export const api = { displayName?: string; email?: string; password?: string; + avatarUrl?: string; alarmChannels?: string[]; alarmEmail?: string; discordWebhookUrl?: string; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ef62c94..4d2ff16 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; diff --git a/internal/api/server.go b/internal/api/server.go index ecebb35..5ad6a2f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1505,6 +1505,7 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request DisplayName *string `json:"displayName"` Email *string `json:"email"` Password *string `json:"password"` + AvatarURL *string `json:"avatarUrl"` AlarmChannels *[]string `json:"alarmChannels"` AlarmEmail *string `json:"alarmEmail"` DiscordWebhookURL *string `json:"discordWebhookUrl"` @@ -1534,6 +1535,7 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request req.DiscordWebhookURL, req.CustomWebhookURL, req.NotifyBeforeMinutes, + req.AvatarURL, ) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) diff --git a/internal/domain/models.go b/internal/domain/models.go index 4934766..fcf16f6 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -98,6 +98,7 @@ type User struct { Username string `json:"username,omitempty"` PasswordHash string `json:"-"` DisplayName string `json:"displayName"` + AvatarURL string `json:"avatarUrl,omitempty"` Role string `json:"role"` // admin | member IsActive bool `json:"isActive"` AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook @@ -179,6 +180,7 @@ type TaskListShare struct { TargetID string `json:"targetId"` TargetName string `json:"targetName,omitempty"` Email string `json:"email,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` CreatedAt string `json:"createdAt"` } @@ -186,6 +188,7 @@ type TaskListMember struct { UserID string `json:"userId"` DisplayName string `json:"displayName"` Email string `json:"email"` + AvatarURL string `json:"avatarUrl,omitempty"` Role string `json:"role"` // owner | member } @@ -238,6 +241,7 @@ type TeamMember struct { UserID string `json:"userId"` DisplayName string `json:"displayName"` Email string `json:"email"` + AvatarURL string `json:"avatarUrl,omitempty"` Role string `json:"role"` // admin | member JoinedAt string `json:"joinedAt"` } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 819cf75..a748531 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -81,6 +81,7 @@ func (s *Store) migrate() error { discord_webhook_url TEXT NOT NULL DEFAULT '', custom_webhook_url TEXT NOT NULL DEFAULT '', notify_before_minutes INTEGER NOT NULL DEFAULT 30, + avatar_url TEXT NOT NULL DEFAULT '', 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 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 avatar_url 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';", @@ -486,7 +488,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) { // --- 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 { parts := strings.Split(raw, ",") @@ -509,7 +511,7 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) { err := row.Scan( &u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName, &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 { return nil, err @@ -559,11 +561,12 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam DiscordWebhookURL: "", CustomWebhookURL: "", NotifyBeforeMinutes: 30, + AvatarURL: "", CreatedAt: domain.NowISO(), } _, 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) - VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, ?)`, + `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, '', ?)`, u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt, ) if err != nil { @@ -714,7 +717,7 @@ func (s *Store) UpdateUser( discordWebhookURL *string, notifyBeforeMinutes *int, ) (*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( @@ -724,6 +727,7 @@ func (s *Store) UpdateUserFull( alarmChannels *[]string, alarmEmail, discordWebhookURL, customWebhookURL *string, notifyBeforeMinutes *int, + avatarURL *string, ) (*domain.User, error) { u, err := s.GetUserByID(userID) if err != nil { @@ -756,14 +760,17 @@ func (s *Store) UpdateUserFull( if notifyBeforeMinutes != nil { u.NotifyBeforeMinutes = *notifyBeforeMinutes } + if avatarURL != nil { + u.AvatarURL = strings.TrimSpace(*avatarURL) + } activeInt := 0 if u.IsActive { activeInt = 1 } channelsStr := strings.Join(u.AlarmChannels, ",") _, 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 = ?`, - u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, userID, + `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, u.AvatarURL, userID, ) if err != nil { 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) { 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, ) if err != nil { @@ -2208,7 +2215,7 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) { for rows.Next() { var u domain.User 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 users = append(users, u) } @@ -2936,7 +2943,7 @@ func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, er if shares[i].TargetType == "team" { _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName) } 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 @@ -2946,25 +2953,26 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb memberMap := make(map[string]domain.TaskListMember) // 1. Owner - var ownerName, ownerEmail string - _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail) + var ownerName, ownerEmail, ownerAvatar string + _ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail, &ownerAvatar) memberMap[tl.UserID] = domain.TaskListMember{ UserID: tl.UserID, DisplayName: ownerName, Email: ownerEmail, + AvatarURL: ownerAvatar, Role: "owner", } // 2. Team members if tl.TeamID != "" if tl.TeamID != "" { 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, ) if err == nil { for rows.Next() { 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" memberMap[m.UserID] = m } @@ -2977,13 +2985,13 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb for _, sh := range tl.Shares { if sh.TargetType == "team" { 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, ) if err == nil { for rows.Next() { 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" memberMap[m.UserID] = m } @@ -2992,7 +3000,7 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb } } else if sh.TargetType == "user" { 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" 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) { 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 JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ? @@ -3481,7 +3489,7 @@ func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) { members := make([]domain.TeamMember, 0) for rows.Next() { 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) } }