feat: Add custom avatar system with color picker and text overlay
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-23 11:12:31 -08:00
parent c6ffc67fdd
commit a4d63a9e2c
7 changed files with 272 additions and 5 deletions

View file

@ -173,6 +173,8 @@ class User(SQLModel, table=True):
role: str = Field(default="user") # user, moderator, admin role: str = Field(default="user") # user, moderator, admin
bio: Optional[str] = Field(default=None) bio: Optional[str] = Field(default=None)
avatar: 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 # Gamification
xp: int = Field(default=0, description="Experience points") xp: int = Field(default=0, description="Experience points")

View file

@ -14,6 +14,24 @@ class UserProfileUpdate(BaseModel):
avatar: Optional[str] = None avatar: Optional[str] = None
username: Optional[str] = None username: Optional[str] = None
display_name: 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) @router.get("/{user_id}", response_model=UserRead)
def get_user_public(user_id: int, session: Session = Depends(get_session)): 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 current_user.bio = update.bio
if update.avatar is not None: if update.avatar is not None:
current_user.avatar = update.avatar 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: if update.username or update.display_name:
# Find or create primary profile # Find or create primary profile
@ -74,6 +101,42 @@ def update_my_profile(
session.refresh(current_user) session.refresh(current_user)
return 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) @router.patch("/me/preferences", response_model=UserPreferencesUpdate)
def update_preferences( def update_preferences(
prefs: UserPreferencesUpdate, prefs: UserPreferencesUpdate,

View file

@ -12,6 +12,8 @@ class UserRead(SQLModel):
email: str email: str
is_active: bool is_active: bool
is_superuser: bool is_superuser: bool
avatar_bg_color: Optional[str] = "#3B82F6"
avatar_text: Optional[str] = None
class Token(SQLModel): class Token(SQLModel):
access_token: str access_token: str

View file

@ -14,7 +14,7 @@ import { UserGroupsList } from "@/components/profile/user-groups-list"
import { ChaseSongsList } from "@/components/profile/chase-songs-list" import { ChaseSongsList } from "@/components/profile/chase-songs-list"
import { AttendanceSummary } from "@/components/profile/attendance-summary" import { AttendanceSummary } from "@/components/profile/attendance-summary"
import { LevelProgressCard } from "@/components/gamification/level-progress" 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" import { motion } from "framer-motion"
// Types // Types
@ -23,6 +23,8 @@ interface UserProfile {
email: string email: string
username: string username: string
avatar: string | null avatar: string | null
avatar_bg_color: string | null
avatar_text: string | null
bio: string | null bio: string | null
created_at: string created_at: string
} }
@ -125,10 +127,13 @@ export default function ProfilePage() {
</div> </div>
<div className="flex flex-col md:flex-row gap-8 items-start pt-8"> <div className="flex flex-col md:flex-row gap-8 items-start pt-8">
<Avatar className="h-32 w-32 border-4 border-background shadow-lg"> <UserAvatar
<AvatarImage src={`https://api.dicebear.com/7.x/notionists/svg?seed=${user.id}`} /> bgColor={user.avatar_bg_color || "#3B82F6"}
<AvatarFallback><User className="h-12 w-12" /></AvatarFallback> text={user.avatar_text || undefined}
</Avatar> username={displayName}
size="xl"
className="border-4 border-background"
/>
<div className="space-y-4 flex-1"> <div className="space-y-4 flex-1">
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View file

@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"
import { usePreferences } from "@/contexts/preferences-context" import { usePreferences } from "@/contexts/preferences-context"
import { useAuth } from "@/contexts/auth-context" import { useAuth } from "@/contexts/auth-context"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { AvatarSettings } from "@/components/profile/avatar-settings"
export default function SettingsPage() { export default function SettingsPage() {
const { preferences, updatePreferences, loading } = usePreferences() const { preferences, updatePreferences, loading } = usePreferences()
@ -80,6 +81,13 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Avatar Section */}
<AvatarSettings
currentBgColor={(user as any)?.avatar_bg_color || "#3B82F6"}
currentText={(user as any)?.avatar_text || ""}
username={(user as any)?.email?.split('@')[0] || "User"}
/>
{/* Preferences Section */} {/* Preferences Section */}
<Card> <Card>
<CardHeader> <CardHeader>

View file

@ -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 (
<Card>
<CardHeader>
<CardTitle>Customize Avatar</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Preview */}
<div className="flex justify-center">
<UserAvatar
bgColor={bgColor}
text={text}
username={username}
size="xl"
/>
</div>
{/* Color Selection */}
<div className="space-y-2">
<Label>Background Color</Label>
<div className="grid grid-cols-4 gap-2">
{PRESET_COLORS.map((color) => (
<button
key={color.value}
onClick={() => setBgColor(color.value)}
className="relative h-10 rounded-lg transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2"
style={{ backgroundColor: color.value }}
title={color.name}
>
{bgColor === color.value && (
<Check className="absolute inset-0 m-auto h-5 w-5 text-white drop-shadow-md" />
)}
</button>
))}
</div>
</div>
{/* Text Input */}
<div className="space-y-2">
<Label htmlFor="avatar-text">Display Text (1-3 characters)</Label>
<Input
id="avatar-text"
placeholder="e.g. ABC or 123"
value={text}
onChange={(e) => handleTextChange(e.target.value)}
maxLength={3}
className="text-center text-lg font-bold uppercase"
/>
<p className="text-xs text-muted-foreground">
Leave empty to show first letter of username
</p>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button
onClick={handleSave}
disabled={saving}
className="w-full"
>
{saving ? "Saving..." : "Save Avatar"}
</Button>
</CardContent>
</Card>
)
}

View file

@ -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 (
<div
className={cn(
"rounded-full flex items-center justify-center font-bold text-white shadow-md select-none",
sizeClasses[size],
className
)}
style={{ backgroundColor: bgColor }}
>
{displayText}
</div>
)
}