@greatapps/greatauth-ui 0.3.6 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +28 -1
- package/dist/index.js +487 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/components/entity-avatar.tsx +72 -0
- package/src/components/image-crop-upload.tsx +253 -0
- package/src/components/index.ts +4 -0
- package/src/index.ts +4 -0
- package/src/lib/image-utils.ts +82 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
const COLORS = [
|
|
7
|
+
"bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
|
8
|
+
"bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
|
|
9
|
+
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
|
10
|
+
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
|
11
|
+
"bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
|
|
12
|
+
"bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
|
13
|
+
"bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300",
|
|
14
|
+
"bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const SIZE_MAP = {
|
|
18
|
+
xs: "h-5 w-5 text-[9px]",
|
|
19
|
+
sm: "h-6 w-6 text-[10px]",
|
|
20
|
+
md: "h-8 w-8 text-xs",
|
|
21
|
+
lg: "h-10 w-10 text-sm",
|
|
22
|
+
xl: "h-16 w-16 text-lg",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
function hashCode(str: string): number {
|
|
26
|
+
let hash = 0;
|
|
27
|
+
for (let i = 0; i < str.length; i++) {
|
|
28
|
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
29
|
+
}
|
|
30
|
+
return Math.abs(hash);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getInitials(name: string): string {
|
|
34
|
+
const parts = name.trim().split(/\s+/);
|
|
35
|
+
if (parts.length >= 2) {
|
|
36
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
37
|
+
}
|
|
38
|
+
return (name[0] || "?").toUpperCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EntityAvatarProps {
|
|
42
|
+
photo?: string | null;
|
|
43
|
+
name: string | null;
|
|
44
|
+
size?: keyof typeof SIZE_MAP;
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function EntityAvatar({
|
|
49
|
+
photo,
|
|
50
|
+
name,
|
|
51
|
+
size = "md",
|
|
52
|
+
className,
|
|
53
|
+
}: EntityAvatarProps) {
|
|
54
|
+
const displayName = name || "?";
|
|
55
|
+
const color = COLORS[hashCode(displayName) % COLORS.length];
|
|
56
|
+
const initials = getInitials(displayName);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Avatar className={cn(SIZE_MAP[size], className)}>
|
|
60
|
+
{photo && (
|
|
61
|
+
<AvatarImage
|
|
62
|
+
src={photo}
|
|
63
|
+
alt={displayName}
|
|
64
|
+
className="object-cover"
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
<AvatarFallback className={cn("font-medium", color)}>
|
|
68
|
+
{initials}
|
|
69
|
+
</AvatarFallback>
|
|
70
|
+
</Avatar>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
|
+
import Cropper from "react-easy-crop";
|
|
5
|
+
import type { Area } from "react-easy-crop";
|
|
6
|
+
import { Button } from "./ui/button";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "./ui/dialog";
|
|
14
|
+
import { Slider } from "./ui/slider";
|
|
15
|
+
import { toast } from "sonner";
|
|
16
|
+
import { Camera, Trash2 } from "lucide-react";
|
|
17
|
+
import { EntityAvatar } from "./entity-avatar";
|
|
18
|
+
import {
|
|
19
|
+
getCroppedImage,
|
|
20
|
+
validateImageFile,
|
|
21
|
+
fileToDataUrl,
|
|
22
|
+
} from "../lib/image-utils";
|
|
23
|
+
|
|
24
|
+
const STORAGE_URL_MAP: Record<string, string> = {
|
|
25
|
+
users: "https://gauth-r2-storage.greatlabs.workers.dev",
|
|
26
|
+
agents: "https://gagents-r2-storage.greatlabs.workers.dev",
|
|
27
|
+
contacts: "https://gchat-r2-storage.greatlabs.workers.dev",
|
|
28
|
+
professionals: "https://gclinic-r2-storage.greatlabs.workers.dev",
|
|
29
|
+
patients: "https://gclinic-r2-storage.greatlabs.workers.dev",
|
|
30
|
+
clinics: "https://gclinic-r2-storage.greatlabs.workers.dev",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const BUCKET_MAP: Record<string, string> = {
|
|
34
|
+
users: "gauth-r1-storage",
|
|
35
|
+
agents: "gagents-r1-storage",
|
|
36
|
+
contacts: "gchat-r1-storage",
|
|
37
|
+
professionals: "gclinic-r1-storage",
|
|
38
|
+
patients: "gclinic-r1-storage",
|
|
39
|
+
clinics: "gclinic-r1-storage",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const DEFAULT_STORAGE_URL = "https://gauth-r2-storage.greatlabs.workers.dev";
|
|
43
|
+
const DEFAULT_BUCKET = "gauth-r1-storage";
|
|
44
|
+
|
|
45
|
+
export interface ImageCropUploadProps {
|
|
46
|
+
value?: string | null;
|
|
47
|
+
onChange: (url: string) => void;
|
|
48
|
+
onRemove?: () => void;
|
|
49
|
+
entityType: string;
|
|
50
|
+
entityId?: number | string;
|
|
51
|
+
idAccount: number;
|
|
52
|
+
name?: string | null;
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ImageCropUpload({
|
|
57
|
+
value,
|
|
58
|
+
onChange,
|
|
59
|
+
onRemove,
|
|
60
|
+
entityType,
|
|
61
|
+
entityId,
|
|
62
|
+
idAccount,
|
|
63
|
+
name,
|
|
64
|
+
disabled = false,
|
|
65
|
+
}: ImageCropUploadProps) {
|
|
66
|
+
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
|
67
|
+
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
|
68
|
+
const [zoom, setZoom] = useState(1);
|
|
69
|
+
const [croppedArea, setCroppedArea] = useState<Area | null>(null);
|
|
70
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
71
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
72
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
73
|
+
|
|
74
|
+
const onCropComplete = useCallback((_: Area, croppedAreaPixels: Area) => {
|
|
75
|
+
setCroppedArea(croppedAreaPixels);
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const handleFileSelect = useCallback(
|
|
79
|
+
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
80
|
+
const file = e.target.files?.[0];
|
|
81
|
+
if (!file) return;
|
|
82
|
+
|
|
83
|
+
const error = validateImageFile(file);
|
|
84
|
+
if (error) {
|
|
85
|
+
toast.error(error);
|
|
86
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const dataUrl = await fileToDataUrl(file);
|
|
91
|
+
setImageSrc(dataUrl);
|
|
92
|
+
setCrop({ x: 0, y: 0 });
|
|
93
|
+
setZoom(1);
|
|
94
|
+
setIsOpen(true);
|
|
95
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
96
|
+
},
|
|
97
|
+
[],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const handleConfirmCrop = useCallback(async () => {
|
|
101
|
+
if (!imageSrc || !croppedArea) return;
|
|
102
|
+
|
|
103
|
+
setIsUploading(true);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const base64 = await getCroppedImage(imageSrc, croppedArea);
|
|
107
|
+
const path = `${entityType}/${idAccount}/${entityId || "temp"}.webp`;
|
|
108
|
+
const storageUrl = STORAGE_URL_MAP[entityType] || DEFAULT_STORAGE_URL;
|
|
109
|
+
const bucket = BUCKET_MAP[entityType] || DEFAULT_BUCKET;
|
|
110
|
+
|
|
111
|
+
const res = await fetch(storageUrl, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify({ file: base64, bucket, path }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const data = await res.json();
|
|
118
|
+
|
|
119
|
+
if (data.status !== 1) {
|
|
120
|
+
throw new Error(data.message || "Erro ao enviar imagem");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const publicUrl = `${storageUrl}/${path}?t=${Date.now()}`;
|
|
124
|
+
onChange(publicUrl);
|
|
125
|
+
toast.success("Imagem atualizada");
|
|
126
|
+
} catch (err) {
|
|
127
|
+
toast.error(
|
|
128
|
+
err instanceof Error ? err.message : "Erro ao enviar imagem",
|
|
129
|
+
);
|
|
130
|
+
} finally {
|
|
131
|
+
setIsUploading(false);
|
|
132
|
+
setIsOpen(false);
|
|
133
|
+
setImageSrc(null);
|
|
134
|
+
}
|
|
135
|
+
}, [imageSrc, croppedArea, entityType, entityId, idAccount, onChange]);
|
|
136
|
+
|
|
137
|
+
const handleRemove = useCallback(async () => {
|
|
138
|
+
if (!value) return;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const path = `${entityType}/${idAccount}/${entityId || "temp"}.webp`;
|
|
142
|
+
const storageUrl = STORAGE_URL_MAP[entityType] || DEFAULT_STORAGE_URL;
|
|
143
|
+
const bucket = BUCKET_MAP[entityType] || DEFAULT_BUCKET;
|
|
144
|
+
|
|
145
|
+
await fetch(storageUrl, {
|
|
146
|
+
method: "DELETE",
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
body: JSON.stringify({ bucket, path }),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
onRemove?.();
|
|
152
|
+
toast.success("Imagem removida");
|
|
153
|
+
} catch {
|
|
154
|
+
toast.error("Erro ao remover imagem");
|
|
155
|
+
}
|
|
156
|
+
}, [value, entityType, entityId, idAccount, onRemove]);
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div className="flex flex-col items-center gap-2">
|
|
160
|
+
<div className="relative group">
|
|
161
|
+
<EntityAvatar photo={value} name={name ?? null} size="xl" />
|
|
162
|
+
{!disabled && (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={() => inputRef.current?.click()}
|
|
166
|
+
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
|
167
|
+
>
|
|
168
|
+
<Camera className="h-5 w-5 text-white" />
|
|
169
|
+
</button>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<input
|
|
174
|
+
ref={inputRef}
|
|
175
|
+
type="file"
|
|
176
|
+
accept="image/png,image/jpeg,image/webp"
|
|
177
|
+
onChange={handleFileSelect}
|
|
178
|
+
className="hidden"
|
|
179
|
+
disabled={disabled}
|
|
180
|
+
/>
|
|
181
|
+
|
|
182
|
+
{!disabled && value && (
|
|
183
|
+
<Button
|
|
184
|
+
type="button"
|
|
185
|
+
variant="ghost"
|
|
186
|
+
size="sm"
|
|
187
|
+
onClick={handleRemove}
|
|
188
|
+
className="text-destructive hover:text-destructive"
|
|
189
|
+
>
|
|
190
|
+
<Trash2 className="h-4 w-4 mr-1" />
|
|
191
|
+
Remover
|
|
192
|
+
</Button>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
196
|
+
<DialogContent className="sm:max-w-md">
|
|
197
|
+
<DialogHeader>
|
|
198
|
+
<DialogTitle>Recortar imagem</DialogTitle>
|
|
199
|
+
</DialogHeader>
|
|
200
|
+
|
|
201
|
+
<div className="relative w-full h-72 bg-muted rounded-md overflow-hidden">
|
|
202
|
+
{imageSrc && (
|
|
203
|
+
<Cropper
|
|
204
|
+
image={imageSrc}
|
|
205
|
+
crop={crop}
|
|
206
|
+
zoom={zoom}
|
|
207
|
+
aspect={1}
|
|
208
|
+
onCropChange={setCrop}
|
|
209
|
+
onZoomChange={setZoom}
|
|
210
|
+
onCropComplete={onCropComplete}
|
|
211
|
+
cropShape="round"
|
|
212
|
+
showGrid={false}
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div className="flex items-center gap-3 px-1">
|
|
218
|
+
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
|
219
|
+
Zoom
|
|
220
|
+
</span>
|
|
221
|
+
<Slider
|
|
222
|
+
value={[zoom]}
|
|
223
|
+
onValueChange={(v: number[]) => setZoom(v[0])}
|
|
224
|
+
min={1}
|
|
225
|
+
max={3}
|
|
226
|
+
step={0.1}
|
|
227
|
+
className="flex-1"
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<DialogFooter>
|
|
232
|
+
<Button
|
|
233
|
+
variant="outline"
|
|
234
|
+
onClick={() => {
|
|
235
|
+
setIsOpen(false);
|
|
236
|
+
setImageSrc(null);
|
|
237
|
+
}}
|
|
238
|
+
disabled={isUploading}
|
|
239
|
+
>
|
|
240
|
+
Cancelar
|
|
241
|
+
</Button>
|
|
242
|
+
<Button
|
|
243
|
+
onClick={handleConfirmCrop}
|
|
244
|
+
disabled={isUploading || !croppedArea}
|
|
245
|
+
>
|
|
246
|
+
{isUploading ? "Enviando..." : "Confirmar"}
|
|
247
|
+
</Button>
|
|
248
|
+
</DialogFooter>
|
|
249
|
+
</DialogContent>
|
|
250
|
+
</Dialog>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -3,3 +3,7 @@ export { AppSidebar } from "./app-sidebar";
|
|
|
3
3
|
export { AppHeader } from "./app-header";
|
|
4
4
|
export { LoginForm } from "./login-form";
|
|
5
5
|
export { ThemeToggle } from "./theme-toggle";
|
|
6
|
+
export { EntityAvatar } from "./entity-avatar";
|
|
7
|
+
export type { EntityAvatarProps } from "./entity-avatar";
|
|
8
|
+
export { ImageCropUpload } from "./image-crop-upload";
|
|
9
|
+
export type { ImageCropUploadProps } from "./image-crop-upload";
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,10 @@ export { AppSidebar } from "./components";
|
|
|
15
15
|
export { AppHeader } from "./components";
|
|
16
16
|
export { LoginForm } from "./components";
|
|
17
17
|
export { ThemeToggle } from "./components";
|
|
18
|
+
export { EntityAvatar } from "./components";
|
|
19
|
+
export type { EntityAvatarProps } from "./components";
|
|
20
|
+
export { ImageCropUpload } from "./components";
|
|
21
|
+
export type { ImageCropUploadProps } from "./components";
|
|
18
22
|
|
|
19
23
|
// UI Primitives (consuming apps may need)
|
|
20
24
|
export { SidebarProvider, SidebarInset, SidebarTrigger, useSidebar } from "./components/ui/sidebar";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Area } from "react-easy-crop";
|
|
2
|
+
|
|
3
|
+
export async function getCroppedImage(
|
|
4
|
+
imageSrc: string,
|
|
5
|
+
cropArea: Area,
|
|
6
|
+
outputSize = 400,
|
|
7
|
+
quality = 0.85,
|
|
8
|
+
): Promise<string> {
|
|
9
|
+
const image = await loadImage(imageSrc);
|
|
10
|
+
const canvas = document.createElement("canvas");
|
|
11
|
+
canvas.width = outputSize;
|
|
12
|
+
canvas.height = outputSize;
|
|
13
|
+
const ctx = canvas.getContext("2d");
|
|
14
|
+
|
|
15
|
+
if (!ctx) throw new Error("Canvas context not available");
|
|
16
|
+
|
|
17
|
+
ctx.drawImage(
|
|
18
|
+
image,
|
|
19
|
+
cropArea.x,
|
|
20
|
+
cropArea.y,
|
|
21
|
+
cropArea.width,
|
|
22
|
+
cropArea.height,
|
|
23
|
+
0,
|
|
24
|
+
0,
|
|
25
|
+
outputSize,
|
|
26
|
+
outputSize,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const blob = await new Promise<Blob>((resolve, reject) => {
|
|
30
|
+
canvas.toBlob(
|
|
31
|
+
(b) => (b ? resolve(b) : reject(new Error("Failed to create blob"))),
|
|
32
|
+
"image/webp",
|
|
33
|
+
quality,
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return blobToBase64(blob);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const img = new Image();
|
|
43
|
+
img.crossOrigin = "anonymous";
|
|
44
|
+
img.onload = () => resolve(img);
|
|
45
|
+
img.onerror = reject;
|
|
46
|
+
img.src = src;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function blobToBase64(blob: Blob): Promise<string> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const reader = new FileReader();
|
|
53
|
+
reader.onload = () => {
|
|
54
|
+
const result = reader.result as string;
|
|
55
|
+
resolve(result.split(",")[1]);
|
|
56
|
+
};
|
|
57
|
+
reader.onerror = reject;
|
|
58
|
+
reader.readAsDataURL(blob);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
|
|
63
|
+
const MAX_SIZE_BYTES = 5 * 1024 * 1024;
|
|
64
|
+
|
|
65
|
+
export function validateImageFile(file: File): string | null {
|
|
66
|
+
if (!ACCEPTED_TYPES.includes(file.type)) {
|
|
67
|
+
return "Formato não suportado. Use PNG, JPG ou WEBP";
|
|
68
|
+
}
|
|
69
|
+
if (file.size > MAX_SIZE_BYTES) {
|
|
70
|
+
return "Imagem muito grande. Máximo: 5MB";
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function fileToDataUrl(file: File): Promise<string> {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const reader = new FileReader();
|
|
78
|
+
reader.onload = () => resolve(reader.result as string);
|
|
79
|
+
reader.onerror = reject;
|
|
80
|
+
reader.readAsDataURL(file);
|
|
81
|
+
});
|
|
82
|
+
}
|