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
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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