Commit 98e87c02 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/avatar-upload-editor' into 'develop'

feat(profile): add avatar upload and crop editor

See merge request !5
parents 7b583fbe e3513736
...@@ -15,6 +15,16 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -15,6 +15,16 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend). - **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend).
- **Vite/ZMP entry**: Giữ `index.html` tại root repository và không cấu hình Vite `root: "./src"`. ZMP CLI khởi chạy dev server với project root; cấu hình khác sẽ khiến iframe app trả 404. Build output chuẩn là `www/` tại root. - **Vite/ZMP entry**: Giữ `index.html` tại root repository và không cấu hình Vite `root: "./src"`. ZMP CLI khởi chạy dev server với project root; cấu hình khác sẽ khiến iframe app trả 404. Build output chuẩn là `www/` tại root.
- **Luồng xác thực**: Khi app mount, `AuthInitializer` gọi `/auth/me`; `AuthGuard` chỉ render private route sau khi khởi tạo xong và chuyển người dùng chưa đăng nhập tới `/login`. Cookie HTTP-only là cơ chế xác thực ưu tiên. - **Luồng xác thực**: Khi app mount, `AuthInitializer` gọi `/auth/me`; `AuthGuard` chỉ render private route sau khi khởi tạo xong và chuyển người dùng chưa đăng nhập tới `/login`. Cookie HTTP-only là cơ chế xác thực ưu tiên.
- **Upload file**: Browser tải file trực tiếp lên Cloudflare R2 bằng presigned PUT URL do backend
cấp; không proxy binary qua API và không đưa R2 credentials vào frontend. Hiện luồng này chỉ áp
dụng cho avatar JPEG/PNG/WebP tối đa 5 MB; chỉ cập nhật profile sau khi PUT thành công.
- **Crop avatar**: Trang hồ sơ mở trình chỉnh avatar khi chạm trực tiếp vào ảnh; điểm lấy nét X/Y
được lưu trên profile và dùng nhất quán ở mọi nơi hiển thị avatar.
Hai điều khiển Ngang/Dọc dùng cùng spacing và cùng cấu trúc nút
`−`/slider/`+`. Nút giảm hoặc tăng phải disabled khi giá trị đã chạm giới hạn tương ứng;
toàn bộ điều khiển disabled khi chưa có ảnh hoặc đang lưu. Dựa trên kích thước thật của ảnh
khóa cả dòng Ngang/Dọc kèm biểu tượng khóa nếu tỷ lệ ảnh không có vùng dư
để dịch theo chiều đó.
## Trạng thái đã biết ## Trạng thái đã biết
......
...@@ -2,6 +2,8 @@ import React from "react"; ...@@ -2,6 +2,8 @@ import React from "react";
export interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> { export interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> {
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
positionX?: number;
positionY?: number;
} }
export const Avatar: React.FC<AvatarProps> = ({ export const Avatar: React.FC<AvatarProps> = ({
...@@ -9,6 +11,9 @@ export const Avatar: React.FC<AvatarProps> = ({ ...@@ -9,6 +11,9 @@ export const Avatar: React.FC<AvatarProps> = ({
className = "", className = "",
src, src,
alt = "User Avatar", alt = "User Avatar",
positionX = 50,
positionY = 50,
style,
...props ...props
}) => { }) => {
const sizeStyles = { const sizeStyles = {
...@@ -31,6 +36,10 @@ export const Avatar: React.FC<AvatarProps> = ({ ...@@ -31,6 +36,10 @@ export const Avatar: React.FC<AvatarProps> = ({
src={src || defaultAvatar} src={src || defaultAvatar}
alt={alt} alt={alt}
className="w-full h-full object-cover rounded-full" className="w-full h-full object-cover rounded-full"
style={{
...style,
objectPosition: `${positionX}% ${positionY}%`,
}}
{...props} {...props}
/> />
</div> </div>
......
...@@ -18,7 +18,13 @@ function HomePage() { ...@@ -18,7 +18,13 @@ function HomePage() {
<div className="flex-1 flex flex-col items-center justify-center gap-6 px-4"> <div className="flex-1 flex flex-col items-center justify-center gap-6 px-4">
{/* Logo/Avatar Area */} {/* Logo/Avatar Area */}
<div className="relative"> <div className="relative">
<Avatar size="lg" src={user?.avatarUrl || ""} className="border-clay-primary shadow-clay-hover" /> <Avatar
size="lg"
src={user?.avatarUrl || ""}
positionX={user?.avatarPositionX}
positionY={user?.avatarPositionY}
className="border-clay-primary shadow-clay-hover"
/>
<div className="absolute -bottom-2 -right-2 bg-clay-primary text-white p-2 rounded-full shadow-clay-raised border border-white"> <div className="absolute -bottom-2 -right-2 bg-clay-primary text-white p-2 rounded-full shadow-clay-raised border border-white">
<AIAssistantIcon size={20} /> <AIAssistantIcon size={20} />
</div> </div>
......
This diff is collapsed.
This diff is collapsed.
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { ApiResponse, LoginRequest, User, Session } from "@/types/auth"; import { ApiResponse, LoginRequest, User, Session, UpdateProfileRequest } from "@/types/auth";
export const authService = { export const authService = {
async register(data: any): Promise<ApiResponse> { async register(data: any): Promise<ApiResponse> {
...@@ -27,7 +27,7 @@ export const authService = { ...@@ -27,7 +27,7 @@ export const authService = {
return response.data; return response.data;
}, },
async updateProfile(data: any): Promise<ApiResponse> { async updateProfile(data: UpdateProfileRequest): Promise<ApiResponse<User>> {
const response = await apiClient.put("/auth/profile", data); const response = await apiClient.put("/auth/profile", data);
return response.data; return response.data;
}, },
......
import { apiClient } from "@/lib/api-client";
import { ApiResponse } from "@/types/auth";
import {
AVATAR_CONTENT_TYPES,
AvatarContentType,
CreatePresignedUploadRequest,
PresignedUpload,
} from "@/types/upload";
const isAvatarContentType = (value: string): value is AvatarContentType =>
AVATAR_CONTENT_TYPES.some((contentType) => contentType === value);
export const uploadService = {
async createPresignedUpload(
data: CreatePresignedUploadRequest
): Promise<ApiResponse<PresignedUpload>> {
const response = await apiClient.post("/uploads/presign", data);
return response.data;
},
async uploadAvatar(file: File): Promise<PresignedUpload> {
if (!isAvatarContentType(file.type)) {
throw new Error("Ảnh đại diện phải là JPEG, PNG hoặc WebP.");
}
const presignResponse = await this.createPresignedUpload({
purpose: "avatar",
fileName: file.name,
contentType: file.type,
fileSize: file.size,
});
if (!presignResponse.success || !presignResponse.data) {
throw new Error("Không thể tạo đường dẫn tải ảnh lên.");
}
const upload = presignResponse.data;
const response = await fetch(upload.uploadUrl, {
method: "PUT",
headers: upload.requiredHeaders,
body: file,
});
if (!response.ok) {
throw new Error(`Tải ảnh lên thất bại (${response.status}).`);
}
return upload;
},
};
...@@ -5,9 +5,11 @@ export interface Role { ...@@ -5,9 +5,11 @@ export interface Role {
export interface User { export interface User {
id: string; id: string;
email: string; email: string | null;
fullName: string | null; fullName: string | null;
avatarUrl?: string | null; avatarUrl?: string | null;
avatarPositionX: number;
avatarPositionY: number;
phoneNumber?: string | null; phoneNumber?: string | null;
roleId: string; roleId: string;
role: Role; role: Role;
...@@ -26,6 +28,14 @@ export interface LoginRequest { ...@@ -26,6 +28,14 @@ export interface LoginRequest {
password: string; password: string;
} }
export interface UpdateProfileRequest {
fullName?: string;
avatarUrl?: string | null;
avatarPositionX?: number;
avatarPositionY?: number;
phoneNumber?: string;
}
export interface Session { export interface Session {
id: string; id: string;
deviceName: string; deviceName: string;
......
export const AVATAR_CONTENT_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
] as const;
export type AvatarContentType = (typeof AVATAR_CONTENT_TYPES)[number];
export interface CreatePresignedUploadRequest {
purpose: "avatar";
fileName: string;
contentType: AvatarContentType;
fileSize: number;
}
export interface PresignedUpload {
uploadUrl: string;
publicUrl: string;
objectKey: string;
expiresIn: number;
requiredHeaders: {
"Content-Type": AvatarContentType;
};
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment