mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
feat(profile): implement image editing functionality with cropping and zooming
- Added a new ImageEditModal component for cropping and adjusting images. - Integrated image editing capabilities into the ProfilePicture component, allowing users to edit their profile images. - Updated translations for image editing features in multiple languages. - Introduced a Skeleton component for loading states during image processing. - Enhanced file upload handling with chunked uploads for better performance.
This commit is contained in:
@@ -1721,5 +1721,11 @@
|
||||
"passwordRequired": "كلمة المرور مطلوبة",
|
||||
"nameRequired": "الاسم مطلوب",
|
||||
"required": "هذا الحقل مطلوب"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "تعديل الصورة",
|
||||
"rotate": "تدوير",
|
||||
"zoom": "تكبير/تصغير",
|
||||
"cropInstructions": "اسحب لإعادة تحديد الموضع، غير حجم الزوايا لضبط منطقة القص"
|
||||
}
|
||||
}
|
@@ -1719,5 +1719,11 @@
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
"nameRequired": "Name ist erforderlich",
|
||||
"required": "Dieses Feld ist erforderlich"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "Bild bearbeiten",
|
||||
"rotate": "Drehen",
|
||||
"zoom": "Zoom",
|
||||
"cropInstructions": "Ziehen Sie, um die Position zu ändern, ändern Sie die Größe der Ecken, um die Zuschneidefläche anzupassen"
|
||||
}
|
||||
}
|
@@ -400,6 +400,12 @@
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "Edit Image",
|
||||
"rotate": "Rotate",
|
||||
"zoom": "Zoom",
|
||||
"cropInstructions": "Drag to reposition, resize corners to adjust crop area"
|
||||
},
|
||||
"profile": {
|
||||
"password": {
|
||||
"title": "Change Password",
|
||||
|
@@ -1719,5 +1719,11 @@
|
||||
"passwordRequired": "Se requiere la contraseña",
|
||||
"nameRequired": "El nombre es obligatorio",
|
||||
"required": "Este campo es obligatorio"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "Editar Imagen",
|
||||
"rotate": "Rotar",
|
||||
"zoom": "Zoom",
|
||||
"cropInstructions": "Arrastra para reubicar, ajusta las esquinas para ajustar el área de recorte"
|
||||
}
|
||||
}
|
@@ -1719,5 +1719,11 @@
|
||||
"passwordRequired": "Le mot de passe est requis",
|
||||
"nameRequired": "Nome é obrigatório",
|
||||
"required": "Este campo é obrigatório"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "Modifier l'Image",
|
||||
"rotate": "Tourner",
|
||||
"zoom": "Zoom",
|
||||
"cropInstructions": "Glisser pour répositionner, redimensionner les coins pour ajuster la zone de découpe"
|
||||
}
|
||||
}
|
@@ -1719,5 +1719,11 @@
|
||||
"passwordRequired": "पासवर्ड आवश्यक है",
|
||||
"nameRequired": "नाम आवश्यक है",
|
||||
"required": "यह फ़ील्ड आवश्यक है"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "छवि संपादित करें",
|
||||
"rotate": "घुमाएं",
|
||||
"zoom": "ज़ूम",
|
||||
"cropInstructions": "छवि को पुनः स्थानांतरित करने के लिए खींचें, कोणों को समायोजित करने के लिए आकार बदलें"
|
||||
}
|
||||
}
|
@@ -1719,5 +1719,11 @@
|
||||
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
|
||||
"nameRequired": "Il nome è obbligatorio",
|
||||
"required": "Questo campo è obbligatorio"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "Modifica Immagine",
|
||||
"rotate": "Ruota",
|
||||
"zoom": "Zoom",
|
||||
"cropInstructions": "Trascina per riposizionare, ridimensiona gli angoli per adattare l'area di ritaglio"
|
||||
}
|
||||
}
|
@@ -1719,5 +1719,11 @@
|
||||
"passwordRequired": "パスワードは必須です",
|
||||
"nameRequired": "名前は必須です",
|
||||
"required": "このフィールドは必須です"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "画像を編集",
|
||||
"rotate": "回転",
|
||||
"zoom": "ズーム",
|
||||
"cropInstructions": "位置を変更するにはドラッグし、カット領域を調整するには角をリサイズしてください"
|
||||
}
|
||||
}
|
@@ -1719,5 +1719,11 @@
|
||||
"passwordRequired": "비밀번호는 필수입니다",
|
||||
"nameRequired": "이름은 필수입니다",
|
||||
"required": "이 필드는 필수입니다"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "이미지 편집",
|
||||
"rotate": "회전",
|
||||
"zoom": "확대/축소",
|
||||
"cropInstructions": "위치를 변경하려면 드래그하고, 자르기 영역을 조정하려면 모서리를 확대/축소하세요"
|
||||
}
|
||||
}
|
@@ -1719,5 +1719,11 @@
|
||||
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
|
||||
"nameRequired": "Naam is verplicht",
|
||||
"required": "Dit veld is verplicht"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "Afbeelding bewerken",
|
||||
"rotate": "Draai",
|
||||
"zoom": "Vergroot",
|
||||
"cropInstructions": "Sleep om te herpositioneren, verander de grootte van de hoeken om de uitsnijdgebied aan te passen"
|
||||
}
|
||||
}
|
@@ -1719,5 +1719,11 @@
|
||||
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
|
||||
"nameRequired": "Nazwa jest wymagana",
|
||||
"required": "To pole jest wymagane"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "Edytuj obraz",
|
||||
"rotate": "Obróć",
|
||||
"zoom": "Powiększ",
|
||||
"cropInstructions": "Przeciągnij, aby przesunąć, zmień rozmiar rogów, aby dostosować obszar przycięcia"
|
||||
}
|
||||
}
|
@@ -1719,5 +1719,11 @@
|
||||
"lastNameRequired": "O sobrenome é necessário",
|
||||
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
|
||||
"usernameSpaces": "O nome de usuário não pode conter espaços"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "Editar imagem",
|
||||
"rotate": "Girar",
|
||||
"zoom": "Ampliar",
|
||||
"cropInstructions": "Arraste para reposicionar, redimensione os cantos para ajustar a área de recorte"
|
||||
}
|
||||
}
|
@@ -1719,5 +1719,11 @@
|
||||
"passwordRequired": "Требуется пароль",
|
||||
"nameRequired": "Требуется имя",
|
||||
"required": "Это поле обязательно"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "Редактировать изображение",
|
||||
"rotate": "Повернуть",
|
||||
"zoom": "Увеличить",
|
||||
"cropInstructions": "Перетащите, чтобы переместить, измените размер углов, чтобы отрегулировать область обрезки"
|
||||
}
|
||||
}
|
@@ -1719,5 +1719,11 @@
|
||||
"passwordRequired": "Şifre gerekli",
|
||||
"nameRequired": "İsim gereklidir",
|
||||
"required": "Bu alan zorunludur"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "Resmi Düzenle",
|
||||
"rotate": "Döndür",
|
||||
"zoom": "Yakınlaştır",
|
||||
"cropInstructions": "Yerleştirmek için sürükleyin, kırpma alanını ayarlamak için köşeleri yeniden boyutlandırın"
|
||||
}
|
||||
}
|
@@ -1719,5 +1719,11 @@
|
||||
"passwordRequired": "密码为必填项",
|
||||
"nameRequired": "名称为必填项",
|
||||
"required": "此字段为必填项"
|
||||
},
|
||||
"imageEdit": {
|
||||
"title": "编辑图片",
|
||||
"rotate": "旋转",
|
||||
"zoom": "缩放",
|
||||
"cropInstructions": "拖动以重新定位,调整角落大小以调整裁剪区域"
|
||||
}
|
||||
}
|
@@ -69,6 +69,7 @@
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.59.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-qr-reader": "3.0.0-beta-1",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
4517
apps/web/pnpm-lock.yaml
generated
4517
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { IconCamera, IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { ImageEditModal } from "@/components/modals/image-edit-modal";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
@@ -14,11 +15,15 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { ProfilePictureProps } from "../types";
|
||||
|
||||
export function ProfilePicture({ userData, onImageChange, onImageRemove }: ProfilePictureProps) {
|
||||
const t = useTranslations();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
@@ -28,7 +33,42 @@ export function ProfilePicture({ userData, onImageChange, onImageRemove }: Profi
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
onImageChange(file);
|
||||
setSelectedFile(file);
|
||||
setIsEditModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageEdit = async (croppedImageFile: File) => {
|
||||
setIsLoading(true);
|
||||
setIsEditModalOpen(false);
|
||||
setSelectedFile(null);
|
||||
// Reset the file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
try {
|
||||
await onImageChange(croppedImageFile);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditModalClose = () => {
|
||||
setIsEditModalOpen(false);
|
||||
setSelectedFile(null);
|
||||
// Reset the file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageRemove = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onImageRemove();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -36,36 +76,41 @@ export function ProfilePicture({ userData, onImageChange, onImageRemove }: Profi
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-4">
|
||||
<div className="relative group">
|
||||
<Avatar className="w-25 h-25">
|
||||
<AvatarImage src={userData?.image} />
|
||||
<AvatarFallback className="absolute inset-0 rounded-full border text-4xl font-bold">
|
||||
{userData?.firstName
|
||||
? userData.firstName
|
||||
.split(" ")
|
||||
.map((firstName) => firstName[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
: ""}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{isLoading ? (
|
||||
<Skeleton className="w-25 h-25 rounded-full" />
|
||||
) : (
|
||||
<Avatar className="w-25 h-25">
|
||||
<AvatarImage src={userData?.image} />
|
||||
<AvatarFallback className="absolute inset-0 rounded-full border text-4xl font-bold">
|
||||
{userData?.firstName
|
||||
? userData.firstName
|
||||
.split(" ")
|
||||
.map((firstName) => firstName[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
: ""}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className="absolute bottom-0 right-0 bg-primary text-primary-foreground rounded-full cursor-pointer"
|
||||
variant="default"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconCamera className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!!userData?.image ? (
|
||||
<DropdownMenuItem className="text-destructive" onClick={onImageRemove}>
|
||||
<DropdownMenuItem className="text-destructive" onClick={handleImageRemove} disabled={isLoading}>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
{t("profile.picture.removePhoto")}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={handleAvatarClick}>
|
||||
<DropdownMenuItem onClick={handleAvatarClick} disabled={isLoading}>
|
||||
<IconCamera className="h-4 w-4" />
|
||||
{t("profile.picture.uploadPhoto")}
|
||||
</DropdownMenuItem>
|
||||
@@ -79,6 +124,13 @@ export function ProfilePicture({ userData, onImageChange, onImageRemove }: Profi
|
||||
<p className="text-sm text-muted-foreground">{t("profile.picture.description")}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<ImageEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={handleEditModalClose}
|
||||
onSave={handleImageEdit}
|
||||
imageFile={selectedFile}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
@@ -125,9 +125,10 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
||||
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
|
||||
const result = await ChunkedUploader.uploadFile({
|
||||
file,
|
||||
url,
|
||||
|
279
apps/web/src/components/modals/image-edit-modal.tsx
Normal file
279
apps/web/src/components/modals/image-edit-modal.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { IconCheck, IconRotateClockwise, IconX, IconZoomIn, IconZoomOut } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ReactCrop, { centerCrop, Crop, makeAspectCrop, PixelCrop } from "react-image-crop";
|
||||
|
||||
import "react-image-crop/dist/ReactCrop.css";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
|
||||
interface ImageEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (croppedImageFile: File) => void;
|
||||
imageFile: File | null;
|
||||
}
|
||||
|
||||
function centerAspectCrop(mediaWidth: number, mediaHeight: number, aspect: number) {
|
||||
return centerCrop(
|
||||
makeAspectCrop(
|
||||
{
|
||||
unit: "%",
|
||||
width: 90,
|
||||
},
|
||||
aspect,
|
||||
mediaWidth,
|
||||
mediaHeight
|
||||
),
|
||||
mediaWidth,
|
||||
mediaHeight
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageEditModal({ isOpen, onClose, onSave, imageFile }: ImageEditModalProps) {
|
||||
const t = useTranslations();
|
||||
const [crop, setCrop] = useState<Crop>();
|
||||
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
|
||||
const [scale, setScale] = useState(1);
|
||||
const [rotate, setRotate] = useState(0);
|
||||
const aspect = 1;
|
||||
const [imageSrc, setImageSrc] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isImageLoading, setIsImageLoading] = useState(false);
|
||||
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (imageFile) {
|
||||
setIsImageLoading(true);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setImageSrc(e.target?.result as string);
|
||||
setIsImageLoading(false);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setIsImageLoading(false);
|
||||
};
|
||||
reader.readAsDataURL(imageFile);
|
||||
}
|
||||
}, [imageFile]);
|
||||
|
||||
const onImageLoad = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (aspect) {
|
||||
const { width, height } = e.currentTarget;
|
||||
setCrop(centerAspectCrop(width, height, aspect));
|
||||
}
|
||||
},
|
||||
[aspect]
|
||||
);
|
||||
|
||||
const handleRotate = () => {
|
||||
setRotate((prev) => (prev + 90) % 360);
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
setScale((prev) => Math.min(prev + 0.1, 3));
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
setScale((prev) => Math.max(prev - 0.1, 0.5));
|
||||
};
|
||||
|
||||
const handleScaleChange = (value: number[]) => {
|
||||
setScale(value[0]);
|
||||
};
|
||||
|
||||
const getCroppedImage = useCallback(async (): Promise<File | null> => {
|
||||
if (!completedCrop || !imgRef.current || !previewCanvasRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = imgRef.current;
|
||||
const canvas = previewCanvasRef.current;
|
||||
const crop = completedCrop;
|
||||
|
||||
const scaleX = image.naturalWidth / image.width;
|
||||
const scaleY = image.naturalHeight / image.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pixelRatio = window.devicePixelRatio;
|
||||
canvas.width = crop.width * pixelRatio * scaleX;
|
||||
canvas.height = crop.height * pixelRatio * scaleY;
|
||||
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
|
||||
const cropX = crop.x * scaleX;
|
||||
const cropY = crop.y * scaleY;
|
||||
|
||||
const rotateRads = rotate * (Math.PI / 180);
|
||||
const centerX = image.naturalWidth / 2;
|
||||
const centerY = image.naturalHeight / 2;
|
||||
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(-cropX, -cropY);
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(rotateRads);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.translate(-centerX, -centerY);
|
||||
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
return new Promise<File>((resolve) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
const file = new File([blob], "cropped-image.png", { type: "image/png" });
|
||||
resolve(file);
|
||||
}
|
||||
},
|
||||
"image/png",
|
||||
1
|
||||
);
|
||||
});
|
||||
}, [completedCrop, scale, rotate]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const croppedImageFile = await getCroppedImage();
|
||||
if (croppedImageFile) {
|
||||
onSave(croppedImageFile);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error cropping image:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setImageSrc("");
|
||||
setCrop(undefined);
|
||||
setCompletedCrop(undefined);
|
||||
setScale(1);
|
||||
setRotate(0);
|
||||
setIsImageLoading(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-2xl h-fit overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("imageEdit.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isImageLoading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-10" />
|
||||
<Skeleton className="h-8 w-10" />
|
||||
</div>
|
||||
<div className="flex-1 max-w-xs">
|
||||
<Skeleton className="h-4 w-16 mb-2" />
|
||||
<Skeleton className="h-2 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Skeleton className="w-96 h-96" />
|
||||
</div>
|
||||
</div>
|
||||
) : imageSrc ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleRotate} disabled={isLoading}>
|
||||
<IconRotateClockwise className="h-4 w-4" />
|
||||
{t("imageEdit.rotate")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleZoomOut} disabled={isLoading || scale <= 0.5}>
|
||||
<IconZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleZoomIn} disabled={isLoading || scale >= 3}>
|
||||
<IconZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 max-w-xs">
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
{t("imageEdit.zoom")}: {Math.round(scale * 100)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[scale]}
|
||||
onValueChange={handleScaleChange}
|
||||
max={3}
|
||||
min={0.5}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(c) => setCrop(c)}
|
||||
onComplete={(c) => setCompletedCrop(c)}
|
||||
aspect={aspect}
|
||||
minWidth={100}
|
||||
minHeight={100}
|
||||
keepSelection
|
||||
className="max-w-full max-h-70%"
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt="Crop me"
|
||||
src={imageSrc}
|
||||
style={{
|
||||
transform: `scale(${scale}) rotate(${rotate}deg)`,
|
||||
maxWidth: "100%",
|
||||
maxHeight: "400px",
|
||||
}}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
</ReactCrop>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
|
||||
<IconX className="h-4 w-4" />
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading || !completedCrop}>
|
||||
<IconCheck className="h-4 w-4" />
|
||||
{isLoading ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
{/* Hidden canvas for generating cropped image */}
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
style={{
|
||||
display: "none",
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@@ -253,9 +253,10 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
||||
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
|
||||
const result = await ChunkedUploader.uploadFile({
|
||||
file,
|
||||
url,
|
||||
|
7
apps/web/src/components/ui/skeleton.tsx
Normal file
7
apps/web/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
Reference in New Issue
Block a user