fix(avatar): add upload API routes and fix URL path prefix
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions

- Add /api/users/me/avatar and /api/projects/{name}/avatar multipart upload endpoints
- Fix avatar URL path: missing /avatar prefix (static.gitdata.ai/avatar/{file})
- Fix project avatar: Utc::now() → .timestamp(), missing extension, wrong return type
- Replace broken SkipNoisyPaths middleware with self-contained RequestLogger
  (actix-web 4.13 body type incompatibility with newer actix-http)
- Exclude /assets/* requests from main app logger
- Exclude /avatar/*, /blob/*, /media/*, /static/* from static server logger
- Fix TypingEvent missing sender_type field in ws_universal.rs and connection.rs
- Wire real fetch-based upload in user profile settings
- Add project avatar upload UI to project settings page
This commit is contained in:
ZhenYi 2026-04-25 23:19:22 +08:00
parent b00d42ee8d
commit 8b47f677bb
16 changed files with 377 additions and 121 deletions

2
Cargo.lock generated
View File

@ -8286,6 +8286,8 @@ dependencies = [
"actix-web", "actix-web",
"anyhow", "anyhow",
"env_logger", "env_logger",
"futures",
"log",
"mime", "mime",
"mime_guess2", "mime_guess2",
"serde", "serde",

View File

@ -1,7 +1,6 @@
use actix_cors::Cors; use actix_cors::Cors;
use actix_web::cookie::time::Duration; use actix_web::cookie::time::Duration;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse}; use actix_web::dev::{Service, ServiceRequest, ServiceResponse};
use actix_web::middleware::Logger;
use actix_web::{cookie::Key, web, App, HttpResponse, HttpServer}; use actix_web::{cookie::Key, web, App, HttpResponse, HttpServer};
use clap::Parser; use clap::Parser;
use db::cache::AppCache; use db::cache::AppCache;
@ -17,6 +16,7 @@ use session::config::{PersistentSession, SessionLifecycle, TtlExtensionPolicy};
use session::storage::RedisClusterSessionStore; use session::storage::RedisClusterSessionStore;
use session::SessionMiddleware; use session::SessionMiddleware;
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use std::time::Instant;
mod args; mod args;
@ -30,51 +30,35 @@ pub struct AppState {
pub cache: AppCache, pub cache: AppCache,
} }
/// Skips Logger terminal output for noisy health/monitor/WS endpoints while still /// Custom middleware that logs requests except for noisy paths.
/// passing the request through to the inner service (so metrics are still recorded). struct RequestLogger;
struct SkipNoisyPaths<S>(S);
impl<S> SkipNoisyPaths<S> { impl<S, B> actix_web::dev::Transform<S, ServiceRequest> for RequestLogger
fn new(logger: S) -> Self {
Self(logger)
}
}
impl<S, B> actix_web::dev::Transform<S, ServiceRequest> for SkipNoisyPaths<S>
where where
S: actix_web::dev::Transform< S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
ServiceRequest,
Response = ServiceResponse<B>,
Error = actix_web::Error,
InitError = (),
>,
S::Future: 'static, S::Future: 'static,
B: 'static, B: 'static,
{ {
type Response = ServiceResponse<B>; type Response = ServiceResponse<B>;
type Error = actix_web::Error; type Error = actix_web::Error;
type Transform = SkipNoisyPathsService<S::Transform>; type Transform = RequestLoggerService<S>;
type InitError = (); type InitError = ();
type Future = futures::future::Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S::Transform) -> <Self::Transform as actix_web::dev::Transform< fn new_transform(&self, service: S) -> Self::Future {
ServiceRequest, futures::future::ok(RequestLoggerService {
Response = ServiceResponse<B>,
Error = actix_web::Error,
InitError = (),
>>::Future {
futures::future::ok(SkipNoisyPathsService {
service, service,
_marker: std::marker::PhantomData, _marker: std::marker::PhantomData,
}) })
} }
} }
struct SkipNoisyPathsService<S> { struct RequestLoggerService<S> {
service: S, service: S,
_marker: std::marker::PhantomData<fn(ServiceRequest)>, _marker: std::marker::PhantomData<fn(ServiceRequest)>,
} }
impl<S, B> actix_web::dev::Service<ServiceRequest> for SkipNoisyPathsService<S> impl<S, B> actix_web::dev::Service<ServiceRequest> for RequestLoggerService<S>
where where
S: actix_web::dev::Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>, S: actix_web::dev::Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
S::Future: 'static, S::Future: 'static,
@ -90,19 +74,33 @@ where
fn call(&self, req: ServiceRequest) -> Self::Future { fn call(&self, req: ServiceRequest) -> Self::Future {
let path = req.path().to_string(); let path = req.path().to_string();
let should_skip = path == "/health" let method = req.method().to_string();
let should_log = !(path == "/health"
|| path == "/metrics" || path == "/metrics"
|| path.starts_with("/ws"); || path.starts_with("/ws")
if should_skip { || path.starts_with("/assets"));
let fut = self.service.call(req);
Box::pin(async move { let start = Instant::now();
let res = fut.await?; let fut = self.service.call(req);
// Replace body so Logger doesn't print the line for this request.
Ok(res.map_body(|_, _| actix_web::body::Empty::new())) Box::pin(async move {
}) let res = fut.await?;
} else { if should_log {
self.service.call(req) tracing::info!(
} target: "http_request",
method = %method,
path = %path,
status = res.status().as_u16(),
elapsed = ?start.elapsed(),
"{} {} {} {:?}",
method,
path,
res.status().as_u16(),
start.elapsed()
);
}
Ok(res)
})
} }
} }
@ -207,7 +205,7 @@ async fn main() -> anyhow::Result<()> {
App::new() App::new()
.wrap(cors) .wrap(cors)
.wrap(session_mw) .wrap(session_mw)
.wrap(SkipNoisyPaths::new(Logger::default())) .wrap(RequestLogger)
.wrap(metrics_mw) .wrap(metrics_mw)
.wrap(TracingSpanMiddleware::new()) .wrap(TracingSpanMiddleware::new())
.app_data(web::Data::new(AppState { .app_data(web::Data::new(AppState {

View File

@ -8,6 +8,7 @@ actix-web = { workspace = true }
actix-files = { workspace = true } actix-files = { workspace = true }
actix-cors = { workspace = true } actix-cors = { workspace = true }
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
futures = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
mime = { workspace = true } mime = { workspace = true }
@ -15,6 +16,7 @@ mime_guess2 = { workspace = true }
slog = { workspace = true } slog = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
env_logger = { workspace = true } env_logger = { workspace = true }
log = "0.4"
[profile.release] [profile.release]
strip = true strip = true

View File

@ -1,10 +1,12 @@
use actix_cors::Cors; use actix_cors::Cors;
use actix_files::Files; use actix_files::Files;
use actix_web::dev::{Service, ServiceRequest, ServiceResponse}; use actix_web::dev::{Service, ServiceRequest, ServiceResponse};
use actix_web::{http::header, middleware::Logger, web, App, HttpResponse, HttpServer}; use actix_web::{http::header, web, App, HttpResponse, HttpServer};
use futures::future::LocalBoxFuture; use futures::future::LocalBoxFuture;
use log::info;
use std::path::PathBuf; use std::path::PathBuf;
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use std::time::Instant;
/// Static file server for avatar, blob, and other static files /// Static file server for avatar, blob, and other static files
/// Serves files from /data/{type} directories /// Serves files from /data/{type} directories
@ -42,50 +44,35 @@ async fn health() -> HttpResponse {
})) }))
} }
/// Skips Logger terminal output for noisy health/WS endpoints. /// Custom middleware that logs requests except for noisy paths (health, metrics, static files).
struct SkipNoisyPaths<S>(S); struct RequestLogger;
impl<S> SkipNoisyPaths<S> { impl<S, B> actix_web::dev::Transform<S, ServiceRequest> for RequestLogger
fn new(logger: S) -> Self {
Self(logger)
}
}
impl<S, B> actix_web::dev::Transform<S, ServiceRequest> for SkipNoisyPaths<S>
where where
S: actix_web::dev::Transform< S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
ServiceRequest,
Response = ServiceResponse<B>,
Error = actix_web::Error,
InitError = (),
>,
S::Future: 'static, S::Future: 'static,
B: 'static, B: 'static,
{ {
type Response = ServiceResponse<B>; type Response = ServiceResponse<B>;
type Error = actix_web::Error; type Error = actix_web::Error;
type Transform = SkipNoisyPathsService<S::Transform>; type Transform = RequestLoggerService<S>;
type InitError = (); type InitError = ();
type Future = futures::future::Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S::Transform) -> <Self::Transform as actix_web::dev::Transform< fn new_transform(&self, service: S) -> Self::Future {
ServiceRequest, futures::future::ok(RequestLoggerService {
Response = ServiceResponse<B>,
Error = actix_web::Error,
InitError = (),
>>::Future {
futures::future::ok(SkipNoisyPathsService {
service, service,
_marker: std::marker::PhantomData, _marker: std::marker::PhantomData,
}) })
} }
} }
struct SkipNoisyPathsService<S> { struct RequestLoggerService<S> {
service: S, service: S,
_marker: std::marker::PhantomData<fn(ServiceRequest)>, _marker: std::marker::PhantomData<fn(ServiceRequest)>,
} }
impl<S, B> Service<ServiceRequest> for SkipNoisyPathsService<S> impl<S, B> Service<ServiceRequest> for RequestLoggerService<S>
where where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>, S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
S::Future: 'static, S::Future: 'static,
@ -101,17 +88,32 @@ where
fn call(&self, req: ServiceRequest) -> Self::Future { fn call(&self, req: ServiceRequest) -> Self::Future {
let path = req.path().to_string(); let path = req.path().to_string();
let should_skip = let method = req.method().to_string();
path == "/health" || path == "/metrics" || path.starts_with("/ws"); let should_log = !(path == "/health"
if should_skip { || path == "/metrics"
let fut = self.service.call(req); || path.starts_with("/ws")
Box::pin(async move { || path.starts_with("/avatar")
let res = fut.await?; || path.starts_with("/blob")
Ok(res.map_body(|_, _| actix_web::body::Empty::new())) || path.starts_with("/media")
}) || path.starts_with("/static"));
} else {
self.service.call(req) let start = Instant::now();
} let fut = self.service.call(req);
Box::pin(async move {
let res = fut.await?;
if should_log {
info!(
target: "static_server",
"{} {} {} {:?}",
method,
path,
res.status().as_u16(),
start.elapsed()
);
}
Ok(res)
})
} }
} }
@ -155,7 +157,7 @@ async fn main() -> anyhow::Result<()> {
App::new() App::new()
.wrap(cors) .wrap(cors)
.wrap(SkipNoisyPaths::new(Logger::default())) .wrap(RequestLogger)
.route("/health", web::get().to(health)) .route("/health", web::get().to(health))
.service( .service(
Files::new("/avatar", root.join("avatar")) Files::new("/avatar", root.join("avatar"))

View File

@ -7,6 +7,7 @@ pub mod issue;
pub mod openapi; pub mod openapi;
pub mod project; pub mod project;
pub mod pull_request; pub mod pull_request;
pub mod robots;
pub mod room; pub mod room;
pub mod route; pub mod route;
pub mod search; pub mod search;

View File

@ -0,0 +1,82 @@
use actix_multipart::Multipart;
use actix_web::{HttpResponse, Result, web};
use futures_util::StreamExt;
use service::AppService;
use session::Session;
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct AvatarUploadResponse {
pub avatar_url: String,
}
/// Upload a project's avatar.
/// Accepts a multipart form with a single file field.
#[utoipa::path(
post,
path = "/api/projects/{project_name}/avatar",
params(
("project_name" = String, Path, description = "Project name"),
),
responses(
(status = 200, description = "Avatar uploaded", body = crate::ApiResponse<AvatarUploadResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "No permission"),
),
tag = "Project"
)]
pub async fn upload_project_avatar(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
mut payload: Multipart,
) -> Result<HttpResponse, crate::error::ApiError> {
let project_name = path.into_inner();
let max_size: usize = 2 * 1024 * 1024; // 2MB
let mut file_data: Vec<u8> = Vec::new();
let mut file_ext = "png".to_string();
while let Some(item) = payload.next().await {
let mut field = item.map_err(|e| {
crate::error::ApiError(service::error::AppError::BadRequest(e.to_string()))
})?;
// Detect file extension from content-type
if let Some(content_type) = field.content_type() {
let ext = match content_type.essence_str() {
"image/jpeg" | "image/jpg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
"image/png" | _ => "png",
};
file_ext = ext.to_string();
}
while let Some(chunk) = field.next().await {
let data = chunk.map_err(|e| {
crate::error::ApiError(service::error::AppError::BadRequest(e.to_string()))
})?;
if file_data.len() + data.len() > max_size {
return Err(crate::error::ApiError(
service::error::AppError::BadRequest(
"File exceeds maximum size of 2MB".to_string(),
),
));
}
file_data.extend_from_slice(&data);
}
}
if file_data.is_empty() {
return Err(crate::error::ApiError(
service::error::AppError::BadRequest("No file provided".to_string()),
));
}
let avatar_url = service
.project_avatar_upload(&session, project_name, file_data, file_ext)
.await
.map_err(crate::error::ApiError::from)?;
Ok(crate::ApiResponse::ok(AvatarUploadResponse { avatar_url }).to_response())
}

View File

@ -1,5 +1,6 @@
pub mod activity; pub mod activity;
pub mod audit; pub mod audit;
pub mod avatar;
pub mod billing; pub mod billing;
pub mod board; pub mod board;
pub mod info; pub mod info;
@ -168,6 +169,10 @@ pub fn init_project_routes(cfg: &mut web::ServiceConfig) {
"/{project_name}/activities", "/{project_name}/activities",
web::post().to(activity::project_log_activity), web::post().to(activity::project_log_activity),
) )
.route(
"/{project_name}/avatar",
web::post().to(avatar::upload_project_avatar),
)
.route( .route(
"/{project_name}/billing", "/{project_name}/billing",
web::get().to(billing::project_billing), web::get().to(billing::project_billing),

0
libs/api/robots.rs Normal file
View File

View File

@ -410,6 +410,7 @@ pub async fn ws_universal(
username: names.into_values().next().unwrap_or_else(|| "unknown".to_string()), username: names.into_values().next().unwrap_or_else(|| "unknown".to_string()),
avatar_url: None, avatar_url: None,
action: action.to_string(), action: action.to_string(),
sender_type: None,
}; };
manager.broadcast_typing(room_id, typing_event).await; manager.broadcast_typing(room_id, typing_event).await;
} }

76
libs/api/user/avatar.rs Normal file
View File

@ -0,0 +1,76 @@
use actix_multipart::Multipart;
use actix_web::{HttpResponse, Result, web};
use futures_util::StreamExt;
use service::AppService;
use session::Session;
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct AvatarUploadResponse {
pub avatar_url: String,
}
/// Upload current user's avatar.
/// Accepts a multipart form with a single file field.
#[utoipa::path(
post,
path = "/api/users/me/avatar",
responses(
(status = 200, description = "Avatar uploaded", body = crate::ApiResponse<AvatarUploadResponse>),
(status = 401, description = "Unauthorized"),
),
tag = "User"
)]
pub async fn upload_avatar(
service: web::Data<AppService>,
session: Session,
mut payload: Multipart,
) -> Result<HttpResponse, crate::error::ApiError> {
let max_size: usize = 2 * 1024 * 1024; // 2MB
let mut file_data: Vec<u8> = Vec::new();
let mut file_ext = "png".to_string();
while let Some(item) = payload.next().await {
let mut field = item.map_err(|e| {
crate::error::ApiError(service::error::AppError::BadRequest(e.to_string()))
})?;
// Detect file extension from content-type
if let Some(content_type) = field.content_type() {
let ext = match content_type.essence_str() {
"image/jpeg" | "image/jpg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
"image/png" | _ => "png",
};
file_ext = ext.to_string();
}
while let Some(chunk) = field.next().await {
let data = chunk.map_err(|e| {
crate::error::ApiError(service::error::AppError::BadRequest(e.to_string()))
})?;
if file_data.len() + data.len() > max_size {
return Err(crate::error::ApiError(
service::error::AppError::BadRequest(
"File exceeds maximum size of 2MB".to_string(),
),
));
}
file_data.extend_from_slice(&data);
}
}
if file_data.is_empty() {
return Err(crate::error::ApiError(
service::error::AppError::BadRequest("No file provided".to_string()),
));
}
let avatar_url = service
.user_avatar_upload(session, file_data, &file_ext)
.await
.map_err(crate::error::ApiError::from)?;
Ok(crate::ApiResponse::ok(AvatarUploadResponse { avatar_url }).to_response())
}

View File

@ -1,4 +1,5 @@
pub mod access_key; pub mod access_key;
pub mod avatar;
pub mod chpc; pub mod chpc;
pub mod notification; pub mod notification;
pub mod preferences; pub mod preferences;
@ -18,6 +19,7 @@ pub fn init_user_routes(cfg: &mut web::ServiceConfig) {
web::scope("/users") web::scope("/users")
.route("/me/profile", web::get().to(profile::get_my_profile)) .route("/me/profile", web::get().to(profile::get_my_profile))
.route("/me/profile", web::patch().to(profile::update_my_profile)) .route("/me/profile", web::patch().to(profile::update_my_profile))
.route("/me/avatar", web::post().to(avatar::upload_avatar))
.route( .route(
"/me/preferences", "/me/preferences",
web::get().to(preferences::get_preferences), web::get().to(preferences::get_preferences),

View File

@ -703,7 +703,7 @@ impl RoomConnectionManager {
for key in keys { for key in keys {
let parts: Vec<&str> = key.split(':').collect(); let parts: Vec<&str> = key.split(':').collect();
let user_id = parts.get(2).and_then(|s| Uuid::parse_str(s).ok()); let user_id = parts.get(2).and_then(|s| Uuid::parse_str(s).ok());
if let (Some(Ok(value)), Some(Ok(user_uuid)))) = ( if let (Some(value), Some(user_uuid)) = (
redis::cmd("GET").arg(&key).query_async::<String>(&mut conn).await.ok(), redis::cmd("GET").arg(&key).query_async::<String>(&mut conn).await.ok(),
user_id, user_id,
) { ) {

View File

@ -15,7 +15,7 @@ impl AppService {
project_name: String, project_name: String,
file: Vec<u8>, file: Vec<u8>,
file_ext: String, file_ext: String,
) -> Result<(), AppError> { ) -> Result<String, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self let project = self
.utils_find_project_by_name(project_name.clone()) .utils_find_project_by_name(project_name.clone())
@ -26,14 +26,18 @@ impl AppService {
if role == MemberRole::Member { if role == MemberRole::Member {
return Err(AppError::NoPower); return Err(AppError::NoPower);
} }
let time = Utc::now(); let time = Utc::now().timestamp();
let file_name = format!("{}-{}", project.id, time); let file_name = format!("{}-{}", project.id, time);
self.avatar self.avatar
.upload(file, file_name.clone(), &file_ext) .upload(file, file_name.clone(), &file_ext)
.await .await
.map_err(|e| AppError::AvatarUploadError(e.to_string()))?; .map_err(|e| AppError::AvatarUploadError(e.to_string()))?;
let static_url = self.config.static_domain().unwrap_or("/static".to_string()); let static_url = self.config.static_domain().unwrap_or("/static".to_string());
let file_url = format!("{}/{}", static_url, file_name); let file_url = format!(
"{}/avatar/{}",
static_url.trim_end_matches('/'),
format!("{}.{}", file_name, file_ext)
);
project::Entity::update_many() project::Entity::update_many()
.filter(project::Column::Id.eq(project.id)) .filter(project::Column::Id.eq(project.id))
.col_expr(project::Column::AvatarUrl, Expr::value(file_url.clone())) .col_expr(project::Column::AvatarUrl, Expr::value(file_url.clone()))
@ -54,6 +58,6 @@ impl AppService {
ctx, ctx,
) )
.await?; .await?;
Ok(()) Ok(file_url)
} }
} }

View File

@ -30,7 +30,7 @@ impl AppService {
.static_domain() .static_domain()
.unwrap_or_else(|_| "/static".to_string()); .unwrap_or_else(|_| "/static".to_string());
let file_url = format!( let file_url = format!(
"{}/{}", "{}/avatar/{}",
static_url.trim_end_matches('/'), static_url.trim_end_matches('/'),
format!("{}.{}", file_name, file_ext) format!("{}.{}", file_name, file_ext)
); );

View File

@ -1,5 +1,6 @@
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { Loader2 } from 'lucide-react'; import { Loader2, Upload, User } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
AlertDialog, AlertDialog,
@ -30,12 +31,51 @@ export function SettingsGeneral() {
const [displayName, setDisplayName] = useState(project?.display_name || ''); const [displayName, setDisplayName] = useState(project?.display_name || '');
const [description, setDescription] = useState(project?.description || ''); const [description, setDescription] = useState(project?.description || '');
const [isPublic, setIsPublic] = useState(project?.is_public ?? false); const [isPublic, setIsPublic] = useState(project?.is_public ?? false);
const [avatarUrl, setAvatarUrl] = useState(project?.avatar_url ?? '');
const hasChanges = const hasChanges =
displayName !== (project?.display_name || '') || displayName !== (project?.display_name || '') ||
description !== (project?.description || '') || description !== (project?.description || '') ||
isPublic !== (project?.is_public ?? false); isPublic !== (project?.is_public ?? false);
const uploadAvatarMutation = useMutation({
mutationFn: async (file: File) => {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${baseUrl}/api/projects/${project?.name}/avatar`, {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message ?? 'Upload failed');
}
const json = await res.json();
return json as { data?: { avatar_url?: string } };
},
onSuccess: (data) => {
toast.success('Project avatar updated');
const url = data?.data?.avatar_url;
if (url) setAvatarUrl(url);
refetch();
},
onError: (err: Error) => {
toast.error(err.message || 'Failed to upload avatar');
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
toast.error('File size must be less than 2MB');
return;
}
uploadAvatarMutation.mutate(file);
};
const handleSave = async () => { const handleSave = async () => {
if (!project?.name) return; if (!project?.name) return;
@ -80,6 +120,47 @@ export function SettingsGeneral() {
</CardHeader> </CardHeader>
<CardContent className="space-y-6 pt-2"> <CardContent className="space-y-6 pt-2">
<div className="space-y-2">
<Label>Project Avatar</Label>
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-lg border-2 border-muted bg-muted/50 flex items-center justify-center overflow-hidden shrink-0">
{avatarUrl ? (
<img
src={avatarUrl}
alt="Project avatar"
className="h-full w-full object-cover"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
) : (
<User className="h-8 w-8 text-muted-foreground/50" />
)}
{uploadAvatarMutation.isPending && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => document.getElementById('project-avatar-upload')?.click()}
disabled={uploadAvatarMutation.isPending}
>
<Upload className="h-4 w-4 mr-2" />
Upload avatar
</Button>
<Input
id="project-avatar-upload"
type="file"
className="hidden"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleFileChange}
/>
<span className="text-xs text-muted-foreground">PNG, JPG, GIF or WebP. Max 2MB.</span>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="display-name">Display Name</Label> <Label htmlFor="display-name">Display Name</Label>
<Input <Input

View File

@ -43,45 +43,45 @@ export function SettingsProfile() {
websiteUrl !== originalWebsiteUrl || websiteUrl !== originalWebsiteUrl ||
organization !== originalOrganization; organization !== originalOrganization;
const uploadAvatarMutation = useMutation({ const uploadAvatarMutation = useMutation({
mutationFn: async (data: { image_data: string; format?: string }) => { mutationFn: async (file: File) => {
// This would need an actual avatar upload API const baseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
// For now, we'll just use the data URL directly const formData = new FormData();
return { data: { avatar_url: `data:image/png;base64,${data.image_data}` } }; formData.append('file', file);
}, const res = await fetch(`${baseUrl}/api/users/me/avatar`, {
onSuccess: (data: unknown) => { method: 'POST',
toast.success('Avatar uploaded successfully'); body: formData,
const url = (data as { data?: { avatar_url?: string } })?.data?.avatar_url; credentials: 'include',
if (url) setAvatarUrl(url); });
queryClient.invalidateQueries({ queryKey: ['userProfile'] }); if (!res.ok) {
}, const err = await res.json().catch(() => null);
onError: () => { throw new Error(err?.message ?? 'Upload failed');
toast.error('Failed to upload avatar'); }
}, const json = await res.json();
}); return json as { data?: { avatar_url?: string } };
},
onSuccess: (data) => {
toast.success('Avatar uploaded successfully');
const url = data?.data?.avatar_url;
if (url) setAvatarUrl(url);
queryClient.invalidateQueries({ queryKey: ['userProfile'] });
},
onError: (err: Error) => {
toast.error(err.message || 'Failed to upload avatar');
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
if (file.size > 2 * 1024 * 1024) { if (file.size > 2 * 1024 * 1024) {
toast.error('File size must be less than 2MB'); toast.error('File size must be less than 2MB');
return; return;
} }
const reader = new FileReader(); uploadAvatarMutation.mutate(file);
reader.onloadend = () => { };
const base64String = reader.result as string;
const base64Data = base64String.split(',')[1];
const format = file.type.split('/')[1] || 'png';
uploadAvatarMutation.mutate({
image_data: base64Data,
format: format,
});
};
reader.readAsDataURL(file);
};
const updateProfileMutation = useMutation({ const updateProfileMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {