feat: Add custom avatar system with color picker and text overlay
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
This commit is contained in:
parent
c6ffc67fdd
commit
a4d63a9e2c
7 changed files with 272 additions and 5 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
|
||||
<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">
|
||||
<AvatarImage src={`https://api.dicebear.com/7.x/notionists/svg?seed=${user.id}`} />
|
||||
<AvatarFallback><User className="h-12 w-12" /></AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar
|
||||
bgColor={user.avatar_bg_color || "#3B82F6"}
|
||||
text={user.avatar_text || undefined}
|
||||
username={displayName}
|
||||
size="xl"
|
||||
className="border-4 border-background"
|
||||
/>
|
||||
<div className="space-y-4 flex-1">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</CardContent>
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
|
|||
145
frontend/components/profile/avatar-settings.tsx
Normal file
145
frontend/components/profile/avatar-settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
frontend/components/ui/user-avatar.tsx
Normal file
42
frontend/components/ui/user-avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue