Dev #5

Merged
sandrodach merged 19 commits from dev into main 2026-10-26 13:35:00 +01:00
13 changed files with 353 additions and 51 deletions
Showing only changes of commit 444442fdf0 - Show all commits

No files matched your search

+9 -11
View File
@@ -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"
>
<div className="w-8 h-8 rounded-full bg-accent text-on-accent text-xs font-bold shadow-sm flex items-center justify-center">
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
</div>
<UserAvatar user={user} size="sm" className="shadow-sm" />
</button>
</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"
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">
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
</div>
<UserAvatar user={user} size="sm" />
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
{user.displayName}
</span>
@@ -644,13 +641,16 @@ export const App: React.FC = () => {
{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">
{/* 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="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}
</span>
</div>
</div>
{/* Theme Quick Switcher */}
<div className="p-2.5 space-y-1.5">
@@ -1070,9 +1070,7 @@ export const App: React.FC = () => {
{/* User Info Header */}
<div className="flex items-center justify-between pb-2">
<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">
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
</div>
<UserAvatar user={user} size="lg" className="border border-border/80 shadow-sm" />
<div>
<p className="font-bold text-sm text-main">{user.displayName}</p>
<p className="text-xs text-muted">{user.email}</p>
+12 -8
View File
@@ -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" />}
{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>
+157
View File
@@ -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);
});
}
+3 -6
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { Search, X, Check } from 'lucide-react';
import { User } from '../lib/types';
import { UserAvatar } from '../components/UserAvatar';
interface UserAutocompleteProps {
users: User[];
@@ -101,9 +102,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
{selectedUser ? (
<div className="h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 min-w-0">
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
{(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()}
</div>
<UserAvatar user={selectedUser} size="xs" />
<span className="font-semibold text-main truncate">
{selectedUser.displayName || selectedUser.email}
</span>
@@ -170,9 +169,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
}`}
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
{(u.displayName || u.email || '?')[0].toUpperCase()}
</div>
<UserAvatar user={u} size="xs" />
<div className="truncate">
<span className="font-semibold text-main">
{u.displayName || u.email}
@@ -21,6 +21,7 @@ import {
UserRole,
} from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { UserAvatar } from '../../components/UserAvatar';
interface AdminSettingsViewProps {
currentUser: User;
@@ -556,6 +557,8 @@ 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 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>
@@ -567,6 +570,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</div>
<p className="text-muted text-[11px]">{u.email}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Role selector */}
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import {
User as UserIcon,
Bell,
@@ -15,10 +15,13 @@ import {
Send,
AlertCircle,
Palette,
Upload,
Camera,
} from 'lucide-react';
import { APIKey, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
interface UserSettingsViewProps {
currentUser: User;
@@ -42,9 +45,12 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
// --- Profile State ---
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
const [email, setEmail] = useState(currentUser.email || '');
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
const [newPassword, setNewPassword] = useState('');
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// --- Multi-Channel Alarms State ---
const [alarmChannels, setAlarmChannels] = useState<string[]>(
@@ -69,6 +75,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
useEffect(() => {
setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || '');
setAvatarUrl(currentUser.avatarUrl || '');
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
@@ -95,6 +102,34 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
);
};
// --- Handlers: Avatar ---
const handleAvatarFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsProcessingAvatar(true);
setProfileMsg(null);
try {
const dataUrl = await resizeImageToDataUrl(file, 256);
setAvatarUrl(dataUrl);
triggerHaptic(10);
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Laden des Bildes' });
} finally {
setIsProcessingAvatar(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleSelectPresetAvatar = (preset: string) => {
setAvatarUrl(preset);
triggerHaptic(10);
};
const handleRemoveAvatar = () => {
setAvatarUrl('');
triggerHaptic(10);
};
// --- Handlers: Profile ---
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
@@ -104,6 +139,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const patch: any = {
displayName: displayName.trim(),
email: email.trim(),
avatarUrl: avatarUrl.trim(),
};
if (newPassword.trim()) {
patch.password = newPassword.trim();
@@ -358,7 +394,88 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
)}
<form onSubmit={handleSaveProfile} className="space-y-4">
<form onSubmit={handleSaveProfile} className="space-y-5">
{/* Avatar Management */}
<div className="p-3.5 bg-panel-elevated/40 border border-border rounded-box space-y-3">
<label className="block text-muted font-medium">Profilbild</label>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4">
<div className="relative group shrink-0">
<UserAvatar
user={{ displayName, email, avatarUrl }}
size="xl"
className="border-2 border-border/80 shadow-md ring-2 ring-primary/20"
/>
{isProcessingAvatar && (
<div className="absolute inset-0 bg-base-100/70 rounded-full flex items-center justify-center text-[10px] font-medium text-main">
Lädt…
</div>
)}
</div>
<div className="flex-1 space-y-2.5 text-center sm:text-left">
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarFileSelected}
className="hidden"
id="avatar-upload-input"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isProcessingAvatar}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
<span>Bild hochladen</span>
</button>
{avatarUrl && (
<button
type="button"
onClick={handleRemoveAvatar}
className="btn-ghost h-8 px-2.5 text-xs text-status-err hover:bg-status-err/10 flex items-center gap-1"
title="Profilbild entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Entfernen</span>
</button>
)}
</div>
{/* Emoji Presets */}
<div className="space-y-1">
<span className="text-[11px] text-muted">Oder schnelles Emoji wählen:</span>
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-1.5 pt-0.5">
{['🦊', '🐱', '🐶', '🐼', '🦁', '🦉', '🚀', '☕', '⚡', '🎨', '🌟', '🎯', '💡'].map(
(preset) => (
<button
key={preset}
type="button"
onClick={() => handleSelectPresetAvatar(preset)}
className={`w-7 h-7 rounded-full text-sm flex items-center justify-center transition-transform hover:scale-110 active:scale-95 ${
avatarUrl === preset
? 'bg-primary/20 border-2 border-primary scale-105'
: 'bg-panel hover:bg-panel-hover border border-border'
}`}
title={`Emoji ${preset}`}
>
{preset}
</button>
)
)}
</div>
</div>
<p className="text-[10px] text-faint">
Wird überall kreisförmig dargestellt (z.&nbsp;B. Menüleiste oben, Freigabelisten, Aufgaben).
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-muted font-medium mb-1">Anzeigename</label>
+2 -3
View File
@@ -15,6 +15,7 @@ import {
import { Team, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { UserAutocomplete } from '../UserAutocomplete';
import { UserAvatar } from '../../components/UserAvatar';
interface GroupsViewProps {
currentUser: User;
@@ -487,9 +488,7 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
className="p-2.5 bg-panel border border-border rounded-btn flex items-center justify-between gap-2 text-xs"
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-full bg-accent/20 text-accent text-[10px] font-bold flex items-center justify-center flex-shrink-0">
{(m.displayName || 'U').slice(0, 1).toUpperCase()}
</div>
<UserAvatar user={{ displayName: m.displayName, email: m.email, avatarUrl: m.avatarUrl }} size="xs" />
<div className="min-w-0">
<p className="text-main font-medium truncate text-xs">
{m.displayName} {isSelf && <span className="text-faint">(Du)</span>}
+7 -2
View File
@@ -1249,8 +1249,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
return (
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
{otherMembers.slice(0, 2).map((m) => (
<span key={m.userId} className="w-3.5 h-3.5 rounded-pill bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center">
{getInitials(m.displayName || m.email)[0]}
<span key={m.userId} className="w-3.5 h-3.5 rounded-full overflow-hidden bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center shrink-0">
{m.avatarUrl ? (
<img src={m.avatarUrl} alt="" className="w-full h-full object-cover rounded-full" />
) : (
getInitials(m.displayName || m.email)[0]
)}
</span>
))}
</div>
@@ -1563,6 +1567,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
targetId: sh.targetId,
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
targetEmail: sh.email,
avatarUrl: sh.avatarUrl,
permission: 'editor' as const,
role: 'editor',
createdAt: sh.createdAt,
+1
View File
@@ -163,6 +163,7 @@ export const api = {
displayName?: string;
email?: string;
password?: string;
avatarUrl?: string;
alarmChannels?: string[];
alarmEmail?: string;
discordWebhookUrl?: string;
+6
View File
@@ -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;
+2
View File
@@ -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())
+4
View File
@@ -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"`
}
+27 -19
View File
@@ -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)
}
}