@greatapps/greatauth-ui 0.3.6 → 0.3.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greatapps/greatauth-ui",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -39,6 +39,7 @@
39
39
  "next-themes": "*",
40
40
  "radix-ui": "^1.4",
41
41
  "cmdk": "^1",
42
+ "react-easy-crop": "^5",
42
43
  "sonner": "^2.0.7",
43
44
  "vaul": "^1.1.2",
44
45
  "input-otp": "^1.4.2"
@@ -49,16 +50,19 @@
49
50
  "class-variance-authority": "^0.7"
50
51
  },
51
52
  "devDependencies": {
52
- "tsup": "latest",
53
- "typescript": "^5",
54
- "react": "^19",
55
- "react-dom": "^19",
53
+ "@tanstack/react-query": "^5.90.21",
54
+ "@tanstack/react-table": "^8.21.3",
56
55
  "@types/react": "latest",
57
56
  "@types/react-dom": "latest",
58
- "next": "latest",
59
57
  "better-auth": "latest",
58
+ "cmdk": "^1",
59
+ "date-fns": "^4.1.0",
60
+ "next": "latest",
60
61
  "next-themes": "latest",
61
62
  "radix-ui": "^1.4",
62
- "cmdk": "^1"
63
+ "react": "^19",
64
+ "react-dom": "^19",
65
+ "tsup": "latest",
66
+ "typescript": "^5"
63
67
  }
64
68
  }
