@m5kdev/web-ui 0.1.0
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/LICENSE +621 -0
- package/README.md +17 -0
- package/package.json +169 -0
- package/src/animations/card.motion.ts +9 -0
- package/src/components/AvatarUpload.tsx +133 -0
- package/src/components/Button.tsx +14 -0
- package/src/components/Calendar.css +684 -0
- package/src/components/Calendar.tsx +32 -0
- package/src/components/CardsSelect.tsx +155 -0
- package/src/components/CollapsibleSidebarMenuItem.tsx +57 -0
- package/src/components/ColorPicker.tsx +56 -0
- package/src/components/CopyButton.tsx +45 -0
- package/src/components/CropDialog.tsx +154 -0
- package/src/components/DialogProvider.tsx +105 -0
- package/src/components/ErrorFallback.tsx +17 -0
- package/src/components/FileDropzone.tsx +120 -0
- package/src/components/MultiSelectDropdown.tsx +233 -0
- package/src/components/Orb.tsx +288 -0
- package/src/components/PageAlert.tsx +121 -0
- package/src/components/SelectChips.tsx +40 -0
- package/src/components/SidebarItem.tsx +26 -0
- package/src/components/Steps.tsx +340 -0
- package/src/components/TablerIconPicker.tsx +4260 -0
- package/src/components/app-header.tsx +40 -0
- package/src/components/blur-card.tsx +132 -0
- package/src/components/features-section-demo-1.tsx +127 -0
- package/src/components/features-section-demo-2.tsx +102 -0
- package/src/components/features-section-demo-3.tsx +272 -0
- package/src/components/mode-toggle.tsx +31 -0
- package/src/components/nav-main.tsx +69 -0
- package/src/components/pricing-cards.tsx +133 -0
- package/src/components/shared/ButtonCopy.tsx +50 -0
- package/src/components/team-switcher.tsx +83 -0
- package/src/components/theme-provider.tsx +74 -0
- package/src/components/typewriter.tsx +90 -0
- package/src/components/ui/alert-dialog.tsx +133 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/avatar.tsx +47 -0
- package/src/components/ui/badge.tsx +33 -0
- package/src/components/ui/bento-grid.tsx +54 -0
- package/src/components/ui/bento-grid2.tsx +66 -0
- package/src/components/ui/breadcrumb.tsx +101 -0
- package/src/components/ui/button.tsx +50 -0
- package/src/components/ui/card.tsx +55 -0
- package/src/components/ui/checkbox.tsx +26 -0
- package/src/components/ui/collapsible.tsx +9 -0
- package/src/components/ui/dialog.tsx +119 -0
- package/src/components/ui/dropdown-menu.tsx +186 -0
- package/src/components/ui/floating-navbar.tsx +78 -0
- package/src/components/ui/form.tsx +167 -0
- package/src/components/ui/image.tsx +55 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/pagination.tsx +105 -0
- package/src/components/ui/progress.tsx +23 -0
- package/src/components/ui/resizable-navbar.tsx +260 -0
- package/src/components/ui/segment-control.tsx +143 -0
- package/src/components/ui/select.tsx +153 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/sheet.tsx +121 -0
- package/src/components/ui/sidebar.tsx +736 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +23 -0
- package/src/components/ui/sonner.tsx +27 -0
- package/src/components/ui/spinner.tsx +45 -0
- package/src/components/ui/switch.tsx +27 -0
- package/src/components/ui/table.tsx +90 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/timeline.tsx +95 -0
- package/src/components/ui/toast.tsx +126 -0
- package/src/components/ui/tooltip.tsx +55 -0
- package/src/components/ui/typewriter-effect.tsx +181 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/useDialog.ts +25 -0
- package/src/icons/GoogleIcon.tsx +32 -0
- package/src/icons/LinkedInIcon.tsx +30 -0
- package/src/icons/MicrosoftIcon.tsx +21 -0
- package/src/lib/chatwoot.ts +51 -0
- package/src/lib/utils.ts +6 -0
- package/src/modules/app/components/AppLoader.tsx +9 -0
- package/src/modules/app/components/AppShell.tsx +21 -0
- package/src/modules/app/components/AppSidebar.tsx +26 -0
- package/src/modules/app/components/AppSidebarContent.tsx +73 -0
- package/src/modules/app/components/AppSidebarHeader.tsx +57 -0
- package/src/modules/app/components/AppSidebarInvites.tsx +32 -0
- package/src/modules/app/components/AppSidebarUser.tsx +128 -0
- package/src/modules/auth/components/AdminUserManagement.tsx +1136 -0
- package/src/modules/auth/components/AdminWaitlist.tsx +358 -0
- package/src/modules/auth/components/AuthLayout.tsx +13 -0
- package/src/modules/auth/components/AuthProviders.tsx +105 -0
- package/src/modules/auth/components/AuthRouter.tsx +29 -0
- package/src/modules/auth/components/ClaimAccountRoute.tsx +242 -0
- package/src/modules/auth/components/ErrorAuthRoute.tsx +121 -0
- package/src/modules/auth/components/ForgotPasswordForm.tsx +58 -0
- package/src/modules/auth/components/ForgotPasswordRoute.tsx +27 -0
- package/src/modules/auth/components/InviteFriends.tsx +273 -0
- package/src/modules/auth/components/LastUsedBadge.tsx +22 -0
- package/src/modules/auth/components/LoginForm.tsx +104 -0
- package/src/modules/auth/components/LoginRoute.tsx +31 -0
- package/src/modules/auth/components/LogoutRoute.tsx +21 -0
- package/src/modules/auth/components/OrganizationAcceptInvitationRoute.tsx +161 -0
- package/src/modules/auth/components/OrganizationMembersRoute.tsx +730 -0
- package/src/modules/auth/components/OrganizationSettingsRoute.tsx +280 -0
- package/src/modules/auth/components/OrganizationSwitcher.tsx +148 -0
- package/src/modules/auth/components/ProfileRoute.tsx +104 -0
- package/src/modules/auth/components/RangeNuqsDatePicker.tsx +365 -0
- package/src/modules/auth/components/ResetPasswordForm.tsx +103 -0
- package/src/modules/auth/components/ResetPasswordRoute.tsx +27 -0
- package/src/modules/auth/components/SignupFormRoute.tsx +189 -0
- package/src/modules/auth/components/SignupRoute.tsx +53 -0
- package/src/modules/auth/components/UserPreferences.tsx +144 -0
- package/src/modules/auth/components/WaitlistCard.tsx +78 -0
- package/src/modules/auth/components/WaitlistCodeValidation.tsx +79 -0
- package/src/modules/billing/components/BillingBetaPage.tsx +124 -0
- package/src/modules/billing/components/BillingInvoicePage.tsx +180 -0
- package/src/modules/billing/components/BillingPlanSelect.tsx +14 -0
- package/src/modules/billing/components/BillingRouter.tsx +20 -0
- package/src/modules/billing/components/BillingSinglePlanSelect.tsx +172 -0
- package/src/modules/table/components/ColumnOrderAndVisibility.tsx +127 -0
- package/src/modules/table/components/NuqsTable.tsx +396 -0
- package/src/modules/table/components/TableFiltering.tsx +520 -0
- package/src/modules/table/components/TablePagination.tsx +59 -0
- package/src/modules/table/components/table.types.ts +11 -0
- package/src/modules/table/filterTransformers.ts +323 -0
- package/src/types.ts +4 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Check, Edit } from "lucide-react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "#components/ui/card";
|
|
4
|
+
import { cn } from "#utils";
|
|
5
|
+
|
|
6
|
+
export interface CardSelectItem {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
content?: React.ReactNode;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
icon?: React.ReactNode;
|
|
13
|
+
onEdit?: (id: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CardsSelectProps {
|
|
17
|
+
items: CardSelectItem[];
|
|
18
|
+
selectedIds: string[];
|
|
19
|
+
onSelectionChange: (selectedIds: string[]) => void;
|
|
20
|
+
maxSelections?: number;
|
|
21
|
+
className?: string;
|
|
22
|
+
cardClassName?: string;
|
|
23
|
+
showCheckbox?: boolean;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
reversed?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const CardsSelect = React.forwardRef<HTMLDivElement, CardsSelectProps>(
|
|
29
|
+
(
|
|
30
|
+
{
|
|
31
|
+
items,
|
|
32
|
+
selectedIds,
|
|
33
|
+
onSelectionChange,
|
|
34
|
+
maxSelections,
|
|
35
|
+
className,
|
|
36
|
+
cardClassName,
|
|
37
|
+
showCheckbox = true,
|
|
38
|
+
disabled = false,
|
|
39
|
+
reversed = false,
|
|
40
|
+
...props
|
|
41
|
+
},
|
|
42
|
+
ref
|
|
43
|
+
) => {
|
|
44
|
+
const handleCardClick = (itemId: string) => {
|
|
45
|
+
if (disabled) return;
|
|
46
|
+
|
|
47
|
+
const item = items.find((item) => item.id === itemId);
|
|
48
|
+
if (item?.disabled) return;
|
|
49
|
+
|
|
50
|
+
const isSelected = selectedIds.includes(itemId);
|
|
51
|
+
|
|
52
|
+
if (isSelected) {
|
|
53
|
+
// Remove from selection
|
|
54
|
+
onSelectionChange(selectedIds.filter((id) => id !== itemId));
|
|
55
|
+
} else {
|
|
56
|
+
// Add to selection
|
|
57
|
+
if (maxSelections && selectedIds.length >= maxSelections) {
|
|
58
|
+
// If max selections reached, replace the first selection
|
|
59
|
+
onSelectionChange([...selectedIds.slice(1), itemId]);
|
|
60
|
+
} else {
|
|
61
|
+
onSelectionChange([...selectedIds, itemId]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const getGridClasses = () => {
|
|
67
|
+
return "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 justify-items-center";
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div ref={ref} className={cn(getGridClasses(), className)} {...props}>
|
|
72
|
+
{items.map((item) => {
|
|
73
|
+
const isSelected = reversed
|
|
74
|
+
? !selectedIds.includes(item.id)
|
|
75
|
+
: selectedIds.includes(item.id);
|
|
76
|
+
const isDisabled = disabled || item.disabled;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Card
|
|
80
|
+
key={item.id}
|
|
81
|
+
className={cn(
|
|
82
|
+
"relative cursor-pointer transition-all duration-200 hover:shadow-md w-full",
|
|
83
|
+
isSelected && "ring-2 ring-primary bg-primary/5 border-primary",
|
|
84
|
+
isDisabled && "opacity-50 cursor-not-allowed",
|
|
85
|
+
!isDisabled && "hover:bg-accent/50",
|
|
86
|
+
cardClassName
|
|
87
|
+
)}
|
|
88
|
+
onClick={() => handleCardClick(item.id)}
|
|
89
|
+
>
|
|
90
|
+
{/* Edit button */}
|
|
91
|
+
{item.onEdit && (
|
|
92
|
+
<div className="absolute top-3 left-3 z-10">
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
className={cn(
|
|
96
|
+
"flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-accent",
|
|
97
|
+
"text-muted-foreground hover:text-foreground"
|
|
98
|
+
)}
|
|
99
|
+
onClick={(e) => {
|
|
100
|
+
e.stopPropagation();
|
|
101
|
+
item.onEdit?.(item.id);
|
|
102
|
+
}}
|
|
103
|
+
aria-label="Edit"
|
|
104
|
+
>
|
|
105
|
+
<Edit className="h-3.5 w-3.5" />
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{/* Selection indicator */}
|
|
111
|
+
{showCheckbox && (
|
|
112
|
+
<div className="absolute top-3 right-3 z-10">
|
|
113
|
+
<div
|
|
114
|
+
className={cn(
|
|
115
|
+
"flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors",
|
|
116
|
+
isSelected
|
|
117
|
+
? "bg-primary border-primary text-primary-foreground"
|
|
118
|
+
: "border-muted-foreground/30 bg-background"
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
{isSelected && <Check className="h-3 w-3" />}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{/* Card icon */}
|
|
127
|
+
{item.icon && (
|
|
128
|
+
<div className="flex items-center justify-center pt-6 pb-4">
|
|
129
|
+
<div className="text-5xl text-primary">
|
|
130
|
+
<i className={`ti ${item.icon}`} />
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
<CardHeader className={cn("pb-3 text-center", item.icon && "pt-2")}>
|
|
136
|
+
<CardTitle className="text-lg leading-tight">{item.title}</CardTitle>
|
|
137
|
+
{item.description && (
|
|
138
|
+
<CardDescription className="text-sm">{item.description}</CardDescription>
|
|
139
|
+
)}
|
|
140
|
+
</CardHeader>
|
|
141
|
+
|
|
142
|
+
{item.content && (
|
|
143
|
+
<CardContent className="pt-0 text-center">{item.content}</CardContent>
|
|
144
|
+
)}
|
|
145
|
+
</Card>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
CardsSelect.displayName = "CardsSelect";
|
|
154
|
+
|
|
155
|
+
export { CardsSelect };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CollapsibleProps } from "@radix-ui/react-collapsible";
|
|
2
|
+
import { ChevronRight } from "lucide-react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { Link } from "react-router";
|
|
5
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "#components/ui/collapsible";
|
|
6
|
+
import {
|
|
7
|
+
SidebarMenuButton,
|
|
8
|
+
SidebarMenuItem,
|
|
9
|
+
SidebarMenuSub,
|
|
10
|
+
useSidebar,
|
|
11
|
+
} from "#components/ui/sidebar";
|
|
12
|
+
|
|
13
|
+
export function CollapsibleSidebarMenuItem({
|
|
14
|
+
children,
|
|
15
|
+
label,
|
|
16
|
+
icon,
|
|
17
|
+
link,
|
|
18
|
+
badge,
|
|
19
|
+
...props
|
|
20
|
+
}: {
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
label: string;
|
|
23
|
+
icon: ReactNode;
|
|
24
|
+
link: string;
|
|
25
|
+
badge?: ReactNode;
|
|
26
|
+
} & CollapsibleProps) {
|
|
27
|
+
const { open } = useSidebar();
|
|
28
|
+
|
|
29
|
+
if (!open)
|
|
30
|
+
return (
|
|
31
|
+
<SidebarMenuItem>
|
|
32
|
+
<SidebarMenuButton asChild tooltip={label}>
|
|
33
|
+
<Link to={link}>
|
|
34
|
+
{icon}
|
|
35
|
+
<span>{label}</span>
|
|
36
|
+
</Link>
|
|
37
|
+
</SidebarMenuButton>
|
|
38
|
+
</SidebarMenuItem>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Collapsible asChild className="group/collapsible" {...props}>
|
|
43
|
+
<SidebarMenuItem>
|
|
44
|
+
<CollapsibleTrigger asChild>
|
|
45
|
+
<SidebarMenuButton>
|
|
46
|
+
{icon}
|
|
47
|
+
{badge ? badge : <span>{label}</span>}
|
|
48
|
+
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
|
49
|
+
</SidebarMenuButton>
|
|
50
|
+
</CollapsibleTrigger>
|
|
51
|
+
<CollapsibleContent>
|
|
52
|
+
<SidebarMenuSub>{children}</SidebarMenuSub>
|
|
53
|
+
</CollapsibleContent>
|
|
54
|
+
</SidebarMenuItem>
|
|
55
|
+
</Collapsible>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Input,
|
|
4
|
+
type InputProps,
|
|
5
|
+
Popover,
|
|
6
|
+
PopoverContent,
|
|
7
|
+
PopoverTrigger,
|
|
8
|
+
} from "@heroui/react";
|
|
9
|
+
import { hexToHsva } from "@uiw/color-convert";
|
|
10
|
+
import Colorful from "@uiw/react-color-colorful";
|
|
11
|
+
import { PipetteIcon } from "lucide-react";
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
|
|
14
|
+
function safeHexToHsva(hex: string) {
|
|
15
|
+
try {
|
|
16
|
+
return hexToHsva(hex);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return { h: 0, s: 0, v: 0, a: 1 };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ColorPicker({
|
|
23
|
+
disableAlpha = true,
|
|
24
|
+
value,
|
|
25
|
+
|
|
26
|
+
onValueChange,
|
|
27
|
+
...props
|
|
28
|
+
}: InputProps & { disableAlpha?: boolean }) {
|
|
29
|
+
const [hsva, setHsva] = useState(safeHexToHsva(value || ""));
|
|
30
|
+
return (
|
|
31
|
+
<Input
|
|
32
|
+
onValueChange={onValueChange}
|
|
33
|
+
value={value}
|
|
34
|
+
endContent={
|
|
35
|
+
<Popover placement="bottom">
|
|
36
|
+
<PopoverTrigger>
|
|
37
|
+
<Button isIconOnly aria-label="Pick color" size="sm" variant="light">
|
|
38
|
+
<PipetteIcon className="h-4 w-4 text-foreground" />
|
|
39
|
+
</Button>
|
|
40
|
+
</PopoverTrigger>
|
|
41
|
+
<PopoverContent aria-label="Color picker">
|
|
42
|
+
<Colorful
|
|
43
|
+
color={hsva}
|
|
44
|
+
disableAlpha={disableAlpha}
|
|
45
|
+
onChange={(color) => {
|
|
46
|
+
setHsva(color.hsva);
|
|
47
|
+
onValueChange?.(color.hex);
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
</PopoverContent>
|
|
51
|
+
</Popover>
|
|
52
|
+
}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Button, type ButtonProps } from "@heroui/react";
|
|
2
|
+
import { CheckCircle, Copy } from "lucide-react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useTranslation } from "react-i18next";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
|
|
7
|
+
export function CopyButton({
|
|
8
|
+
text,
|
|
9
|
+
notificationTimeout = 1000,
|
|
10
|
+
isIconOnly,
|
|
11
|
+
onCopy,
|
|
12
|
+
...props
|
|
13
|
+
}: ButtonProps & { text: string; notificationTimeout?: number; onCopy?: () => void }) {
|
|
14
|
+
const { t } = useTranslation();
|
|
15
|
+
const [isCopied, setIsCopied] = useState(false);
|
|
16
|
+
|
|
17
|
+
const handleCopy = async (text: string) => {
|
|
18
|
+
try {
|
|
19
|
+
await window.navigator.clipboard.writeText(text);
|
|
20
|
+
setIsCopied(true);
|
|
21
|
+
onCopy?.();
|
|
22
|
+
toast.success(t("web-ui:common.copySuccess"));
|
|
23
|
+
setTimeout(() => setIsCopied(false), notificationTimeout);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
toast.error(t("web-ui:common.copyError"));
|
|
26
|
+
console.error(error);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Button isIconOnly={isIconOnly} onPress={() => handleCopy(text)} {...props}>
|
|
32
|
+
{isCopied ? (
|
|
33
|
+
<>
|
|
34
|
+
<CheckCircle className="h-4 w-4" />
|
|
35
|
+
{!isIconOnly && t("web-ui:common.copied")}
|
|
36
|
+
</>
|
|
37
|
+
) : (
|
|
38
|
+
<>
|
|
39
|
+
<Copy className="h-4 w-4" />
|
|
40
|
+
{!isIconOnly && t("web-ui:common.copy")}
|
|
41
|
+
</>
|
|
42
|
+
)}
|
|
43
|
+
</Button>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { ZoomIn, ZoomOut } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import Cropper, { type Area } from "react-easy-crop";
|
|
4
|
+
import { useTranslation } from "react-i18next";
|
|
5
|
+
import { Button } from "#components/ui/button";
|
|
6
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "#components/ui/dialog";
|
|
7
|
+
import { Slider } from "#components/ui/slider";
|
|
8
|
+
|
|
9
|
+
interface CropArea {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface CropDialogProps {
|
|
17
|
+
open: boolean;
|
|
18
|
+
onOpenChange: (open: boolean) => void;
|
|
19
|
+
imageUrl: string;
|
|
20
|
+
onCropComplete: (croppedBlob: Blob) => void;
|
|
21
|
+
onCancel: () => void;
|
|
22
|
+
isLoading?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function getCroppedImg(
|
|
26
|
+
imageSrc: string,
|
|
27
|
+
pixelCrop: CropArea,
|
|
28
|
+
t: (key: string) => string
|
|
29
|
+
): Promise<Blob> {
|
|
30
|
+
const image = new Image();
|
|
31
|
+
image.src = imageSrc;
|
|
32
|
+
|
|
33
|
+
// Create canvas
|
|
34
|
+
const canvas = document.createElement("canvas");
|
|
35
|
+
const ctx = canvas.getContext("2d");
|
|
36
|
+
|
|
37
|
+
if (!ctx) {
|
|
38
|
+
throw new Error(t("web-ui:image.crop.failedContext"));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Set canvas size to the crop size
|
|
42
|
+
canvas.width = pixelCrop.width;
|
|
43
|
+
canvas.height = pixelCrop.height;
|
|
44
|
+
|
|
45
|
+
// Draw the cropped image
|
|
46
|
+
ctx.drawImage(
|
|
47
|
+
image,
|
|
48
|
+
pixelCrop.x,
|
|
49
|
+
pixelCrop.y,
|
|
50
|
+
pixelCrop.width,
|
|
51
|
+
pixelCrop.height,
|
|
52
|
+
0,
|
|
53
|
+
0,
|
|
54
|
+
pixelCrop.width,
|
|
55
|
+
pixelCrop.height
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Get the cropped image as blob
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
canvas.toBlob((blob) => {
|
|
61
|
+
if (!blob) reject(new Error(t("web-ui:image.crop.failedCrop")));
|
|
62
|
+
else resolve(blob);
|
|
63
|
+
}, "image/jpeg");
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function CropDialog({
|
|
68
|
+
open,
|
|
69
|
+
onOpenChange,
|
|
70
|
+
imageUrl,
|
|
71
|
+
onCropComplete,
|
|
72
|
+
onCancel,
|
|
73
|
+
isLoading = false,
|
|
74
|
+
}: CropDialogProps) {
|
|
75
|
+
const { t } = useTranslation();
|
|
76
|
+
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
|
77
|
+
const [zoom, setZoom] = useState(1);
|
|
78
|
+
const [croppedAreaPixels, setCroppedAreaPixels] = useState<CropArea | null>(null);
|
|
79
|
+
|
|
80
|
+
const handleCropComplete = (_croppedArea: Area, croppedAreaPixels: CropArea) => {
|
|
81
|
+
setCroppedAreaPixels(croppedAreaPixels);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleSave = async () => {
|
|
85
|
+
if (!croppedAreaPixels) return;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const croppedImageBlob = await getCroppedImg(imageUrl, croppedAreaPixels, t);
|
|
89
|
+
onCropComplete(croppedImageBlob);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error(t("web-ui:image.crop.error"), error);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
97
|
+
<DialogContent className="sm:max-w-[600px]">
|
|
98
|
+
<DialogHeader>
|
|
99
|
+
<DialogTitle>{t("web-ui:image.crop.title")}</DialogTitle>
|
|
100
|
+
</DialogHeader>
|
|
101
|
+
<div className="relative h-[400px] w-full">
|
|
102
|
+
<Cropper
|
|
103
|
+
image={imageUrl}
|
|
104
|
+
crop={crop}
|
|
105
|
+
zoom={zoom}
|
|
106
|
+
aspect={1}
|
|
107
|
+
onCropChange={setCrop}
|
|
108
|
+
onZoomChange={setZoom}
|
|
109
|
+
onCropComplete={handleCropComplete}
|
|
110
|
+
objectFit="contain"
|
|
111
|
+
showGrid={true}
|
|
112
|
+
style={{
|
|
113
|
+
containerStyle: {
|
|
114
|
+
position: "relative",
|
|
115
|
+
width: "100%",
|
|
116
|
+
height: "100%",
|
|
117
|
+
backgroundColor: "#262626",
|
|
118
|
+
},
|
|
119
|
+
cropAreaStyle: {
|
|
120
|
+
border: "2px solid #fff",
|
|
121
|
+
color: "rgba(255, 255, 255, 0.9)",
|
|
122
|
+
},
|
|
123
|
+
mediaStyle: {
|
|
124
|
+
backgroundColor: "#262626",
|
|
125
|
+
},
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="flex items-center gap-4 px-1">
|
|
131
|
+
<ZoomOut className="h-4 w-4" />
|
|
132
|
+
<Slider
|
|
133
|
+
value={[zoom]}
|
|
134
|
+
onValueChange={(values: number[]) => setZoom(values[0])}
|
|
135
|
+
min={1}
|
|
136
|
+
max={3}
|
|
137
|
+
step={0.1}
|
|
138
|
+
className="flex-1"
|
|
139
|
+
/>
|
|
140
|
+
<ZoomIn className="h-4 w-4" />
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div className="mt-4 flex justify-end space-x-2">
|
|
144
|
+
<Button variant="outline" onClick={onCancel}>
|
|
145
|
+
{t("web-ui:common.cancel")}
|
|
146
|
+
</Button>
|
|
147
|
+
<Button onClick={handleSave} disabled={isLoading}>
|
|
148
|
+
{isLoading ? t("web-ui:common.saving") : t("web-ui:common.save")}
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
</DialogContent>
|
|
152
|
+
</Dialog>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
type ButtonProps,
|
|
4
|
+
Modal,
|
|
5
|
+
ModalBody,
|
|
6
|
+
ModalContent,
|
|
7
|
+
ModalFooter,
|
|
8
|
+
ModalHeader,
|
|
9
|
+
} from "@heroui/react";
|
|
10
|
+
import { semanticColors } from "@heroui/theme";
|
|
11
|
+
import { createContext, useContext, useRef, useState } from "react";
|
|
12
|
+
|
|
13
|
+
export type DialogProps = {
|
|
14
|
+
title: React.ReactNode;
|
|
15
|
+
description: React.ReactNode;
|
|
16
|
+
color?: ButtonProps["color"];
|
|
17
|
+
cancelable?: boolean;
|
|
18
|
+
onCancel?: () => void;
|
|
19
|
+
onConfirm?: () => void;
|
|
20
|
+
cancelLabel?: string;
|
|
21
|
+
confirmLabel?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const DialogContext = createContext<(dialog: DialogProps) => void>(() => {
|
|
25
|
+
console.warn("DialogProvider is not initialized");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export function useDialog() {
|
|
29
|
+
const context = useContext(DialogContext);
|
|
30
|
+
if (!context) {
|
|
31
|
+
throw new Error("useDialog must be used within a DialogProvider");
|
|
32
|
+
}
|
|
33
|
+
return context;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
|
37
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
38
|
+
const [dialog, setDialog] = useState<DialogProps | null>(null);
|
|
39
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
40
|
+
|
|
41
|
+
const handleSetDialog = (dialog: DialogProps) => {
|
|
42
|
+
if (timeoutRef.current) {
|
|
43
|
+
clearTimeout(timeoutRef.current);
|
|
44
|
+
timeoutRef.current = null;
|
|
45
|
+
}
|
|
46
|
+
setDialog(dialog);
|
|
47
|
+
setIsOpen(true);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleUnsetDialog = () => {
|
|
51
|
+
setIsOpen(false);
|
|
52
|
+
if (timeoutRef.current) {
|
|
53
|
+
clearTimeout(timeoutRef.current);
|
|
54
|
+
timeoutRef.current = null;
|
|
55
|
+
}
|
|
56
|
+
timeoutRef.current = setTimeout(() => {
|
|
57
|
+
setDialog(null);
|
|
58
|
+
}, 500);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleCancel = () => {
|
|
62
|
+
dialog?.onCancel?.();
|
|
63
|
+
handleUnsetDialog();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handleConfirm = () => {
|
|
67
|
+
dialog?.onConfirm?.();
|
|
68
|
+
handleUnsetDialog();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<DialogContext.Provider value={handleSetDialog}>
|
|
73
|
+
<>
|
|
74
|
+
{children}
|
|
75
|
+
{dialog && (
|
|
76
|
+
<Modal
|
|
77
|
+
isOpen={isOpen}
|
|
78
|
+
onOpenChange={handleUnsetDialog}
|
|
79
|
+
style={{
|
|
80
|
+
borderColor: semanticColors.light[dialog.color || "danger"][600],
|
|
81
|
+
}}
|
|
82
|
+
classNames={{
|
|
83
|
+
base: "border-1",
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<ModalContent>
|
|
87
|
+
<ModalHeader>{dialog.title}</ModalHeader>
|
|
88
|
+
<ModalBody>{dialog.description}</ModalBody>
|
|
89
|
+
<ModalFooter>
|
|
90
|
+
<div className="flex flex-row gap-2">
|
|
91
|
+
{(dialog.cancelable || dialog.onCancel) && (
|
|
92
|
+
<Button onPress={handleCancel}>{dialog.cancelLabel ?? "Cancel"}</Button>
|
|
93
|
+
)}
|
|
94
|
+
<Button color={dialog.color} onPress={handleConfirm}>
|
|
95
|
+
{dialog.confirmLabel ?? "Confirm"}
|
|
96
|
+
</Button>
|
|
97
|
+
</div>
|
|
98
|
+
</ModalFooter>
|
|
99
|
+
</ModalContent>
|
|
100
|
+
</Modal>
|
|
101
|
+
)}
|
|
102
|
+
</>
|
|
103
|
+
</DialogContext.Provider>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function ErrorFallback({
|
|
2
|
+
error,
|
|
3
|
+
componentStack,
|
|
4
|
+
}: {
|
|
5
|
+
error: unknown;
|
|
6
|
+
componentStack: string;
|
|
7
|
+
}) {
|
|
8
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
9
|
+
return (
|
|
10
|
+
<div className="flex flex-grow-1 flex-col items-center justify-center h-[100vh]">
|
|
11
|
+
<h1 className="text-3xl font-bold">Something went wrong.</h1>
|
|
12
|
+
<h1 className="text-2xl text-gray-700 font-bold mb-5">Please try again later.</h1>
|
|
13
|
+
<div className="text-lg text-gray-700 mb-5">{errorMessage}</div>
|
|
14
|
+
<div className="text-sm text-gray-500 px-10">{componentStack}</div>
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|