Skip to content

Commit 68d3ad2

Browse files
user avatar + api
1 parent d56c5ed commit 68d3ad2

9 files changed

Lines changed: 143 additions & 2 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Add avatar_url to User
2+
3+
Revision ID: a1b2c3d4e5f6
4+
Revises: fe56fa70289e
5+
Create Date: 2026-02-05 10:00:00.000000
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = 'a1b2c3d4e5f6'
15+
down_revision = 'fe56fa70289e'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.add_column('user', sa.Column('avatar_url', sa.String(length=500), nullable=True))
22+
23+
24+
def downgrade():
25+
op.drop_column('user', 'avatar_url')

backend/app/api/routes/users.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import uuid
2+
import shutil
3+
from pathlib import Path
24
from typing import Any
35

4-
from fastapi import APIRouter, Depends, HTTPException
6+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
57
from sqlmodel import col, delete, func, select
68

79
from app import crud
@@ -99,6 +101,38 @@ def update_user_me(
99101
return current_user
100102

101103

104+
@router.post("/me/avatar", response_model=UserPublic)
105+
def update_user_avatar(
106+
*,
107+
session: SessionDep,
108+
current_user: CurrentUser,
109+
file: UploadFile = File(...)
110+
) -> Any:
111+
"""
112+
Upload user avatar.
113+
"""
114+
# Ensure upload directory exists
115+
upload_dir = Path("app/static/uploads")
116+
upload_dir.mkdir(parents=True, exist_ok=True)
117+
118+
# Generate unique filename
119+
file_ext = Path(file.filename).suffix if file.filename else ""
120+
file_name = f"{current_user.id}_{uuid.uuid4()}{file_ext}"
121+
file_path = upload_dir / file_name
122+
123+
with file_path.open("wb") as buffer:
124+
shutil.copyfileobj(file.file, buffer)
125+
126+
# Update user profile
127+
# Assuming served at /static/uploads/
128+
avatar_url = f"/static/uploads/{file_name}"
129+
current_user.avatar_url = avatar_url
130+
session.add(current_user)
131+
session.commit()
132+
session.refresh(current_user)
133+
return current_user
134+
135+
102136
@router.patch("/me/password", response_model=Message)
103137
def update_password_me(
104138
*, session: SessionDep, body: UpdatePassword, current_user: CurrentUser

backend/app/main.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import sentry_sdk
2+
import os
23
from fastapi import FastAPI
4+
from fastapi.staticfiles import StaticFiles
35
from fastapi.routing import APIRoute
46
from starlette.middleware.cors import CORSMiddleware
57

@@ -30,4 +32,11 @@ def custom_generate_unique_id(route: APIRoute) -> str:
3032
allow_headers=["*"],
3133
)
3234

35+
# Mount static files
36+
upload_dir = "app/static"
37+
if not os.path.exists(upload_dir):
38+
os.makedirs(upload_dir)
39+
40+
app.mount("/static", StaticFiles(directory=upload_dir), name="static")
41+
3342
app.include_router(api_router, prefix=settings.API_V1_STR)

backend/app/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class UserBase(SQLModel):
1616
is_active: bool = True
1717
is_superuser: bool = False
1818
full_name: str | None = Field(default=None, max_length=255)
19+
avatar_url: str | None = Field(default=None, max_length=500)
1920

2021

2122
# Properties to receive via API on creation
66.9 KB
Loading

compose.override.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ services:
8181
# TODO: remove once coverage is done locally
8282
volumes:
8383
- ./backend/htmlcov:/app/backend/htmlcov
84+
- ./backend/app/static:/app/backend/app/static
8485
environment:
8586
SMTP_HOST: "mailcatcher"
8687
SMTP_PORT: "1025"

compose.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ services:
1111
timeout: 10s
1212
volumes:
1313
- app-db-data:/var/lib/postgresql/data/pgdata
14+
- app-static-data:/app/backend/app/static
1415
env_file:
1516
- .env
1617
environment:
@@ -114,6 +115,9 @@ services:
114115
interval: 10s
115116
timeout: 5s
116117
retries: 5
118+
119+
volumes:
120+
- app-static-data:/app/backend/app/static
117121

118122
build:
119123
context: .
@@ -167,6 +171,7 @@ services:
167171
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect
168172
volumes:
169173
app-db-data:
174+
app-static-data:
170175

171176
networks:
172177
traefik-public:

frontend/src/client/types.gen.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export type UserPublic = {
7575
is_active?: boolean;
7676
is_superuser?: boolean;
7777
full_name?: (string | null);
78+
avatar_url?: (string | null);
7879
id: string;
7980
created_at?: (string | null);
8081
};

frontend/src/components/UserSettings/UserInformation.tsx

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { zodResolver } from "@hookform/resolvers/zod"
22
import { useMutation, useQueryClient } from "@tanstack/react-query"
3-
import { useState } from "react"
3+
import { useState, useRef, type ChangeEvent } from "react"
44
import { useForm } from "react-hook-form"
55
import { z } from "zod"
66

@@ -34,6 +34,40 @@ const UserInformation = () => {
3434
const [editMode, setEditMode] = useState(false)
3535
const { user: currentUser } = useAuth()
3636

37+
const fileInputRef = useRef<HTMLInputElement>(null)
38+
39+
const handleAvatarClick = () => {
40+
fileInputRef.current?.click()
41+
}
42+
43+
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
44+
const file = event.target.files?.[0]
45+
if (!file) return
46+
47+
const formData = new FormData()
48+
formData.append("file", file)
49+
50+
try {
51+
const token = localStorage.getItem("access_token")
52+
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/v1/users/me/avatar`, {
53+
method: "POST",
54+
headers: {
55+
Authorization: `Bearer ${token}`,
56+
},
57+
body: formData,
58+
})
59+
60+
if (!response.ok) {
61+
throw new Error("Failed to upload avatar")
62+
}
63+
64+
showSuccessToast("Avatar updated successfully")
65+
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
66+
} catch (error) {
67+
showErrorToast("Error uploading avatar")
68+
}
69+
}
70+
3771
const form = useForm<FormData>({
3872
resolver: zodResolver(formSchema),
3973
mode: "onBlur",
@@ -83,6 +117,37 @@ const UserInformation = () => {
83117
return (
84118
<div className="max-w-md">
85119
<h3 className="text-lg font-semibold py-4">User Information</h3>
120+
121+
<div className="flex items-center gap-4 mb-6">
122+
<div
123+
className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-100 border border-gray-200"
124+
>
125+
{currentUser?.avatar_url ? (
126+
<img
127+
src={`${import.meta.env.VITE_API_URL}${currentUser.avatar_url}`}
128+
alt="Avatar"
129+
className="w-full h-full object-cover"
130+
/>
131+
) : (
132+
<div className="flex items-center justify-center h-full text-3xl font-bold text-gray-300 uppercase">
133+
{currentUser?.full_name?.charAt(0) || currentUser?.email?.charAt(0) || "?"}
134+
</div>
135+
)}
136+
</div>
137+
<div className="flex flex-col gap-2">
138+
<Button variant="outline" size="sm" onClick={handleAvatarClick}>
139+
Change Avatar
140+
</Button>
141+
<input
142+
type="file"
143+
ref={fileInputRef}
144+
className="hidden"
145+
accept="image/*"
146+
onChange={handleFileChange}
147+
/>
148+
</div>
149+
</div>
150+
86151
<Form {...form}>
87152
<form
88153
onSubmit={form.handleSubmit(onSubmit)}

0 commit comments

Comments
 (0)