@djangocfg/ui-nextjs 1.4.45
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 +21 -0
- package/README.md +152 -0
- package/package.json +110 -0
- package/src/animations/AnimatedBackground.tsx +645 -0
- package/src/animations/index.ts +2 -0
- package/src/blocks/ArticleCard.tsx +94 -0
- package/src/blocks/ArticleList.tsx +95 -0
- package/src/blocks/CTASection.tsx +136 -0
- package/src/blocks/FeatureSection.tsx +104 -0
- package/src/blocks/Hero.tsx +102 -0
- package/src/blocks/NewsletterSection.tsx +119 -0
- package/src/blocks/StatsSection.tsx +103 -0
- package/src/blocks/SuperHero.tsx +328 -0
- package/src/blocks/TestimonialSection.tsx +122 -0
- package/src/blocks/index.ts +9 -0
- package/src/components/README.md +2018 -0
- package/src/components/breadcrumb-navigation.tsx +127 -0
- package/src/components/breadcrumb.tsx +132 -0
- package/src/components/button-download.tsx +275 -0
- package/src/components/dropdown-menu.tsx +219 -0
- package/src/components/index.ts +86 -0
- package/src/components/markdown/MarkdownMessage.tsx +338 -0
- package/src/components/markdown/index.ts +5 -0
- package/src/components/menubar.tsx +274 -0
- package/src/components/multi-select-pro/async.tsx +608 -0
- package/src/components/multi-select-pro/helpers.tsx +84 -0
- package/src/components/multi-select-pro/index.tsx +622 -0
- package/src/components/navigation-menu.tsx +153 -0
- package/src/components/pagination-static.tsx +348 -0
- package/src/components/pagination.tsx +138 -0
- package/src/components/phone-input.tsx +276 -0
- package/src/components/sidebar.tsx +866 -0
- package/src/components/sonner.tsx +31 -0
- package/src/components/ssr-pagination.tsx +237 -0
- package/src/hooks/index.ts +19 -0
- package/src/hooks/useCfgRouter.ts +153 -0
- package/src/hooks/useLocalStorage.ts +221 -0
- package/src/hooks/useQueryParams.ts +73 -0
- package/src/hooks/useSessionStorage.ts +188 -0
- package/src/hooks/useTheme.ts +57 -0
- package/src/index.ts +24 -0
- package/src/lib/index.ts +2 -0
- package/src/styles/index.css +2 -0
- package/src/theme/ForceTheme.tsx +115 -0
- package/src/theme/ThemeProvider.tsx +82 -0
- package/src/theme/ThemeToggle.tsx +52 -0
- package/src/theme/index.ts +3 -0
- package/src/tools/JsonForm/JsonSchemaForm.tsx +199 -0
- package/src/tools/JsonForm/examples/BotConfigExample.tsx +245 -0
- package/src/tools/JsonForm/examples/RealBotConfigExample.tsx +157 -0
- package/src/tools/JsonForm/index.ts +46 -0
- package/src/tools/JsonForm/templates/ArrayFieldItemTemplate.tsx +46 -0
- package/src/tools/JsonForm/templates/ArrayFieldTemplate.tsx +73 -0
- package/src/tools/JsonForm/templates/BaseInputTemplate.tsx +106 -0
- package/src/tools/JsonForm/templates/ErrorListTemplate.tsx +34 -0
- package/src/tools/JsonForm/templates/FieldTemplate.tsx +61 -0
- package/src/tools/JsonForm/templates/ObjectFieldTemplate.tsx +43 -0
- package/src/tools/JsonForm/templates/index.ts +12 -0
- package/src/tools/JsonForm/types.ts +83 -0
- package/src/tools/JsonForm/utils.ts +212 -0
- package/src/tools/JsonForm/widgets/CheckboxWidget.tsx +36 -0
- package/src/tools/JsonForm/widgets/NumberWidget.tsx +88 -0
- package/src/tools/JsonForm/widgets/SelectWidget.tsx +100 -0
- package/src/tools/JsonForm/widgets/SwitchWidget.tsx +34 -0
- package/src/tools/JsonForm/widgets/TextWidget.tsx +95 -0
- package/src/tools/JsonForm/widgets/index.ts +12 -0
- package/src/tools/JsonTree/index.tsx +252 -0
- package/src/tools/LottiePlayer/LottiePlayer.client.tsx +212 -0
- package/src/tools/LottiePlayer/index.tsx +54 -0
- package/src/tools/LottiePlayer/types.ts +108 -0
- package/src/tools/LottiePlayer/useLottie.ts +163 -0
- package/src/tools/Mermaid/Mermaid.client.tsx +341 -0
- package/src/tools/Mermaid/index.tsx +40 -0
- package/src/tools/OpenapiViewer/components/EndpointInfo.tsx +144 -0
- package/src/tools/OpenapiViewer/components/EndpointsLibrary.tsx +255 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout.tsx +123 -0
- package/src/tools/OpenapiViewer/components/PlaygroundStepper.tsx +98 -0
- package/src/tools/OpenapiViewer/components/RequestBuilder.tsx +164 -0
- package/src/tools/OpenapiViewer/components/RequestParametersForm.tsx +253 -0
- package/src/tools/OpenapiViewer/components/ResponseViewer.tsx +169 -0
- package/src/tools/OpenapiViewer/components/VersionSelector.tsx +64 -0
- package/src/tools/OpenapiViewer/components/index.ts +14 -0
- package/src/tools/OpenapiViewer/constants.ts +39 -0
- package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +338 -0
- package/src/tools/OpenapiViewer/hooks/index.ts +8 -0
- package/src/tools/OpenapiViewer/hooks/useMobile.ts +10 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +203 -0
- package/src/tools/OpenapiViewer/index.tsx +36 -0
- package/src/tools/OpenapiViewer/types.ts +152 -0
- package/src/tools/OpenapiViewer/utils/apiKeyManager.ts +149 -0
- package/src/tools/OpenapiViewer/utils/formatters.ts +71 -0
- package/src/tools/OpenapiViewer/utils/index.ts +9 -0
- package/src/tools/OpenapiViewer/utils/versionManager.ts +161 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +217 -0
- package/src/tools/PrettyCode/index.tsx +43 -0
- package/src/tools/VideoPlayer/README.md +239 -0
- package/src/tools/VideoPlayer/VideoControls.tsx +138 -0
- package/src/tools/VideoPlayer/VideoPlayer.tsx +230 -0
- package/src/tools/VideoPlayer/index.ts +9 -0
- package/src/tools/VideoPlayer/types.ts +62 -0
- package/src/tools/index.ts +43 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import {
|
|
4
|
+
Breadcrumb,
|
|
5
|
+
BreadcrumbList,
|
|
6
|
+
BreadcrumbItem,
|
|
7
|
+
BreadcrumbLink,
|
|
8
|
+
BreadcrumbPage,
|
|
9
|
+
BreadcrumbSeparator,
|
|
10
|
+
BreadcrumbEllipsis,
|
|
11
|
+
} from './breadcrumb';
|
|
12
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
13
|
+
|
|
14
|
+
export interface BreadcrumbItem {
|
|
15
|
+
/** Display text for the breadcrumb */
|
|
16
|
+
label: string;
|
|
17
|
+
/** URL to navigate to. If not provided, item will be rendered as current page */
|
|
18
|
+
href?: string;
|
|
19
|
+
/** Whether this item is the current page (will be rendered as BreadcrumbPage) */
|
|
20
|
+
isCurrentPage?: boolean;
|
|
21
|
+
/** Optional icon to display before the label */
|
|
22
|
+
icon?: React.ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BreadcrumbNavigationProps {
|
|
26
|
+
/** Array of breadcrumb items */
|
|
27
|
+
items: BreadcrumbItem[];
|
|
28
|
+
/** Custom separator between items */
|
|
29
|
+
separator?: React.ReactNode;
|
|
30
|
+
/** Maximum number of items to show before collapsing with ellipsis */
|
|
31
|
+
maxItems?: number;
|
|
32
|
+
/** Custom className for the breadcrumb container */
|
|
33
|
+
className?: string;
|
|
34
|
+
/** Whether to use Next.js Link component for navigation */
|
|
35
|
+
useNextLink?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const BreadcrumbNavigation: React.FC<BreadcrumbNavigationProps> = ({
|
|
39
|
+
items,
|
|
40
|
+
separator,
|
|
41
|
+
maxItems = 5,
|
|
42
|
+
className,
|
|
43
|
+
useNextLink = true,
|
|
44
|
+
}) => {
|
|
45
|
+
// Handle empty or single item cases
|
|
46
|
+
if (!items || items.length === 0) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Determine which items to show based on maxItems
|
|
51
|
+
const shouldCollapse = items.length > maxItems;
|
|
52
|
+
let displayItems: BreadcrumbItem[] = items;
|
|
53
|
+
|
|
54
|
+
if (shouldCollapse && items.length > 0) {
|
|
55
|
+
// Show first item, ellipsis, and last few items
|
|
56
|
+
if(items.length > 0) {
|
|
57
|
+
const firstItem = items[0]!;
|
|
58
|
+
const lastItems = items.slice(-(maxItems - 2)).filter(Boolean);
|
|
59
|
+
displayItems = [firstItem, ...lastItems];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const renderBreadcrumbItem = (item: BreadcrumbItem, index: number, isLast: boolean) => {
|
|
64
|
+
const isCurrentPage = item.isCurrentPage || isLast || !item.href;
|
|
65
|
+
|
|
66
|
+
const content = (
|
|
67
|
+
<>
|
|
68
|
+
{item.icon && <span className="mr-1">{item.icon}</span>}
|
|
69
|
+
{item.label}
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (isCurrentPage) {
|
|
74
|
+
return (
|
|
75
|
+
<BreadcrumbItem key={`${item.label}-${index}`}>
|
|
76
|
+
<BreadcrumbPage>{content}</BreadcrumbPage>
|
|
77
|
+
</BreadcrumbItem>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<BreadcrumbItem key={`${item.label}-${index}`}>
|
|
83
|
+
<BreadcrumbLink asChild={useNextLink}>
|
|
84
|
+
{useNextLink ? (
|
|
85
|
+
<Link href={item.href!}>{content}</Link>
|
|
86
|
+
) : (
|
|
87
|
+
<a href={item.href!}>{content}</a>
|
|
88
|
+
)}
|
|
89
|
+
</BreadcrumbLink>
|
|
90
|
+
</BreadcrumbItem>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Breadcrumb className={className}>
|
|
96
|
+
<BreadcrumbList>
|
|
97
|
+
{displayItems.map((item, index) => {
|
|
98
|
+
const isLast = index === displayItems.length - 1;
|
|
99
|
+
const isFirst = index === 0;
|
|
100
|
+
|
|
101
|
+
// Show ellipsis after first item if we collapsed items
|
|
102
|
+
if (shouldCollapse && isFirst && displayItems.length > 2) {
|
|
103
|
+
return (
|
|
104
|
+
<React.Fragment key={`fragment-${index}`}>
|
|
105
|
+
{renderBreadcrumbItem(item, index, isLast)}
|
|
106
|
+
<BreadcrumbSeparator>{separator}</BreadcrumbSeparator>
|
|
107
|
+
<BreadcrumbItem>
|
|
108
|
+
<BreadcrumbEllipsis />
|
|
109
|
+
</BreadcrumbItem>
|
|
110
|
+
{!isLast && <BreadcrumbSeparator>{separator}</BreadcrumbSeparator>}
|
|
111
|
+
</React.Fragment>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<React.Fragment key={`fragment-${index}`}>
|
|
117
|
+
{renderBreadcrumbItem(item, index, isLast)}
|
|
118
|
+
{!isLast && <BreadcrumbSeparator>{separator}</BreadcrumbSeparator>}
|
|
119
|
+
</React.Fragment>
|
|
120
|
+
);
|
|
121
|
+
})}
|
|
122
|
+
</BreadcrumbList>
|
|
123
|
+
</Breadcrumb>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
BreadcrumbNavigation.displayName = 'BreadcrumbNavigation';
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Slot } from "@radix-ui/react-slot"
|
|
5
|
+
import { cn } from "@djangocfg/ui-core/lib"
|
|
6
|
+
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"
|
|
7
|
+
import Link from "next/link"
|
|
8
|
+
|
|
9
|
+
const Breadcrumb = React.forwardRef<
|
|
10
|
+
HTMLElement,
|
|
11
|
+
React.ComponentPropsWithoutRef<"nav"> & {
|
|
12
|
+
separator?: React.ReactNode
|
|
13
|
+
}
|
|
14
|
+
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
|
15
|
+
Breadcrumb.displayName = "Breadcrumb"
|
|
16
|
+
|
|
17
|
+
const BreadcrumbList = React.forwardRef<
|
|
18
|
+
HTMLOListElement,
|
|
19
|
+
React.ComponentPropsWithoutRef<"ol">
|
|
20
|
+
>(({ className, ...props }, ref) => (
|
|
21
|
+
<ol
|
|
22
|
+
ref={ref}
|
|
23
|
+
className={cn(
|
|
24
|
+
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
|
25
|
+
className
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
/>
|
|
29
|
+
))
|
|
30
|
+
BreadcrumbList.displayName = "BreadcrumbList"
|
|
31
|
+
|
|
32
|
+
const BreadcrumbItem = React.forwardRef<
|
|
33
|
+
HTMLLIElement,
|
|
34
|
+
React.ComponentPropsWithoutRef<"li"> & { key?: React.Key }
|
|
35
|
+
>(({ className, ...props }, ref) => (
|
|
36
|
+
<li
|
|
37
|
+
ref={ref}
|
|
38
|
+
className={cn("inline-flex items-center gap-1.5", className)}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
41
|
+
))
|
|
42
|
+
BreadcrumbItem.displayName = "BreadcrumbItem"
|
|
43
|
+
|
|
44
|
+
const BreadcrumbLink = React.forwardRef<
|
|
45
|
+
HTMLAnchorElement,
|
|
46
|
+
React.ComponentPropsWithoutRef<"a"> & {
|
|
47
|
+
asChild?: boolean
|
|
48
|
+
href?: string
|
|
49
|
+
}
|
|
50
|
+
>(({ asChild, className, href, children, ...props }, ref) => {
|
|
51
|
+
const Comp = asChild ? Slot : "a"
|
|
52
|
+
|
|
53
|
+
if (href) {
|
|
54
|
+
return (
|
|
55
|
+
<Link
|
|
56
|
+
href={href}
|
|
57
|
+
className={cn("transition-colors hover:text-foreground", className)}
|
|
58
|
+
ref={ref}
|
|
59
|
+
>
|
|
60
|
+
{children}
|
|
61
|
+
</Link>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Comp
|
|
67
|
+
ref={ref}
|
|
68
|
+
className={cn("transition-colors hover:text-foreground", className)}
|
|
69
|
+
{...props}
|
|
70
|
+
>
|
|
71
|
+
{children}
|
|
72
|
+
</Comp>
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
BreadcrumbLink.displayName = "BreadcrumbLink"
|
|
76
|
+
|
|
77
|
+
const BreadcrumbPage = React.forwardRef<
|
|
78
|
+
HTMLSpanElement,
|
|
79
|
+
React.ComponentPropsWithoutRef<"span">
|
|
80
|
+
>(({ className, ...props }, ref) => (
|
|
81
|
+
<span
|
|
82
|
+
ref={ref}
|
|
83
|
+
role="link"
|
|
84
|
+
aria-disabled="true"
|
|
85
|
+
aria-current="page"
|
|
86
|
+
className={cn("font-normal text-foreground", className)}
|
|
87
|
+
{...props}
|
|
88
|
+
/>
|
|
89
|
+
))
|
|
90
|
+
BreadcrumbPage.displayName = "BreadcrumbPage"
|
|
91
|
+
|
|
92
|
+
const BreadcrumbSeparator = ({
|
|
93
|
+
children,
|
|
94
|
+
className,
|
|
95
|
+
...props
|
|
96
|
+
}: React.ComponentProps<"li"> & { key?: React.Key }) => (
|
|
97
|
+
<li
|
|
98
|
+
role="presentation"
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
|
|
101
|
+
{...props}
|
|
102
|
+
>
|
|
103
|
+
{children ?? <ChevronRightIcon />}
|
|
104
|
+
</li>
|
|
105
|
+
)
|
|
106
|
+
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
|
107
|
+
|
|
108
|
+
const BreadcrumbEllipsis = ({
|
|
109
|
+
className,
|
|
110
|
+
...props
|
|
111
|
+
}: React.ComponentProps<"span">) => (
|
|
112
|
+
<span
|
|
113
|
+
role="presentation"
|
|
114
|
+
aria-hidden="true"
|
|
115
|
+
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
|
116
|
+
{...props}
|
|
117
|
+
>
|
|
118
|
+
<DotsHorizontalIcon className="h-4 w-4" />
|
|
119
|
+
<span className="sr-only">More</span>
|
|
120
|
+
</span>
|
|
121
|
+
)
|
|
122
|
+
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
|
123
|
+
|
|
124
|
+
export {
|
|
125
|
+
Breadcrumb,
|
|
126
|
+
BreadcrumbList,
|
|
127
|
+
BreadcrumbItem,
|
|
128
|
+
BreadcrumbLink,
|
|
129
|
+
BreadcrumbPage,
|
|
130
|
+
BreadcrumbSeparator,
|
|
131
|
+
BreadcrumbEllipsis,
|
|
132
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Loader2, Download, CheckCircle2, AlertCircle } from "lucide-react"
|
|
5
|
+
import { Button, type ButtonProps } from "@djangocfg/ui-core/components"
|
|
6
|
+
import { cn } from "@djangocfg/ui-core/lib"
|
|
7
|
+
import { useLocalStorage } from "../hooks/useLocalStorage"
|
|
8
|
+
|
|
9
|
+
// Token key used by the API client
|
|
10
|
+
const TOKEN_KEY = "auth_token"
|
|
11
|
+
|
|
12
|
+
export interface DownloadButtonProps extends Omit<ButtonProps, 'onClick'> {
|
|
13
|
+
/**
|
|
14
|
+
* URL to download from
|
|
15
|
+
*/
|
|
16
|
+
url: string
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Optional filename override. If not provided, will try to extract from Content-Disposition header
|
|
20
|
+
*/
|
|
21
|
+
filename?: string
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Optional callback when download starts
|
|
25
|
+
*/
|
|
26
|
+
onDownloadStart?: () => void
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Optional callback when download completes
|
|
30
|
+
*/
|
|
31
|
+
onDownloadComplete?: (filename: string) => void
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Optional callback when download fails
|
|
35
|
+
*/
|
|
36
|
+
onDownloadError?: (error: Error) => void
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Use fetch API for download (allows progress tracking and auth headers)
|
|
40
|
+
* Default: true
|
|
41
|
+
*/
|
|
42
|
+
useFetch?: boolean
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Show download status icons (loading, success, error)
|
|
46
|
+
* Default: true
|
|
47
|
+
*/
|
|
48
|
+
showStatus?: boolean
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Auto-reset status after success/error (in ms)
|
|
52
|
+
* Default: 2000
|
|
53
|
+
*/
|
|
54
|
+
statusResetDelay?: number
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Method for download (GET or POST)
|
|
58
|
+
* Default: GET
|
|
59
|
+
*/
|
|
60
|
+
method?: 'GET' | 'POST'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Optional request body for POST requests
|
|
64
|
+
*/
|
|
65
|
+
body?: any
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type DownloadStatus = 'idle' | 'downloading' | 'success' | 'error'
|
|
69
|
+
|
|
70
|
+
const DownloadButton = React.forwardRef<HTMLButtonElement, DownloadButtonProps>(
|
|
71
|
+
(
|
|
72
|
+
{
|
|
73
|
+
url,
|
|
74
|
+
filename,
|
|
75
|
+
onDownloadStart,
|
|
76
|
+
onDownloadComplete,
|
|
77
|
+
onDownloadError,
|
|
78
|
+
useFetch = true,
|
|
79
|
+
showStatus = true,
|
|
80
|
+
statusResetDelay = 2000,
|
|
81
|
+
method = 'GET',
|
|
82
|
+
body,
|
|
83
|
+
children,
|
|
84
|
+
disabled,
|
|
85
|
+
className,
|
|
86
|
+
...props
|
|
87
|
+
},
|
|
88
|
+
ref
|
|
89
|
+
) => {
|
|
90
|
+
const [status, setStatus] = React.useState<DownloadStatus>('idle')
|
|
91
|
+
const resetTimeoutRef = React.useRef<NodeJS.Timeout | undefined>(undefined)
|
|
92
|
+
|
|
93
|
+
// Get auth token from localStorage
|
|
94
|
+
const [token] = useLocalStorage<string>(TOKEN_KEY, '')
|
|
95
|
+
|
|
96
|
+
// Clean up timeout on unmount
|
|
97
|
+
React.useEffect(() => {
|
|
98
|
+
return () => {
|
|
99
|
+
if (resetTimeoutRef.current) {
|
|
100
|
+
clearTimeout(resetTimeoutRef.current)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}, [])
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Extract filename from Content-Disposition header
|
|
107
|
+
*/
|
|
108
|
+
const extractFilename = (headers: Headers): string | null => {
|
|
109
|
+
const disposition = headers.get('Content-Disposition')
|
|
110
|
+
if (!disposition) return null
|
|
111
|
+
|
|
112
|
+
// Try to match: filename*=UTF-8''example.txt or filename="example.txt"
|
|
113
|
+
const filenameMatch = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';]+)["']?/i)
|
|
114
|
+
if (filenameMatch && filenameMatch[1]) {
|
|
115
|
+
return decodeURIComponent(filenameMatch[1])
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Trigger browser download for blob
|
|
123
|
+
*/
|
|
124
|
+
const triggerDownload = (blob: Blob, downloadFilename: string) => {
|
|
125
|
+
const url = window.URL.createObjectURL(blob)
|
|
126
|
+
const a = document.createElement('a')
|
|
127
|
+
a.href = url
|
|
128
|
+
a.download = downloadFilename
|
|
129
|
+
document.body.appendChild(a)
|
|
130
|
+
a.click()
|
|
131
|
+
document.body.removeChild(a)
|
|
132
|
+
window.URL.revokeObjectURL(url)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reset status after delay
|
|
137
|
+
*/
|
|
138
|
+
const scheduleStatusReset = () => {
|
|
139
|
+
if (resetTimeoutRef.current) {
|
|
140
|
+
clearTimeout(resetTimeoutRef.current)
|
|
141
|
+
}
|
|
142
|
+
resetTimeoutRef.current = setTimeout(() => {
|
|
143
|
+
setStatus('idle')
|
|
144
|
+
}, statusResetDelay)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Download using fetch API (supports auth, progress, error handling)
|
|
149
|
+
*/
|
|
150
|
+
const downloadWithFetch = async () => {
|
|
151
|
+
try {
|
|
152
|
+
setStatus('downloading')
|
|
153
|
+
onDownloadStart?.()
|
|
154
|
+
|
|
155
|
+
const headers: HeadersInit = {}
|
|
156
|
+
|
|
157
|
+
// Add authorization header if token is available
|
|
158
|
+
if (token) {
|
|
159
|
+
headers['Authorization'] = `Bearer ${token}`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const options: RequestInit = {
|
|
163
|
+
method,
|
|
164
|
+
headers,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (method === 'POST' && body) {
|
|
168
|
+
options.body = JSON.stringify(body)
|
|
169
|
+
options.headers = {
|
|
170
|
+
...options.headers,
|
|
171
|
+
'Content-Type': 'application/json',
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const response = await fetch(url, options)
|
|
176
|
+
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
throw new Error(`Download failed: ${response.status} ${response.statusText}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const blob = await response.blob()
|
|
182
|
+
|
|
183
|
+
// Determine filename
|
|
184
|
+
const downloadFilename =
|
|
185
|
+
filename ||
|
|
186
|
+
extractFilename(response.headers) ||
|
|
187
|
+
`download-${Date.now()}.bin`
|
|
188
|
+
|
|
189
|
+
// Trigger download
|
|
190
|
+
triggerDownload(blob, downloadFilename)
|
|
191
|
+
|
|
192
|
+
setStatus('success')
|
|
193
|
+
onDownloadComplete?.(downloadFilename)
|
|
194
|
+
scheduleStatusReset()
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error('Download error:', error)
|
|
197
|
+
setStatus('error')
|
|
198
|
+
onDownloadError?.(error as Error)
|
|
199
|
+
scheduleStatusReset()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Simple download using window.open (fallback)
|
|
205
|
+
*/
|
|
206
|
+
const downloadWithWindowOpen = () => {
|
|
207
|
+
try {
|
|
208
|
+
setStatus('downloading')
|
|
209
|
+
onDownloadStart?.()
|
|
210
|
+
window.open(url, '_blank')
|
|
211
|
+
|
|
212
|
+
// We can't really track success/failure with window.open
|
|
213
|
+
// So just assume success after a short delay
|
|
214
|
+
setTimeout(() => {
|
|
215
|
+
setStatus('success')
|
|
216
|
+
onDownloadComplete?.(filename || 'file')
|
|
217
|
+
scheduleStatusReset()
|
|
218
|
+
}, 500)
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.error('Download error:', error)
|
|
221
|
+
setStatus('error')
|
|
222
|
+
onDownloadError?.(error as Error)
|
|
223
|
+
scheduleStatusReset()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Handle download click
|
|
229
|
+
*/
|
|
230
|
+
const handleDownload = () => {
|
|
231
|
+
if (status === 'downloading') return
|
|
232
|
+
|
|
233
|
+
if (useFetch) {
|
|
234
|
+
downloadWithFetch()
|
|
235
|
+
} else {
|
|
236
|
+
downloadWithWindowOpen()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Render status icon
|
|
242
|
+
*/
|
|
243
|
+
const renderStatusIcon = () => {
|
|
244
|
+
if (!showStatus) return null
|
|
245
|
+
|
|
246
|
+
switch (status) {
|
|
247
|
+
case 'downloading':
|
|
248
|
+
return <Loader2 className="animate-spin" />
|
|
249
|
+
case 'success':
|
|
250
|
+
return <CheckCircle2 className="text-green-600" />
|
|
251
|
+
case 'error':
|
|
252
|
+
return <AlertCircle className="text-red-600" />
|
|
253
|
+
default:
|
|
254
|
+
return <Download />
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<Button
|
|
260
|
+
ref={ref}
|
|
261
|
+
onClick={handleDownload}
|
|
262
|
+
disabled={disabled || status === 'downloading'}
|
|
263
|
+
className={cn(className)}
|
|
264
|
+
{...props}
|
|
265
|
+
>
|
|
266
|
+
{renderStatusIcon()}
|
|
267
|
+
{children}
|
|
268
|
+
</Button>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
DownloadButton.displayName = "DownloadButton"
|
|
274
|
+
|
|
275
|
+
export { DownloadButton }
|