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
- **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.
- **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
......
......@@ -2,6 +2,8 @@ import React from "react";
export interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> {
size?: "sm" | "md" | "lg";
positionX?: number;
positionY?: number;
}
export const Avatar: React.FC<AvatarProps> = ({
......@@ -9,6 +11,9 @@ export const Avatar: React.FC<AvatarProps> = ({
className = "",
src,
alt = "User Avatar",
positionX = 50,
positionY = 50,
style,
...props
}) => {
const sizeStyles = {
......@@ -31,6 +36,10 @@ export const Avatar: React.FC<AvatarProps> = ({
src={src || defaultAvatar}
alt={alt}
className="w-full h-full object-cover rounded-full"
style={{
...style,
objectPosition: `${positionX}% ${positionY}%`,
}}
{...props}
/>
</div>
......
......@@ -18,7 +18,13 @@ function HomePage() {
<div className="flex-1 flex flex-col items-center justify-center gap-6 px-4">
{/* Logo/Avatar Area */}
<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">
<AIAssistantIcon size={20} />
</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";
import { Badge } from "@/components/ui/Badge";
import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service";
import { uploadService } from "@/services/upload.service";
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
const profileSchema = z.object({
......@@ -51,6 +59,13 @@ const ProfilePage: React.FC = () => {
const queryClient = useQueryClient();
const { user, setUser, clearAuth } = useAuthStore();
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 = [
{ key: "profile", label: "Thông Tin" },
......@@ -64,6 +79,8 @@ const ProfilePage: React.FC = () => {
handleSubmit: handleProfileSubmit,
formState: { errors: profileErrors },
reset: resetProfileForm,
setValue: setProfileValue,
watch: watchProfile,
} = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
......@@ -73,6 +90,8 @@ const ProfilePage: React.FC = () => {
},
});
const currentAvatarUrl = watchProfile("avatarUrl");
const {
register: registerPassword,
handleSubmit: handlePasswordSubmit,
......@@ -92,30 +111,104 @@ const ProfilePage: React.FC = () => {
// Mutations
const updateProfileMutation = useMutation({
mutationFn: authService.updateProfile,
onSuccess: async () => {
onSuccess: (response) => {
openSnackbar({
type: "success",
text: "Cập nhật thông tin cá nhân thành công! 🎉",
});
// Refetch user data
try {
const meRes = await authService.getMe();
if (meRes.success && meRes.data) {
setUser(meRes.data);
resetProfileForm({
fullName: meRes.data.fullName || "",
phoneNumber: meRes.data.phoneNumber || "",
avatarUrl: meRes.data.avatarUrl || "",
});
}
} catch (err) {
console.error("Refetch user profile failed", err);
if (response.success && response.data) {
setUser(response.data);
resetProfileForm({
fullName: response.data.fullName || "",
phoneNumber: response.data.phoneNumber || "",
avatarUrl: response.data.avatarUrl || "",
});
}
},
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({
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 = () => {
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) => {
updatePasswordMutation.mutate({
oldPassword: values.oldPassword,
......@@ -205,7 +348,26 @@ const ProfilePage: React.FC = () => {
<div className="flex flex-col gap-6 mt-4 pb-20">
{/* User Card Header */}
<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">
<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>
......@@ -240,13 +402,7 @@ const ProfilePage: React.FC = () => {
disabled={updateProfileMutation.isPending}
/>
<Input
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}
/>
<input type="hidden" {...registerProfile("avatarUrl")} />
<Button
variant="primary"
......@@ -395,6 +551,19 @@ const ProfilePage: React.FC = () => {
</Button>
</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>
);
};
......
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 = {
async register(data: any): Promise<ApiResponse> {
......@@ -27,7 +27,7 @@ export const authService = {
return response.data;
},
async updateProfile(data: any): Promise<ApiResponse> {
async updateProfile(data: UpdateProfileRequest): Promise<ApiResponse<User>> {
const response = await apiClient.put("/auth/profile", 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 {
export interface User {
id: string;
email: string;
email: string | null;
fullName: string | null;
avatarUrl?: string | null;
avatarPositionX: number;
avatarPositionY: number;
phoneNumber?: string | null;
roleId: string;
role: Role;
......@@ -26,6 +28,14 @@ export interface LoginRequest {
password: string;
}
export interface UpdateProfileRequest {
fullName?: string;
avatarUrl?: string | null;
avatarPositionX?: number;
avatarPositionY?: number;
phoneNumber?: string;
}
export interface Session {
id: 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