@greatapps/greatauth-ui 0.3.5 → 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.
@@ -38,14 +38,14 @@ interface AppSidebarProps {
38
38
  config: AppShellConfig;
39
39
  }
40
40
 
41
- function getUserInitials(name: string, email: string, showLastName?: boolean): string {
41
+ function getUserInitials(name: string, email: string): string {
42
42
  if (!name) return email?.[0]?.toUpperCase() || "?";
43
43
 
44
44
  const parts = name.trim().split(/\s+/);
45
- if (showLastName && parts.length > 1) {
45
+ if (parts.length > 1) {
46
46
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
47
47
  }
48
- return (parts[0][0] + (email?.[0] || "")).toUpperCase();
48
+ return parts[0][0].toUpperCase();
49
49
  }
50
50
 
51
51
  function SimpleMenuItem({ item, pathname }: { item: MenuItem; pathname: string }) {
@@ -103,7 +103,7 @@ export function AppSidebar({ config }: AppSidebarProps) {
103
103
  const userName = session?.user?.name || "";
104
104
  const userEmail = session?.user?.email || "";
105
105
  const userImage = session?.user?.image || "";
106
- const initials = getUserInitials(userName, userEmail, config.userDisplayFields?.showLastName);
106
+ const initials = getUserInitials(userName, userEmail);
107
107
 
108
108
  return (
109
109
  <Sidebar>
@@ -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
+ }
@@ -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
+ }