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>
......
import React, { PointerEvent, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
export interface AvatarPosition {
x: number;
y: number;
}
interface AvatarEditorModalProps {
isOpen: boolean;
imageUrl: string | null;
position: AvatarPosition;
hasStoredAvatar: boolean;
isSaving: boolean;
onClose: () => void;
onFileChange: (file: File) => void;
onPositionChange: (position: AvatarPosition) => void;
onSave: () => void;
onDelete: () => void;
}
interface DragStart {
pointerX: number;
pointerY: number;
position: AvatarPosition;
}
interface AvatarAdjustmentControlProps {
id: string;
label: string;
value: number;
displayValue: string;
min: number;
max: number;
sliderStep: number;
buttonStep: number;
disabled: boolean;
locked?: boolean;
lockedMessage?: string;
decreaseLabel: string;
increaseLabel: string;
onChange: (value: number) => void;
}
const clampPosition = (value: number): number =>
Math.min(100, Math.max(0, Math.round(value)));
const AvatarAdjustmentControl: React.FC<AvatarAdjustmentControlProps> = ({
id,
label,
value,
displayValue,
min,
max,
sliderStep,
buttonStep,
disabled,
locked = false,
lockedMessage,
decreaseLabel,
increaseLabel,
onChange,
}) => (
<div
className={`grid w-full grid-cols-[4.5rem_1fr_2.5rem] items-center gap-2 transition-all duration-200 ease-in-out ${locked ? "opacity-55" : ""}`}
title={locked ? lockedMessage : undefined}
>
<label htmlFor={id} className="flex items-center gap-1 font-nunito text-sm font-bold text-clay-text">
{label}
{locked && (
<svg
aria-label="Đang khóa"
viewBox="0 0 24 24"
className="h-3.5 w-3.5 text-clay-text-muted"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="5" y="10" width="14" height="10" rx="3" />
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg>
)}
</label>
<div className="flex items-center gap-2">
<button
type="button"
aria-label={decreaseLabel}
disabled={disabled || value <= min}
onClick={() => onChange(Math.max(min, value - buttonStep))}
className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-clay-surface font-baloo text-lg font-bold text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-40 disabled:shadow-none"
>
</button>
<input
id={id}
aria-label={label}
type="range"
min={min}
max={max}
step={sliderStep}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
className="min-w-0 flex-1 cursor-pointer accent-clay-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
<button
type="button"
aria-label={increaseLabel}
disabled={disabled || value >= max}
onClick={() => onChange(Math.min(max, value + buttonStep))}
className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-clay-surface font-baloo text-lg font-bold text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-40 disabled:shadow-none"
>
+
</button>
</div>
<span className="text-right font-nunito text-xs font-bold text-clay-text-muted">
{displayValue}
</span>
</div>
);
export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
isOpen,
imageUrl,
position,
hasStoredAvatar,
isSaving,
onClose,
onFileChange,
onPositionChange,
onSave,
onDelete,
}) => {
const dragStart = useRef<DragStart | null>(null);
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
const canAdjustHorizontal = imageSize !== null
&& imageSize.width > imageSize.height;
const canAdjustVertical = imageSize !== null
&& imageSize.height > imageSize.width;
useEffect(() => {
setImageSize(null);
}, [imageUrl]);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!imageUrl || isSaving || (!canAdjustHorizontal && !canAdjustVertical)) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragStart.current = {
pointerX: event.clientX,
pointerY: event.clientY,
position,
};
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!dragStart.current) return;
const bounds = event.currentTarget.getBoundingClientRect();
const deltaX = ((event.clientX - dragStart.current.pointerX) / bounds.width) * 100;
const deltaY = ((event.clientY - dragStart.current.pointerY) / bounds.height) * 100;
onPositionChange({
x: canAdjustHorizontal
? clampPosition(dragStart.current.position.x - deltaX)
: dragStart.current.position.x,
y: canAdjustVertical
? clampPosition(dragStart.current.position.y - deltaY)
: dragStart.current.position.y,
});
};
const stopDragging = (event: PointerEvent<HTMLDivElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
dragStart.current = null;
};
return (
<Modal
isOpen={isOpen}
onClose={isSaving ? () => undefined : onClose}
title="Chỉnh ảnh đại diện"
footer={
<div className="flex w-full flex-wrap items-center justify-end gap-2">
{hasStoredAvatar && (
<Button
type="button"
variant="ghost"
disabled={isSaving}
onClick={onDelete}
className="mr-auto px-3 text-sm text-clay-expense"
>
Xóa ảnh
</Button>
)}
<Button
type="button"
variant="ghost"
disabled={isSaving}
onClick={onClose}
className="px-3 text-sm"
>
Hủy
</Button>
<Button
type="button"
variant="primary"
disabled={!imageUrl || isSaving}
onClick={onSave}
className="px-4 text-sm"
>
{isSaving ? "Đang lưu..." : "Lưu ảnh"}
</Button>
</div>
}
>
<div className="flex flex-col items-center gap-4">
<div
role="application"
aria-label="Kéo để chọn vùng ảnh đại diện"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
className={`h-56 w-56 touch-none select-none overflow-hidden rounded-full border-4 border-white bg-clay-bg shadow-clay-pressed transition-all duration-200 ease-in-out ${canAdjustHorizontal || canAdjustVertical ? "cursor-grab active:cursor-grabbing" : "cursor-not-allowed"}`}
>
{imageUrl ? (
<img
src={imageUrl}
alt="Xem trước ảnh đại diện"
draggable={false}
className="h-full w-full pointer-events-none rounded-full object-cover"
style={{
objectPosition: `${position.x}% ${position.y}%`,
}}
onLoad={(event) => setImageSize({
width: event.currentTarget.naturalWidth,
height: event.currentTarget.naturalHeight,
})}
/>
) : (
<div className="flex h-full w-full items-center justify-center px-8 text-center font-nunito text-sm font-semibold text-clay-text-muted">
Chọn một ảnh để bắt đầu
</div>
)}
</div>
<p className="clay-caption text-center">
Kéo ảnh trong khung tròn hoặc dùng thanh trượt để chọn vùng hiển thị.
</p>
<div className="flex w-full flex-col gap-3">
<AvatarAdjustmentControl
id="avatar-position-x"
label="Ngang"
value={position.x}
displayValue={`${position.x}%`}
min={0}
max={100}
sliderStep={1}
buttonStep={5}
disabled={!imageUrl || isSaving || !canAdjustHorizontal}
locked={Boolean(imageUrl && imageSize && !canAdjustHorizontal)}
lockedMessage="Tỷ lệ ảnh không có vùng dư để chỉnh theo chiều ngang."
decreaseLabel="Dịch ảnh sang trái"
increaseLabel="Dịch ảnh sang phải"
onChange={(x) => onPositionChange({ ...position, x })}
/>
<AvatarAdjustmentControl
id="avatar-position-y"
label="Dọc"
value={position.y}
displayValue={`${position.y}%`}
min={0}
max={100}
sliderStep={1}
buttonStep={5}
disabled={!imageUrl || isSaving || !canAdjustVertical}
locked={Boolean(imageUrl && imageSize && !canAdjustVertical)}
lockedMessage="Tỷ lệ ảnh không có vùng dư để chỉnh theo chiều dọc."
decreaseLabel="Dịch ảnh lên trên"
increaseLabel="Dịch ảnh xuống dưới"
onChange={(y) => onPositionChange({ ...position, y })}
/>
</div>
<label className="inline-flex cursor-pointer items-center rounded-full bg-clay-surface px-5 py-2.5 font-nunito text-sm font-bold text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed">
{imageUrl ? "Chọn ảnh khác" : "Chọn ảnh"}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
disabled={isSaving}
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onFileChange(file);
event.target.value = "";
}}
/>
</label>
<span className="clay-caption">JPEG, PNG, WebP · tối đa 5 MB</span>
</div>
</Modal>
);
};
...@@ -13,7 +13,15 @@ import { Tabs } from "@/components/ui/Tabs"; ...@@ -13,7 +13,15 @@ import { Tabs } from "@/components/ui/Tabs";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
import { uploadService } from "@/services/upload.service";
import { IconGradients } from "@/components/ui/icons"; import { IconGradients } from "@/components/ui/icons";
import {
AvatarEditorModal,
AvatarPosition,
} from "@/pages/profile/avatar-editor-modal";
const AVATAR_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
const AVATAR_ACCEPT = "image/jpeg,image/png,image/webp";
// Zod schemas for forms // Zod schemas for forms
const profileSchema = z.object({ const profileSchema = z.object({
...@@ -51,6 +59,13 @@ const ProfilePage: React.FC = () => { ...@@ -51,6 +59,13 @@ const ProfilePage: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user, setUser, clearAuth } = useAuthStore(); const { user, setUser, clearAuth } = useAuthStore();
const [activeTab, setActiveTab] = useState("profile"); const [activeTab, setActiveTab] = useState("profile");
const [isAvatarEditorOpen, setIsAvatarEditorOpen] = useState(false);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null);
const [avatarPosition, setAvatarPosition] = useState<AvatarPosition>({
x: user?.avatarPositionX ?? 50,
y: user?.avatarPositionY ?? 50,
});
const tabs = [ const tabs = [
{ key: "profile", label: "Thông Tin" }, { key: "profile", label: "Thông Tin" },
...@@ -64,6 +79,8 @@ const ProfilePage: React.FC = () => { ...@@ -64,6 +79,8 @@ const ProfilePage: React.FC = () => {
handleSubmit: handleProfileSubmit, handleSubmit: handleProfileSubmit,
formState: { errors: profileErrors }, formState: { errors: profileErrors },
reset: resetProfileForm, reset: resetProfileForm,
setValue: setProfileValue,
watch: watchProfile,
} = useForm<ProfileFormValues>({ } = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema),
defaultValues: { defaultValues: {
...@@ -73,6 +90,8 @@ const ProfilePage: React.FC = () => { ...@@ -73,6 +90,8 @@ const ProfilePage: React.FC = () => {
}, },
}); });
const currentAvatarUrl = watchProfile("avatarUrl");
const { const {
register: registerPassword, register: registerPassword,
handleSubmit: handlePasswordSubmit, handleSubmit: handlePasswordSubmit,
...@@ -92,30 +111,104 @@ const ProfilePage: React.FC = () => { ...@@ -92,30 +111,104 @@ const ProfilePage: React.FC = () => {
// Mutations // Mutations
const updateProfileMutation = useMutation({ const updateProfileMutation = useMutation({
mutationFn: authService.updateProfile, mutationFn: authService.updateProfile,
onSuccess: async () => { onSuccess: (response) => {
openSnackbar({ openSnackbar({
type: "success", type: "success",
text: "Cập nhật thông tin cá nhân thành công! 🎉", text: "Cập nhật thông tin cá nhân thành công! 🎉",
}); });
// Refetch user data if (response.success && response.data) {
try { setUser(response.data);
const meRes = await authService.getMe(); resetProfileForm({
if (meRes.success && meRes.data) { fullName: response.data.fullName || "",
setUser(meRes.data); phoneNumber: response.data.phoneNumber || "",
resetProfileForm({ avatarUrl: response.data.avatarUrl || "",
fullName: meRes.data.fullName || "", });
phoneNumber: meRes.data.phoneNumber || "",
avatarUrl: meRes.data.avatarUrl || "",
});
}
} catch (err) {
console.error("Refetch user profile failed", err);
} }
}, },
onError: (error: any) => { onError: (error: unknown) => {
const apiMessage = (error as { response?: { data?: { message?: string } } })
.response?.data?.message;
openSnackbar({
type: "error",
text:
apiMessage ||
(error instanceof Error ? error.message : "Cập nhật thất bại, vui lòng thử lại."),
});
},
});
const saveAvatarMutation = useMutation({
mutationFn: async () => {
let avatarUrl = currentAvatarUrl || user?.avatarUrl || null;
if (avatarFile) {
const upload = await uploadService.uploadAvatar(avatarFile);
avatarUrl = upload.publicUrl;
}
if (!avatarUrl) {
throw new Error("Vui lòng chọn ảnh đại diện trước khi lưu.");
}
const response = await authService.updateProfile({
avatarUrl,
avatarPositionX: avatarPosition.x,
avatarPositionY: avatarPosition.y,
});
if (!response.success || !response.data) {
throw new Error("Không thể lưu ảnh đại diện vào hồ sơ.");
}
return response.data;
},
onSuccess: (updatedUser) => {
setProfileValue("avatarUrl", updatedUser.avatarUrl || "", { shouldDirty: false });
setUser(updatedUser);
setAvatarFile(null);
setAvatarPreviewUrl(null);
setIsAvatarEditorOpen(false);
openSnackbar({
type: "success",
text: "Đã lưu ảnh và vùng hiển thị!",
});
},
onError: (error: unknown) => {
const apiMessage = (error as { response?: { data?: { message?: string } } })
.response?.data?.message;
openSnackbar({
type: "error",
text:
apiMessage ||
(error instanceof Error ? error.message : "Tải ảnh đại diện thất bại."),
});
},
});
const deleteAvatarMutation = useMutation({
mutationFn: async () => {
const response = await authService.updateProfile({ avatarUrl: null });
if (!response.success || !response.data) {
throw new Error("Không thể xóa ảnh đại diện.");
}
return response.data;
},
onSuccess: (updatedUser) => {
setProfileValue("avatarUrl", "", { shouldDirty: false });
setUser(updatedUser);
setAvatarFile(null);
setAvatarPreviewUrl(null);
setAvatarPosition({ x: 50, y: 50 });
setIsAvatarEditorOpen(false);
openSnackbar({
type: "success",
text: "Đã xóa ảnh đại diện.",
});
},
onError: (error: unknown) => {
const apiMessage = (error as { response?: { data?: { message?: string } } })
.response?.data?.message;
openSnackbar({ openSnackbar({
type: "error", type: "error",
text: error.response?.data?.message || "Cập nhật thất bại, vui lòng thử lại.", text:
apiMessage ||
(error instanceof Error ? error.message : "Không thể xóa ảnh đại diện."),
}); });
}, },
}); });
...@@ -190,6 +283,56 @@ const ProfilePage: React.FC = () => { ...@@ -190,6 +283,56 @@ const ProfilePage: React.FC = () => {
updateProfileMutation.mutate(values); updateProfileMutation.mutate(values);
}; };
const handleAvatarChange = (file: File) => {
if (!AVATAR_ACCEPT.split(",").includes(file.type)) {
openSnackbar({
type: "error",
text: "Ảnh đại diện phải là JPEG, PNG hoặc WebP.",
});
return;
}
if (file.size > AVATAR_MAX_FILE_SIZE_BYTES) {
openSnackbar({
type: "error",
text: "Ảnh đại diện không được vượt quá 5 MB.",
});
return;
}
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
setAvatarFile(file);
setAvatarPreviewUrl(reader.result);
setAvatarPosition({ x: 50, y: 50 });
}
};
reader.onerror = () => {
openSnackbar({
type: "error",
text: "Không thể đọc ảnh đã chọn. Vui lòng thử ảnh khác.",
});
};
reader.readAsDataURL(file);
};
const openAvatarEditor = () => {
setAvatarFile(null);
setAvatarPreviewUrl(user?.avatarUrl || null);
setAvatarPosition({
x: user?.avatarPositionX ?? 50,
y: user?.avatarPositionY ?? 50,
});
setIsAvatarEditorOpen(true);
};
const closeAvatarEditor = () => {
if (saveAvatarMutation.isPending || deleteAvatarMutation.isPending) return;
setAvatarFile(null);
setAvatarPreviewUrl(null);
setIsAvatarEditorOpen(false);
};
const onPasswordSubmit = (values: PasswordFormValues) => { const onPasswordSubmit = (values: PasswordFormValues) => {
updatePasswordMutation.mutate({ updatePasswordMutation.mutate({
oldPassword: values.oldPassword, oldPassword: values.oldPassword,
...@@ -205,7 +348,26 @@ const ProfilePage: React.FC = () => { ...@@ -205,7 +348,26 @@ const ProfilePage: React.FC = () => {
<div className="flex flex-col gap-6 mt-4 pb-20"> <div className="flex flex-col gap-6 mt-4 pb-20">
{/* User Card Header */} {/* User Card Header */}
<Card className="flex items-center gap-4 py-4 bg-clay-surface border border-white/50"> <Card className="flex items-center gap-4 py-4 bg-clay-surface border border-white/50">
<Avatar size="lg" src={user?.avatarUrl || ""} className="border-clay-primary shadow-clay-hover" /> <button
type="button"
aria-label="Chỉnh ảnh đại diện"
onClick={openAvatarEditor}
className="group relative rounded-full transition-all duration-200 ease-in-out focus:outline-none focus:ring-4 focus:ring-clay-primary/25 active:scale-95"
>
<Avatar
size="lg"
src={currentAvatarUrl || ""}
positionX={user?.avatarPositionX}
positionY={user?.avatarPositionY}
className="border-clay-primary shadow-clay-hover"
/>
<span className="absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-clay-primary text-white shadow-clay-raised transition-all duration-200 ease-in-out group-active:shadow-clay-pressed">
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 4 20 9.5 9 20H4v-5Z" />
<path d="m12.5 6 5.5 5.5" />
</svg>
</span>
</button>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h2 className="clay-title-h2 truncate text-clay-primary">{user?.fullName || "Chưa thiết lập"}</h2> <h2 className="clay-title-h2 truncate text-clay-primary">{user?.fullName || "Chưa thiết lập"}</h2>
<p className="clay-caption truncate">{user?.email}</p> <p className="clay-caption truncate">{user?.email}</p>
...@@ -240,13 +402,7 @@ const ProfilePage: React.FC = () => { ...@@ -240,13 +402,7 @@ const ProfilePage: React.FC = () => {
disabled={updateProfileMutation.isPending} disabled={updateProfileMutation.isPending}
/> />
<Input <input type="hidden" {...registerProfile("avatarUrl")} />
label="Đường dẫn ảnh đại diện (Avatar URL)"
placeholder="Nhập liên kết ảnh..."
error={profileErrors.avatarUrl?.message}
{...registerProfile("avatarUrl")}
disabled={updateProfileMutation.isPending}
/>
<Button <Button
variant="primary" variant="primary"
...@@ -395,6 +551,19 @@ const ProfilePage: React.FC = () => { ...@@ -395,6 +551,19 @@ const ProfilePage: React.FC = () => {
</Button> </Button>
</div> </div>
</div> </div>
<AvatarEditorModal
isOpen={isAvatarEditorOpen}
imageUrl={avatarPreviewUrl}
position={avatarPosition}
hasStoredAvatar={Boolean(user?.avatarUrl)}
isSaving={saveAvatarMutation.isPending || deleteAvatarMutation.isPending}
onClose={closeAvatarEditor}
onFileChange={handleAvatarChange}
onPositionChange={setAvatarPosition}
onSave={() => saveAvatarMutation.mutate()}
onDelete={() => deleteAvatarMutation.mutate()}
/>
</Page> </Page>
); );
}; };
......
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