diff --git a/backend/models.py b/backend/models.py index 0fbfb32..79c9d3f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -173,6 +173,8 @@ class User(SQLModel, table=True): role: str = Field(default="user") # user, moderator, admin bio: Optional[str] = Field(default=None) avatar: Optional[str] = Field(default=None) + avatar_bg_color: Optional[str] = Field(default="#3B82F6", description="Hex color for avatar background") + avatar_text: Optional[str] = Field(default=None, description="1-3 character text overlay on avatar") # Gamification xp: int = Field(default=0, description="Experience points") diff --git a/backend/routers/users.py b/backend/routers/users.py index fc35bb7..1486e19 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -14,6 +14,24 @@ class UserProfileUpdate(BaseModel): avatar: Optional[str] = None username: Optional[str] = None display_name: Optional[str] = None + avatar_bg_color: Optional[str] = None + avatar_text: Optional[str] = None + +# Preset avatar colors +AVATAR_COLORS = [ + "#3B82F6", # Blue + "#EF4444", # Red + "#10B981", # Green + "#F59E0B", # Amber + "#8B5CF6", # Purple + "#EC4899", # Pink + "#6366F1", # Indigo + "#14B8A6", # Teal +] + +class AvatarUpdate(BaseModel): + bg_color: Optional[str] = None + text: Optional[str] = None # 1-3 alphanumeric chars @router.get("/{user_id}", response_model=UserRead) def get_user_public(user_id: int, session: Session = Depends(get_session)): @@ -34,6 +52,15 @@ def update_my_profile( current_user.bio = update.bio if update.avatar is not None: current_user.avatar = update.avatar + if update.avatar_bg_color is not None: + # Validate it's a valid hex color or preset + if update.avatar_bg_color in AVATAR_COLORS or (update.avatar_bg_color.startswith('#') and len(update.avatar_bg_color) == 7): + current_user.avatar_bg_color = update.avatar_bg_color + if update.avatar_text is not None: + # Validate 1-3 alphanumeric characters + import re + if len(update.avatar_text) <= 3 and re.match(r'^[A-Za-z0-9]*$', update.avatar_text): + current_user.avatar_text = update.avatar_text if update.avatar_text else None if update.username or update.display_name: # Find or create primary profile @@ -74,6 +101,42 @@ def update_my_profile( session.refresh(current_user) return current_user +@router.patch("/me/avatar") +def update_avatar( + update: AvatarUpdate, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Update avatar customization""" + import re + + if update.bg_color is not None: + if update.bg_color in AVATAR_COLORS or (update.bg_color.startswith('#') and len(update.bg_color) == 7): + current_user.avatar_bg_color = update.bg_color + else: + raise HTTPException(status_code=400, detail="Invalid color. Use a preset color or valid hex.") + + if update.text is not None: + if len(update.text) > 3: + raise HTTPException(status_code=400, detail="Avatar text must be 3 characters or less") + if update.text and not re.match(r'^[A-Za-z0-9]*$', update.text): + raise HTTPException(status_code=400, detail="Avatar text must be alphanumeric") + current_user.avatar_text = update.text.upper() if update.text else None + + session.add(current_user) + session.commit() + session.refresh(current_user) + + return { + "avatar_bg_color": current_user.avatar_bg_color, + "avatar_text": current_user.avatar_text + } + +@router.get("/avatar/colors") +def get_avatar_colors(): + """Get available avatar preset colors""" + return {"colors": AVATAR_COLORS} + @router.patch("/me/preferences", response_model=UserPreferencesUpdate) def update_preferences( prefs: UserPreferencesUpdate, diff --git a/backend/schemas.py b/backend/schemas.py index 2946e92..325ab20 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -12,6 +12,8 @@ class UserRead(SQLModel): email: str is_active: bool is_superuser: bool + avatar_bg_color: Optional[str] = "#3B82F6" + avatar_text: Optional[str] = None class Token(SQLModel): access_token: str diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 02d1010..823eec8 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -14,7 +14,7 @@ import { UserGroupsList } from "@/components/profile/user-groups-list" import { ChaseSongsList } from "@/components/profile/chase-songs-list" import { AttendanceSummary } from "@/components/profile/attendance-summary" import { LevelProgressCard } from "@/components/gamification/level-progress" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { UserAvatar } from "@/components/ui/user-avatar" import { motion } from "framer-motion" // Types @@ -23,6 +23,8 @@ interface UserProfile { email: string username: string avatar: string | null + avatar_bg_color: string | null + avatar_text: string | null bio: string | null created_at: string } @@ -125,10 +127,13 @@ export default function ProfilePage() {
- - - - +
diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index 55b74bc..ecb68a2 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button" import { usePreferences } from "@/contexts/preferences-context" import { useAuth } from "@/contexts/auth-context" import { getApiUrl } from "@/lib/api-config" +import { AvatarSettings } from "@/components/profile/avatar-settings" export default function SettingsPage() { const { preferences, updatePreferences, loading } = usePreferences() @@ -80,6 +81,13 @@ export default function SettingsPage() { + {/* Avatar Section */} + + {/* Preferences Section */} diff --git a/frontend/components/profile/avatar-settings.tsx b/frontend/components/profile/avatar-settings.tsx new file mode 100644 index 0000000..d9ef8ab --- /dev/null +++ b/frontend/components/profile/avatar-settings.tsx @@ -0,0 +1,145 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { UserAvatar } from "@/components/ui/user-avatar" +import { getApiUrl } from "@/lib/api-config" +import { Check } from "lucide-react" + +const PRESET_COLORS = [ + { value: "#3B82F6", name: "Blue" }, + { value: "#EF4444", name: "Red" }, + { value: "#10B981", name: "Green" }, + { value: "#F59E0B", name: "Amber" }, + { value: "#8B5CF6", name: "Purple" }, + { value: "#EC4899", name: "Pink" }, + { value: "#6366F1", name: "Indigo" }, + { value: "#14B8A6", name: "Teal" }, +] + +interface AvatarSettingsProps { + currentBgColor?: string + currentText?: string + username?: string + onSave?: (bgColor: string, text: string) => void +} + +export function AvatarSettings({ + currentBgColor = "#3B82F6", + currentText = "", + username = "", + onSave +}: AvatarSettingsProps) { + const [bgColor, setBgColor] = useState(currentBgColor) + const [text, setText] = useState(currentText) + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") + + const handleTextChange = (value: string) => { + // Only allow alphanumeric, max 3 chars + const cleaned = value.replace(/[^A-Za-z0-9]/g, '').slice(0, 3).toUpperCase() + setText(cleaned) + setError("") + } + + const handleSave = async () => { + setSaving(true) + setError("") + + try { + const token = localStorage.getItem("token") + const res = await fetch(`${getApiUrl()}/users/me/avatar`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + bg_color: bgColor, + text: text || null, + }), + }) + + if (!res.ok) { + const data = await res.json() + throw new Error(data.detail || "Failed to save") + } + + onSave?.(bgColor, text) + } catch (e: any) { + setError(e.message || "Failed to save avatar") + } finally { + setSaving(false) + } + } + + return ( + + + Customize Avatar + + + {/* Preview */} +
+ +
+ + {/* Color Selection */} +
+ +
+ {PRESET_COLORS.map((color) => ( + + ))} +
+
+ + {/* Text Input */} +
+ + handleTextChange(e.target.value)} + maxLength={3} + className="text-center text-lg font-bold uppercase" + /> +

+ Leave empty to show first letter of username +

+
+ + {error && ( +

{error}

+ )} + + +
+
+ ) +} diff --git a/frontend/components/ui/user-avatar.tsx b/frontend/components/ui/user-avatar.tsx new file mode 100644 index 0000000..e7fbebd --- /dev/null +++ b/frontend/components/ui/user-avatar.tsx @@ -0,0 +1,42 @@ +"use client" + +import { cn } from "@/lib/utils" + +interface UserAvatarProps { + bgColor?: string + text?: string + username?: string + size?: "sm" | "md" | "lg" | "xl" + className?: string +} + +const sizeClasses = { + sm: "h-8 w-8 text-xs", + md: "h-10 w-10 text-sm", + lg: "h-16 w-16 text-xl", + xl: "h-32 w-32 text-4xl", +} + +export function UserAvatar({ + bgColor = "#3B82F6", + text, + username = "", + size = "md", + className +}: UserAvatarProps) { + // If no custom text, use first letter of username + const displayText = text || username.charAt(0).toUpperCase() || "?" + + return ( +
+ {displayText} +
+ ) +}