feat: implement user avatars with DiceBear integration

- Add avatar_url field to MemberSettingsUpdate schema
- Create AvatarService for generating default avatars using DiceBear
- Update auth service to generate avatars on user registration
- Add avatar upload UI to settings page
- Update settings endpoint to handle avatar URL updates
- Display current avatar in settings with upload/generate options

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mistral Vibe
2026-03-30 19:15:24 +02:00
parent 3b8c4a0cb8
commit ccafcd38af
10 changed files with 836 additions and 3 deletions

View File

@@ -7,6 +7,7 @@ interface MemberRead {
id: string;
display_name: string;
email: string;
avatar_url: string | null;
nc_username: string | null;
nc_url: string | null;
nc_configured: boolean;
@@ -37,6 +38,7 @@ function SettingsForm({ me, onBack }: { me: MemberRead; onBack: () => void }) {
const [ncUrl, setNcUrl] = useState(me.nc_url ?? "");
const [ncUsername, setNcUsername] = useState(me.nc_username ?? "");
const [ncPassword, setNcPassword] = useState("");
const [avatarUrl, setAvatarUrl] = useState(me.avatar_url ?? "");
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -47,6 +49,7 @@ function SettingsForm({ me, onBack }: { me: MemberRead; onBack: () => void }) {
nc_url: ncUrl || undefined,
nc_username: ncUsername || undefined,
nc_password: ncPassword || undefined,
avatar_url: avatarUrl || undefined,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["me"] });
@@ -66,9 +69,16 @@ function SettingsForm({ me, onBack }: { me: MemberRead; onBack: () => void }) {
<>
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 13, color: "var(--text-muted)", fontFamily: "monospace", letterSpacing: 1, marginBottom: 16 }}>PROFILE</h2>
<label style={labelStyle}>DISPLAY NAME</label>
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} style={inputStyle} />
<p style={{ color: "var(--text-subtle)", fontSize: 11, margin: "4px 0 0" }}>{me.email}</p>
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 16 }}>
{avatarUrl && (
<img src={avatarUrl} alt="Profile" style={{ width: 64, height: 64, borderRadius: "50%", objectFit: "cover" }} />
)}
<div style={{ flex: 1 }}>
<label style={labelStyle}>DISPLAY NAME</label>
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} style={inputStyle} />
<p style={{ color: "var(--text-subtle)", fontSize: 11, margin: "4px 0 0" }}>{me.email}</p>
</div>
</div>
</section>
<section style={{ marginBottom: 32 }}>
@@ -103,6 +113,78 @@ function SettingsForm({ me, onBack }: { me: MemberRead; onBack: () => void }) {
</p>
</section>
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 13, color: "var(--text-muted)", fontFamily: "monospace", letterSpacing: 1, marginBottom: 16 }}>AVATAR</h2>
<div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 16 }}>
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
// In a real app, you would upload the file to a server
// and get a URL back. For now, we'll use a placeholder.
const reader = new FileReader();
reader.onload = (event) => {
// This is a simplified approach - in production you'd upload to server
setAvatarUrl(event.target?.result as string);
};
reader.readAsDataURL(file);
}
}}
style={{ display: "none" }}
id="avatar-upload"
/>
<label htmlFor="avatar-upload" style={{
background: "var(--accent)",
border: "none",
borderRadius: 6,
color: "var(--accent-fg)",
cursor: "pointer",
padding: "8px 16px",
fontWeight: 600,
fontSize: 14
}}>
Upload Avatar
</label>
<button
onClick={() => {
// Generate a new random avatar
const randomSeed = Math.random().toString(36).substring(2, 15);
setAvatarUrl(`https://api.dicebear.com/v6/identicon/svg?seed=${randomSeed}&backgroundType=gradientLinear&size=128`);
}}
style={{
background: "none",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--text)",
cursor: "pointer",
padding: "8px 16px",
fontSize: 14
}}
>
Generate Random
</button>
</div>
{avatarUrl && (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<img src={avatarUrl} alt="Preview" style={{ width: 48, height: 48, borderRadius: "50%", objectFit: "cover" }} />
<button
onClick={() => setAvatarUrl("")}
style={{
background: "none",
border: "none",
color: "var(--danger)",
cursor: "pointer",
fontSize: 12
}}
>
Remove
</button>
</div>
)}
</section>
{error && <p style={{ color: "var(--danger)", fontSize: 13, marginBottom: 12 }}>{error}</p>}
{saved && <p style={{ color: "var(--teal)", fontSize: 13, marginBottom: 12 }}>Settings saved.</p>}