@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,31 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useTheme } from "next-themes"
|
|
4
|
+
import { Toaster as Sonner } from "sonner"
|
|
5
|
+
|
|
6
|
+
type ToasterProps = React.ComponentProps<typeof Sonner>
|
|
7
|
+
|
|
8
|
+
const Toaster = ({ ...props }: ToasterProps) => {
|
|
9
|
+
const { theme = "system" } = useTheme()
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<Sonner
|
|
13
|
+
theme={theme as ToasterProps["theme"]}
|
|
14
|
+
className="toaster group"
|
|
15
|
+
toastOptions={{
|
|
16
|
+
classNames: {
|
|
17
|
+
toast:
|
|
18
|
+
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
|
19
|
+
description: "group-[.toast]:text-muted-foreground",
|
|
20
|
+
actionButton:
|
|
21
|
+
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
|
22
|
+
cancelButton:
|
|
23
|
+
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
|
24
|
+
},
|
|
25
|
+
}}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { Toaster }
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
import { useIsMobile } from '@djangocfg/ui-core/hooks';
|
|
8
|
+
import { useQueryParams } from '../hooks';
|
|
9
|
+
import {
|
|
10
|
+
Pagination,
|
|
11
|
+
PaginationContent,
|
|
12
|
+
PaginationEllipsis,
|
|
13
|
+
PaginationItem,
|
|
14
|
+
PaginationLink,
|
|
15
|
+
PaginationNext,
|
|
16
|
+
PaginationPrevious,
|
|
17
|
+
} from './pagination';
|
|
18
|
+
|
|
19
|
+
interface SSRPaginationProps {
|
|
20
|
+
currentPage: number;
|
|
21
|
+
totalPages: number;
|
|
22
|
+
totalItems: number;
|
|
23
|
+
itemsPerPage: number;
|
|
24
|
+
hasNextPage: boolean;
|
|
25
|
+
hasPreviousPage: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
showInfo?: boolean;
|
|
28
|
+
maxVisiblePages?: number;
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
pathname?: string;
|
|
31
|
+
preserveQuery?: boolean;
|
|
32
|
+
prefetch?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const SSRPagination: React.FC<SSRPaginationProps> = ({
|
|
36
|
+
currentPage,
|
|
37
|
+
totalPages,
|
|
38
|
+
totalItems,
|
|
39
|
+
itemsPerPage,
|
|
40
|
+
hasNextPage,
|
|
41
|
+
hasPreviousPage,
|
|
42
|
+
className,
|
|
43
|
+
showInfo = true,
|
|
44
|
+
maxVisiblePages = 7,
|
|
45
|
+
baseUrl,
|
|
46
|
+
pathname: propPathname,
|
|
47
|
+
preserveQuery = true,
|
|
48
|
+
prefetch = true,
|
|
49
|
+
}) => {
|
|
50
|
+
const queryParams = useQueryParams();
|
|
51
|
+
const pathname = usePathname();
|
|
52
|
+
const isMobile = useIsMobile();
|
|
53
|
+
|
|
54
|
+
// Get current page from URL if available, otherwise use prop
|
|
55
|
+
const getCurrentPageFromUrl = (): number => {
|
|
56
|
+
const pageParam = queryParams.get('page');
|
|
57
|
+
if (pageParam) {
|
|
58
|
+
const pageNum = parseInt(pageParam, 10);
|
|
59
|
+
return isNaN(pageNum) ? 1 : pageNum;
|
|
60
|
+
}
|
|
61
|
+
return 1;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const actualCurrentPage = getCurrentPageFromUrl() || currentPage;
|
|
65
|
+
|
|
66
|
+
// Calculate actual navigation state based on current page from URL
|
|
67
|
+
const actualHasPreviousPage = actualCurrentPage > 1;
|
|
68
|
+
|
|
69
|
+
// Smart total pages calculation - if we're on a page higher than totalPages,
|
|
70
|
+
// extend totalPages to include current page + some extra
|
|
71
|
+
const smartTotalPages = Math.max(
|
|
72
|
+
totalPages,
|
|
73
|
+
actualCurrentPage + (hasNextPage ? 5 : 0)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const actualHasNextPage = hasNextPage;
|
|
77
|
+
|
|
78
|
+
// Generate URL for a specific page
|
|
79
|
+
const getPageUrl = (page: number): string => {
|
|
80
|
+
if (baseUrl) {
|
|
81
|
+
return `${baseUrl}?page=${page}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Use current route with updated page parameter
|
|
85
|
+
const newSearchParams = preserveQuery
|
|
86
|
+
? new URLSearchParams(queryParams.toString())
|
|
87
|
+
: new URLSearchParams();
|
|
88
|
+
|
|
89
|
+
newSearchParams.set('page', page.toString());
|
|
90
|
+
|
|
91
|
+
// Remove page=1 from URL to keep URLs clean
|
|
92
|
+
if (page === 1) {
|
|
93
|
+
newSearchParams.delete('page');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const queryString = newSearchParams.toString();
|
|
97
|
+
const basePath = propPathname || pathname || '';
|
|
98
|
+
return queryString ? `${basePath}?${queryString}` : basePath;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Generate array of page numbers to display
|
|
102
|
+
const getVisiblePages = (): (number | 'ellipsis')[] => {
|
|
103
|
+
// On mobile, show fewer pages
|
|
104
|
+
const mobileMaxVisible = 3;
|
|
105
|
+
const effectiveMaxVisible = isMobile ? mobileMaxVisible : maxVisiblePages;
|
|
106
|
+
|
|
107
|
+
if (smartTotalPages <= effectiveMaxVisible) {
|
|
108
|
+
return Array.from({ length: smartTotalPages }, (_, i) => i + 1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pages: (number | 'ellipsis')[] = [];
|
|
112
|
+
const halfVisible = Math.floor(effectiveMaxVisible / 2);
|
|
113
|
+
|
|
114
|
+
if (isMobile) {
|
|
115
|
+
// Mobile: Show only current page and adjacent pages
|
|
116
|
+
if (actualCurrentPage > 1) {
|
|
117
|
+
pages.push(actualCurrentPage - 1);
|
|
118
|
+
}
|
|
119
|
+
pages.push(actualCurrentPage);
|
|
120
|
+
if (actualCurrentPage < smartTotalPages) {
|
|
121
|
+
pages.push(actualCurrentPage + 1);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
// Desktop: Full pagination logic
|
|
125
|
+
// Always show first page
|
|
126
|
+
pages.push(1);
|
|
127
|
+
|
|
128
|
+
let start = Math.max(2, actualCurrentPage - halfVisible);
|
|
129
|
+
let end = Math.min(smartTotalPages - 1, actualCurrentPage + halfVisible);
|
|
130
|
+
|
|
131
|
+
// Adjust range if we're near the beginning or end
|
|
132
|
+
if (actualCurrentPage <= halfVisible + 1) {
|
|
133
|
+
end = Math.min(smartTotalPages - 1, effectiveMaxVisible - 1);
|
|
134
|
+
} else if (actualCurrentPage >= smartTotalPages - halfVisible) {
|
|
135
|
+
start = Math.max(2, smartTotalPages - effectiveMaxVisible + 2);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Add ellipsis after first page if needed
|
|
139
|
+
if (start > 2) {
|
|
140
|
+
pages.push('ellipsis');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Add middle pages
|
|
144
|
+
for (let i = start; i <= end; i++) {
|
|
145
|
+
pages.push(i);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Add ellipsis before last page if needed
|
|
149
|
+
if (end < smartTotalPages - 1) {
|
|
150
|
+
pages.push('ellipsis');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Always show last page (if more than 1 page)
|
|
154
|
+
if (smartTotalPages > 1) {
|
|
155
|
+
pages.push(smartTotalPages);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return pages;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Don't render if there's only one page or no pages
|
|
163
|
+
if (smartTotalPages <= 1) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const visiblePages = getVisiblePages();
|
|
168
|
+
const startItem = (actualCurrentPage - 1) * itemsPerPage + 1;
|
|
169
|
+
const endItem = Math.min(actualCurrentPage * itemsPerPage, totalItems);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div className={cn("space-y-4", className)}>
|
|
173
|
+
{/* Pagination Info */}
|
|
174
|
+
{showInfo && (
|
|
175
|
+
<div className="text-sm text-muted-foreground text-center">
|
|
176
|
+
{isMobile ? (
|
|
177
|
+
`Page ${actualCurrentPage} of ${smartTotalPages}`
|
|
178
|
+
) : (
|
|
179
|
+
`Showing ${startItem.toLocaleString()} to ${endItem.toLocaleString()} of ${totalItems.toLocaleString()} results`
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{/* Pagination Controls */}
|
|
185
|
+
<Pagination>
|
|
186
|
+
<PaginationContent>
|
|
187
|
+
{/* Previous Button */}
|
|
188
|
+
<PaginationItem>
|
|
189
|
+
{actualHasPreviousPage ? (
|
|
190
|
+
<Link href={getPageUrl(actualCurrentPage - 1)} prefetch={prefetch} passHref legacyBehavior>
|
|
191
|
+
<PaginationPrevious />
|
|
192
|
+
</Link>
|
|
193
|
+
) : (
|
|
194
|
+
<PaginationPrevious
|
|
195
|
+
href="#"
|
|
196
|
+
className="pointer-events-none opacity-50"
|
|
197
|
+
onClick={(e) => e.preventDefault()}
|
|
198
|
+
/>
|
|
199
|
+
)}
|
|
200
|
+
</PaginationItem>
|
|
201
|
+
|
|
202
|
+
{/* Page Numbers */}
|
|
203
|
+
{visiblePages.map((page, index) => (
|
|
204
|
+
<PaginationItem key={index}>
|
|
205
|
+
{page === 'ellipsis' ? (
|
|
206
|
+
<PaginationEllipsis />
|
|
207
|
+
) : (
|
|
208
|
+
<Link href={getPageUrl(page)} prefetch={prefetch} passHref legacyBehavior>
|
|
209
|
+
<PaginationLink isActive={page === actualCurrentPage}>
|
|
210
|
+
{page}
|
|
211
|
+
</PaginationLink>
|
|
212
|
+
</Link>
|
|
213
|
+
)}
|
|
214
|
+
</PaginationItem>
|
|
215
|
+
))}
|
|
216
|
+
|
|
217
|
+
{/* Next Button */}
|
|
218
|
+
<PaginationItem>
|
|
219
|
+
{hasNextPage ? (
|
|
220
|
+
<Link href={getPageUrl(actualCurrentPage + 1)} prefetch={prefetch} passHref legacyBehavior>
|
|
221
|
+
<PaginationNext />
|
|
222
|
+
</Link>
|
|
223
|
+
) : (
|
|
224
|
+
<PaginationNext
|
|
225
|
+
href="#"
|
|
226
|
+
className="pointer-events-none opacity-50"
|
|
227
|
+
onClick={(e) => e.preventDefault()}
|
|
228
|
+
/>
|
|
229
|
+
)}
|
|
230
|
+
</PaginationItem>
|
|
231
|
+
</PaginationContent>
|
|
232
|
+
</Pagination>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
SSRPagination.displayName = 'SSRPagination';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Re-export all hooks from @djangocfg/ui-core
|
|
3
|
+
// ============================================================================
|
|
4
|
+
export * from '@djangocfg/ui-core/hooks';
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Next.js/Browser specific hooks
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
// Storage hooks (browser localStorage/sessionStorage)
|
|
11
|
+
export { useLocalStorage } from './useLocalStorage';
|
|
12
|
+
export { useSessionStorage } from './useSessionStorage';
|
|
13
|
+
|
|
14
|
+
// Theme hook (uses next-themes)
|
|
15
|
+
export { useTheme } from './useTheme';
|
|
16
|
+
|
|
17
|
+
// Next.js router hooks
|
|
18
|
+
export { useQueryParams } from './useQueryParams';
|
|
19
|
+
export { useCfgRouter } from './useCfgRouter';
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal Router Hook with BasePath Support
|
|
3
|
+
*
|
|
4
|
+
* Wrapper around Next.js useRouter that automatically handles basePath
|
|
5
|
+
* for static builds served via iframe or subdirectory
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: In Next.js 15 App Router, router.push() does NOT automatically
|
|
8
|
+
* handle basePath (unlike Pages Router). This is a breaking change in App Router.
|
|
9
|
+
*
|
|
10
|
+
* This hook ensures basePath is always included when navigating, especially
|
|
11
|
+
* important for static exports served via iframe where basePath is critical.
|
|
12
|
+
*
|
|
13
|
+
* @see https://nextjs.org/docs/app/building-your-application/upgrading/app-router-migration
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use client';
|
|
17
|
+
|
|
18
|
+
import { useRouter as useNextRouter } from 'next/navigation';
|
|
19
|
+
import { useCallback, useMemo } from 'react';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get base path from environment variable
|
|
23
|
+
*/
|
|
24
|
+
function getBasePath(): string {
|
|
25
|
+
if (typeof process === 'undefined') {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
return process.env.NEXT_PUBLIC_BASE_PATH || '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Add base path to a route path
|
|
33
|
+
*/
|
|
34
|
+
function withBasePath(path: string, basePath: string): string {
|
|
35
|
+
if (!basePath) {
|
|
36
|
+
return path;
|
|
37
|
+
}
|
|
38
|
+
// Ensure path starts with /
|
|
39
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
40
|
+
// Remove trailing slash from basePath
|
|
41
|
+
const normalizedBasePath = basePath.replace(/\/$/, '');
|
|
42
|
+
return `${normalizedBasePath}${normalizedPath}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Router with basePath support
|
|
47
|
+
*
|
|
48
|
+
* Automatically adds basePath to all navigation methods when basePath is configured.
|
|
49
|
+
* In Next.js 15 App Router, router.push() doesn't handle basePath automatically,
|
|
50
|
+
* so this hook uses window.location to ensure basePath is always included.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```tsx
|
|
54
|
+
* const router = useCfgRouter();
|
|
55
|
+
*
|
|
56
|
+
* // With basePath='/cfg/admin':
|
|
57
|
+
* router.push('/dashboard'); // Client-side navigation to '/cfg/admin/dashboard'
|
|
58
|
+
* router.replace('/auth'); // Client-side replace with '/cfg/admin/auth'
|
|
59
|
+
* router.hardPush('/dashboard'); // Full page reload to '/cfg/admin/dashboard'
|
|
60
|
+
* router.hardReplace('/auth'); // Full page replace with '/cfg/admin/auth'
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function useCfgRouter() {
|
|
64
|
+
const router = useNextRouter();
|
|
65
|
+
|
|
66
|
+
// Get basePath and check if we're in static build mode
|
|
67
|
+
const basePath = useMemo(() => getBasePath(), []);
|
|
68
|
+
const isStaticBuild = useMemo(() => {
|
|
69
|
+
return typeof process !== 'undefined' && process.env.NEXT_PUBLIC_STATIC_BUILD === 'true';
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const push = useCallback((href: string, options?: { scroll?: boolean }) => {
|
|
73
|
+
if (basePath) {
|
|
74
|
+
// App Router doesn't handle basePath automatically, use window.location
|
|
75
|
+
window.location.href = withBasePath(href, basePath);
|
|
76
|
+
} else {
|
|
77
|
+
// No basePath configured, use standard router
|
|
78
|
+
router.push(href, options);
|
|
79
|
+
}
|
|
80
|
+
}, [router, basePath]);
|
|
81
|
+
|
|
82
|
+
const replace = useCallback((href: string, options?: { scroll?: boolean }) => {
|
|
83
|
+
if (basePath) {
|
|
84
|
+
// App Router doesn't handle basePath automatically, use window.location
|
|
85
|
+
window.location.replace(withBasePath(href, basePath));
|
|
86
|
+
} else {
|
|
87
|
+
// No basePath configured, use standard router
|
|
88
|
+
router.replace(href, options);
|
|
89
|
+
}
|
|
90
|
+
}, [router, basePath]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Hard push - always uses window.location.href for full page reload
|
|
94
|
+
*
|
|
95
|
+
* Use this for auth redirects where React contexts need to reinitialize.
|
|
96
|
+
* Unlike push(), this ALWAYS triggers a full page reload, ensuring all
|
|
97
|
+
* contexts are reinitialized with fresh state.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```tsx
|
|
101
|
+
* // After successful login - contexts need to reload with new auth state
|
|
102
|
+
* router.hardPush('/dashboard');
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
const hardPush = useCallback((href: string) => {
|
|
106
|
+
window.location.href = withBasePath(href, basePath);
|
|
107
|
+
}, [basePath]);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Hard replace - always uses window.location.replace for full page reload
|
|
111
|
+
*
|
|
112
|
+
* Same as hardPush but replaces current history entry.
|
|
113
|
+
* Use for auth redirects where you don't want back button to return to login.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```tsx
|
|
117
|
+
* // After logout - replace so back button doesn't go to protected page
|
|
118
|
+
* router.hardReplace('/auth');
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
const hardReplace = useCallback((href: string) => {
|
|
122
|
+
window.location.replace(withBasePath(href, basePath));
|
|
123
|
+
}, [basePath]);
|
|
124
|
+
|
|
125
|
+
const prefetch = useCallback((href: string) => {
|
|
126
|
+
// Prefetch doesn't need basePath handling, Next.js handles it
|
|
127
|
+
router.prefetch(href);
|
|
128
|
+
}, [router]);
|
|
129
|
+
|
|
130
|
+
const back = useCallback(() => {
|
|
131
|
+
router.back();
|
|
132
|
+
}, [router]);
|
|
133
|
+
|
|
134
|
+
const forward = useCallback(() => {
|
|
135
|
+
router.forward();
|
|
136
|
+
}, [router]);
|
|
137
|
+
|
|
138
|
+
const refresh = useCallback(() => {
|
|
139
|
+
router.refresh();
|
|
140
|
+
}, [router]);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
push,
|
|
144
|
+
replace,
|
|
145
|
+
hardPush,
|
|
146
|
+
hardReplace,
|
|
147
|
+
prefetch,
|
|
148
|
+
back,
|
|
149
|
+
forward,
|
|
150
|
+
refresh,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Simple localStorage hook with better error handling
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: To prevent hydration mismatch, this hook:
|
|
9
|
+
* - Always returns initialValue on first render (same as SSR)
|
|
10
|
+
* - Reads from localStorage only after component mounts
|
|
11
|
+
*
|
|
12
|
+
* @param key - Storage key
|
|
13
|
+
* @param initialValue - Default value if key doesn't exist
|
|
14
|
+
* @returns [value, setValue, removeValue] - Current value, setter function, and remove function
|
|
15
|
+
*/
|
|
16
|
+
export function useLocalStorage<T>(key: string, initialValue: T) {
|
|
17
|
+
// Always start with initialValue to match SSR
|
|
18
|
+
const [storedValue, setStoredValue] = useState<T>(initialValue);
|
|
19
|
+
const [isHydrated, setIsHydrated] = useState(false);
|
|
20
|
+
const isInitialized = useRef(false);
|
|
21
|
+
|
|
22
|
+
// Read from localStorage after mount (avoids hydration mismatch)
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (isInitialized.current) return;
|
|
25
|
+
isInitialized.current = true;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const item = window.localStorage.getItem(key);
|
|
29
|
+
if (item !== null) {
|
|
30
|
+
// Try to parse as JSON first, fallback to string
|
|
31
|
+
try {
|
|
32
|
+
setStoredValue(JSON.parse(item));
|
|
33
|
+
} catch {
|
|
34
|
+
// If JSON.parse fails, return as string
|
|
35
|
+
setStoredValue(item as T);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error(`Error reading localStorage key "${key}":`, error);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setIsHydrated(true);
|
|
43
|
+
}, [key]);
|
|
44
|
+
|
|
45
|
+
// Check data size and limit
|
|
46
|
+
const checkDataSize = (data: any): boolean => {
|
|
47
|
+
try {
|
|
48
|
+
const jsonString = JSON.stringify(data);
|
|
49
|
+
const sizeInBytes = new Blob([jsonString]).size;
|
|
50
|
+
const sizeInKB = sizeInBytes / 1024;
|
|
51
|
+
|
|
52
|
+
// Limit to 1MB per item
|
|
53
|
+
if (sizeInKB > 1024) {
|
|
54
|
+
console.warn(`Data size (${sizeInKB.toFixed(2)}KB) exceeds 1MB limit for key "${key}"`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return true;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error(`Error checking data size for key "${key}":`, error);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Clear old data when localStorage is full
|
|
66
|
+
const clearOldData = () => {
|
|
67
|
+
try {
|
|
68
|
+
const keys = Object.keys(localStorage).filter(key => key && typeof key === 'string');
|
|
69
|
+
// Remove oldest items if we have more than 50 items
|
|
70
|
+
if (keys.length > 50) {
|
|
71
|
+
const itemsToRemove = Math.ceil(keys.length * 0.2);
|
|
72
|
+
for (let i = 0; i < itemsToRemove; i++) {
|
|
73
|
+
try {
|
|
74
|
+
const key = keys[i];
|
|
75
|
+
if (key) {
|
|
76
|
+
localStorage.removeItem(key);
|
|
77
|
+
localStorage.removeItem(`${key}_timestamp`);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Ignore errors when removing items
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error('Error clearing old localStorage data:', error);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Force clear all data if quota is exceeded
|
|
90
|
+
const forceClearAll = () => {
|
|
91
|
+
try {
|
|
92
|
+
const keys = Object.keys(localStorage);
|
|
93
|
+
for (const key of keys) {
|
|
94
|
+
try {
|
|
95
|
+
localStorage.removeItem(key);
|
|
96
|
+
} catch {
|
|
97
|
+
// Ignore errors when removing items
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error('Error force clearing localStorage:', error);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Update localStorage when value changes
|
|
106
|
+
const setValue = (value: T | ((val: T) => T)) => {
|
|
107
|
+
try {
|
|
108
|
+
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
|
109
|
+
|
|
110
|
+
// Check data size before attempting to save
|
|
111
|
+
if (!checkDataSize(valueToStore)) {
|
|
112
|
+
console.warn(`Data size too large for key "${key}", removing key`);
|
|
113
|
+
// Remove the key if data is too large
|
|
114
|
+
try {
|
|
115
|
+
window.localStorage.removeItem(key);
|
|
116
|
+
window.localStorage.removeItem(`${key}_timestamp`);
|
|
117
|
+
} catch {
|
|
118
|
+
// Ignore errors when removing
|
|
119
|
+
}
|
|
120
|
+
// Still update the state
|
|
121
|
+
setStoredValue(valueToStore);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
setStoredValue(valueToStore);
|
|
126
|
+
|
|
127
|
+
if (typeof window !== 'undefined') {
|
|
128
|
+
// Try to set the value
|
|
129
|
+
try {
|
|
130
|
+
// For strings, store directly without JSON.stringify
|
|
131
|
+
if (typeof valueToStore === 'string') {
|
|
132
|
+
window.localStorage.setItem(key, valueToStore);
|
|
133
|
+
} else {
|
|
134
|
+
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
|
135
|
+
}
|
|
136
|
+
// Add timestamp for cleanup
|
|
137
|
+
window.localStorage.setItem(`${key}_timestamp`, Date.now().toString());
|
|
138
|
+
} catch (storageError: any) {
|
|
139
|
+
// If quota exceeded, clear old data and try again
|
|
140
|
+
if (storageError.name === 'QuotaExceededError' ||
|
|
141
|
+
storageError.code === 22 ||
|
|
142
|
+
storageError.message?.includes('quota')) {
|
|
143
|
+
console.warn('localStorage quota exceeded, clearing old data...');
|
|
144
|
+
clearOldData();
|
|
145
|
+
|
|
146
|
+
// Try again after clearing
|
|
147
|
+
try {
|
|
148
|
+
// For strings, store directly without JSON.stringify
|
|
149
|
+
if (typeof valueToStore === 'string') {
|
|
150
|
+
window.localStorage.setItem(key, valueToStore);
|
|
151
|
+
} else {
|
|
152
|
+
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
|
153
|
+
}
|
|
154
|
+
window.localStorage.setItem(`${key}_timestamp`, Date.now().toString());
|
|
155
|
+
} catch (retryError) {
|
|
156
|
+
console.error(`Failed to set localStorage key "${key}" after clearing old data:`, retryError);
|
|
157
|
+
// If still fails, force clear all and try one more time
|
|
158
|
+
try {
|
|
159
|
+
forceClearAll();
|
|
160
|
+
// For strings, store directly without JSON.stringify
|
|
161
|
+
if (typeof valueToStore === 'string') {
|
|
162
|
+
window.localStorage.setItem(key, valueToStore);
|
|
163
|
+
} else {
|
|
164
|
+
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
|
165
|
+
}
|
|
166
|
+
window.localStorage.setItem(`${key}_timestamp`, Date.now().toString());
|
|
167
|
+
} catch (finalError) {
|
|
168
|
+
console.error(`Failed to set localStorage key "${key}" after force clearing:`, finalError);
|
|
169
|
+
// If still fails, just update the state without localStorage
|
|
170
|
+
setStoredValue(valueToStore);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
throw storageError;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error(`Error setting localStorage key "${key}":`, error);
|
|
180
|
+
// Still update the state even if localStorage fails
|
|
181
|
+
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
|
182
|
+
setStoredValue(valueToStore);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Remove value from localStorage
|
|
187
|
+
const removeValue = () => {
|
|
188
|
+
try {
|
|
189
|
+
setStoredValue(initialValue);
|
|
190
|
+
if (typeof window !== 'undefined') {
|
|
191
|
+
try {
|
|
192
|
+
window.localStorage.removeItem(key);
|
|
193
|
+
window.localStorage.removeItem(`${key}_timestamp`);
|
|
194
|
+
} catch (removeError: any) {
|
|
195
|
+
// If removal fails due to quota, try to clear some data first
|
|
196
|
+
if (removeError.name === 'QuotaExceededError' ||
|
|
197
|
+
removeError.code === 22 ||
|
|
198
|
+
removeError.message?.includes('quota')) {
|
|
199
|
+
console.warn('localStorage quota exceeded during removal, clearing old data...');
|
|
200
|
+
clearOldData();
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
window.localStorage.removeItem(key);
|
|
204
|
+
window.localStorage.removeItem(`${key}_timestamp`);
|
|
205
|
+
} catch (retryError) {
|
|
206
|
+
console.error(`Failed to remove localStorage key "${key}" after clearing:`, retryError);
|
|
207
|
+
// If still fails, force clear all
|
|
208
|
+
forceClearAll();
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
throw removeError;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
console.error(`Error removing localStorage key "${key}":`, error);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return [storedValue, setValue, removeValue] as const;
|
|
221
|
+
}
|