@@ -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";
@@ -0,0 +1,185 @@
1
+ "use client";
2
+
3
+ import {
4
+ type ColumnDef,
5
+ type SortingState,
6
+ flexRender,
7
+ getCoreRowModel,
8
+ getSortedRowModel,
9
+ useReactTable,
10
+ } from "@tanstack/react-table";
11
+ import { useState } from "react";
12
+ import {
13
+ Table,
14
+ TableBody,
15
+ TableCell,
16
+ TableHead,
17
+ TableHeader,
18
+ TableRow,
19
+ } from "../ui/table";
20
+ import { Button } from "../ui/button";
21
+ import { Skeleton } from "../ui/skeleton";
22
+ import { cn } from "../../lib/utils";
23
+ import { ChevronLeft, ChevronRight, ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
24
+
25
+ export interface DataTableProps<TData, TValue> {
26
+ columns: ColumnDef<TData, TValue>[];
27
+ data: TData[];
28
+ isLoading?: boolean;
29
+ emptyMessage?: string;
30
+ total?: number;
31
+ page?: number;
32
+ onPageChange?: (page: number) => void;
33
+ pageSize?: number;
34
+ onRowClick?: (row: TData) => void;
35
+ selectedRowId?: string | number | null;
36
+ getRowId?: (row: TData) => string | number;
37
+ compact?: boolean;
38
+ }
39
+
40
+ export function DataTable<TData, TValue>({
41
+ columns,
42
+ data,
43
+ isLoading,
44
+ emptyMessage = "Nenhum registro encontrado",
45
+ total,
46
+ page = 1,
47
+ onPageChange,
48
+ pageSize = 15,
49
+ onRowClick,
50
+ selectedRowId,
51
+ getRowId,
52
+ compact,
53
+ }: DataTableProps<TData, TValue>) {
54
+ const [sorting, setSorting] = useState<SortingState>([]);
55
+
56
+ const table = useReactTable({
57
+ data,
58
+ columns,
59
+ getCoreRowModel: getCoreRowModel(),
60
+ getSortedRowModel: getSortedRowModel(),
61
+ onSortingChange: setSorting,
62
+ state: { sorting },
63
+ });
64
+
65
+ const totalPages = total ? Math.ceil(total / pageSize) : 1;
66
+ const showPagination = onPageChange && total && total > pageSize;
67
+
68
+ return (
69
+ <div className="space-y-4">
70
+ <div className="rounded-md border">
71
+ <Table>
72
+ <TableHeader>
73
+ {table.getHeaderGroups().map((headerGroup) => (
74
+ <TableRow key={headerGroup.id}>
75
+ {headerGroup.headers.map((header) => (
76
+ <TableHead
77
+ key={header.id}
78
+ className={cn(compact && "py-1.5 text-xs")}
79
+ style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
80
+ >
81
+ {header.isPlaceholder ? null : header.column.getCanSort() ? (
82
+ <button
83
+ type="button"
84
+ className="flex items-center gap-1 hover:text-foreground -ml-1 px-1 py-0.5 rounded cursor-pointer select-none"
85
+ onClick={header.column.getToggleSortingHandler()}
86
+ >
87
+ {flexRender(header.column.columnDef.header, header.getContext())}
88
+ {{
89
+ asc: <ArrowUp className="h-3.5 w-3.5" />,
90
+ desc: <ArrowDown className="h-3.5 w-3.5" />,
91
+ }[header.column.getIsSorted() as string] ?? (
92
+ <ArrowUpDown className="h-3.5 w-3.5 text-muted-foreground/50" />
93
+ )}
94
+ </button>
95
+ ) : (
96
+ flexRender(header.column.columnDef.header, header.getContext())
97
+ )}
98
+ </TableHead>
99
+ ))}
100
+ </TableRow>
101
+ ))}
102
+ </TableHeader>
103
+ <TableBody>
104
+ {isLoading ? (
105
+ Array.from({ length: compact ? 3 : 5 }).map((_, i) => (
106
+ <TableRow key={i}>
107
+ {columns.map((_, j) => (
108
+ <TableCell key={j} className={cn(compact && "py-1.5")}>
109
+ <Skeleton className="h-4 w-full max-w-[120px]" />
110
+ </TableCell>
111
+ ))}
112
+ </TableRow>
113
+ ))
114
+ ) : table.getRowModel().rows.length === 0 ? (
115
+ <TableRow>
116
+ <TableCell
117
+ colSpan={columns.length}
118
+ className={cn(
119
+ "text-center text-muted-foreground",
120
+ compact ? "py-4" : "py-8",
121
+ )}
122
+ >
123
+ {emptyMessage}
124
+ </TableCell>
125
+ </TableRow>
126
+ ) : (
127
+ table.getRowModel().rows.map((row) => {
128
+ const rowId = getRowId ? getRowId(row.original) : undefined;
129
+ const isSelected = selectedRowId != null && rowId != null && rowId === selectedRowId;
130
+
131
+ return (
132
+ <TableRow
133
+ key={row.id}
134
+ className={cn(
135
+ onRowClick && "cursor-pointer",
136
+ isSelected && "bg-accent",
137
+ )}
138
+ onClick={() => onRowClick?.(row.original)}
139
+ >
140
+ {row.getVisibleCells().map((cell) => (
141
+ <TableCell key={cell.id} className={cn(compact && "py-1.5")}>
142
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
143
+ </TableCell>
144
+ ))}
145
+ </TableRow>
146
+ );
147
+ })
148
+ )}
149
+ </TableBody>
150
+ </Table>
151
+ </div>
152
+
153
+ {showPagination && (
154
+ <div className="flex items-center justify-between px-2">
155
+ <p className="text-sm text-muted-foreground">
156
+ {total} registro{total !== 1 ? "s" : ""}
157
+ </p>
158
+ <div className="flex items-center gap-2">
159
+ <Button
160
+ variant="outline"
161
+ size="sm"
162
+ onClick={() => onPageChange(page - 1)}
163
+ disabled={page <= 1}
164
+ >
165
+ <ChevronLeft className="h-4 w-4" />
166
+ Anterior
167
+ </Button>
168
+ <span className="text-sm text-muted-foreground">
169
+ {page} de {totalPages}
170
+ </span>
171
+ <Button
172
+ variant="outline"
173
+ size="sm"
174
+ onClick={() => onPageChange(page + 1)}
175
+ disabled={page >= totalPages}
176
+ >
177
+ Próximo
178
+ <ChevronRight className="h-4 w-4" />
179
+ </Button>
180
+ </div>
181
+ </div>
182
+ )}
183
+ </div>
184
+ );
185
+ }