@djangocfg/ui-core 2.1.89 → 2.1.91
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/README.md +8 -9
- package/package.json +5 -3
- package/src/components/button-download.tsx +276 -0
- package/src/components/dropdown-menu.tsx +219 -0
- package/src/components/index.ts +16 -2
- package/src/components/menubar.tsx +274 -0
- package/src/components/multi-select-pro/async.tsx +600 -0
- package/src/components/multi-select-pro/helpers.tsx +84 -0
- package/src/components/multi-select-pro/index.tsx +613 -0
- package/src/components/navigation-menu.tsx +153 -0
- package/src/components/otp/index.tsx +198 -0
- package/src/components/otp/types.ts +133 -0
- package/src/components/otp/use-otp-input.ts +225 -0
- package/src/components/phone-input.tsx +277 -0
- package/src/components/sonner.tsx +32 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useCopy.ts +2 -10
- package/src/hooks/useLocalStorage.ts +300 -0
- package/src/hooks/useResolvedTheme.ts +68 -0
- package/src/hooks/useSessionStorage.ts +290 -0
- package/src/hooks/useToast.ts +20 -244
- package/src/lib/index.ts +1 -0
- package/src/lib/logger/index.ts +10 -0
- package/src/lib/logger/logStore.ts +122 -0
- package/src/lib/logger/logger.ts +175 -0
- package/src/lib/logger/types.ts +82 -0
- package/src/utils/LazyComponent.tsx +116 -0
- package/src/utils/index.ts +9 -0
- package/src/components/toast.tsx +0 -144
- package/src/components/toaster.tsx +0 -41
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AsYouType, CountryCode, getCountries, getCountryCallingCode, parsePhoneNumberFromString
|
|
5
|
+
} from 'libphonenumber-js';
|
|
6
|
+
import { ChevronDown, Search } from 'lucide-react';
|
|
7
|
+
import * as React from 'react';
|
|
8
|
+
|
|
9
|
+
import { Button } from './button';
|
|
10
|
+
import { Input } from './input';
|
|
11
|
+
import { cn } from '../lib';
|
|
12
|
+
import {
|
|
13
|
+
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger
|
|
14
|
+
} from './dropdown-menu';
|
|
15
|
+
|
|
16
|
+
// Generate country flag emoji from country code
|
|
17
|
+
const getCountryFlag = (countryCode: CountryCode): string => {
|
|
18
|
+
return countryCode
|
|
19
|
+
.toUpperCase()
|
|
20
|
+
.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Get country name from country code using browser's built-in Intl.DisplayNames
|
|
24
|
+
const getCountryName = (countryCode: CountryCode): string => {
|
|
25
|
+
try {
|
|
26
|
+
const displayNames = new Intl.DisplayNames(['en'], { type: 'region' })
|
|
27
|
+
return displayNames.of(countryCode) || countryCode
|
|
28
|
+
} catch {
|
|
29
|
+
// Fallback for unsupported country codes
|
|
30
|
+
return countryCode
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Generate all countries from libphonenumber-js
|
|
35
|
+
const getAllCountries = () => {
|
|
36
|
+
return getCountries().map(countryCode => ({
|
|
37
|
+
code: countryCode,
|
|
38
|
+
name: getCountryName(countryCode),
|
|
39
|
+
flag: getCountryFlag(countryCode),
|
|
40
|
+
dialCode: `+${getCountryCallingCode(countryCode)}`
|
|
41
|
+
})).sort((a, b) => a.name.localeCompare(b.name))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const COUNTRIES = getAllCountries()
|
|
45
|
+
|
|
46
|
+
export interface PhoneInputProps {
|
|
47
|
+
value?: string
|
|
48
|
+
onChange?: (value: string | undefined) => void
|
|
49
|
+
defaultCountry?: CountryCode
|
|
50
|
+
className?: string
|
|
51
|
+
placeholder?: string
|
|
52
|
+
disabled?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
|
|
56
|
+
({
|
|
57
|
+
className,
|
|
58
|
+
value = '',
|
|
59
|
+
onChange,
|
|
60
|
+
defaultCountry = 'US',
|
|
61
|
+
placeholder = "Enter phone number",
|
|
62
|
+
disabled = false,
|
|
63
|
+
...props
|
|
64
|
+
}, ref) => {
|
|
65
|
+
const [selectedCountry, setSelectedCountry] = React.useState<CountryCode>(defaultCountry)
|
|
66
|
+
const [inputValue, setInputValue] = React.useState('')
|
|
67
|
+
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false)
|
|
68
|
+
const [searchQuery, setSearchQuery] = React.useState('')
|
|
69
|
+
const [highlightedIndex, setHighlightedIndex] = React.useState(-1)
|
|
70
|
+
|
|
71
|
+
// Find country data
|
|
72
|
+
const currentCountry = COUNTRIES.find(c => c.code === selectedCountry) || COUNTRIES[0]!
|
|
73
|
+
|
|
74
|
+
// Filter countries based on search query
|
|
75
|
+
const filteredCountries = React.useMemo(() => {
|
|
76
|
+
if (!searchQuery.trim()) return COUNTRIES
|
|
77
|
+
|
|
78
|
+
const query = searchQuery.toLowerCase()
|
|
79
|
+
return COUNTRIES.filter(country =>
|
|
80
|
+
country.name.toLowerCase().includes(query) ||
|
|
81
|
+
country.dialCode.includes(query) ||
|
|
82
|
+
country.code.toLowerCase().includes(query)
|
|
83
|
+
)
|
|
84
|
+
}, [searchQuery])
|
|
85
|
+
|
|
86
|
+
// Initialize input value from props
|
|
87
|
+
React.useEffect(() => {
|
|
88
|
+
if (value) {
|
|
89
|
+
try {
|
|
90
|
+
const phoneNumber = parsePhoneNumberFromString(value)
|
|
91
|
+
if (phoneNumber) {
|
|
92
|
+
setSelectedCountry(phoneNumber.country || defaultCountry)
|
|
93
|
+
setInputValue(phoneNumber.nationalNumber)
|
|
94
|
+
} else {
|
|
95
|
+
setInputValue(value)
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
setInputValue(value)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}, [value, defaultCountry])
|
|
102
|
+
|
|
103
|
+
// Reset highlighted index when filtered countries change
|
|
104
|
+
React.useEffect(() => {
|
|
105
|
+
setHighlightedIndex(-1)
|
|
106
|
+
}, [filteredCountries])
|
|
107
|
+
|
|
108
|
+
// Reset search when dropdown closes
|
|
109
|
+
React.useEffect(() => {
|
|
110
|
+
if (!isDropdownOpen) {
|
|
111
|
+
setSearchQuery('')
|
|
112
|
+
setHighlightedIndex(-1)
|
|
113
|
+
}
|
|
114
|
+
}, [isDropdownOpen])
|
|
115
|
+
|
|
116
|
+
// Handle country selection
|
|
117
|
+
const handleCountrySelect = (country: typeof COUNTRIES[0]) => {
|
|
118
|
+
setSelectedCountry(country.code)
|
|
119
|
+
setIsDropdownOpen(false)
|
|
120
|
+
setSearchQuery('')
|
|
121
|
+
setHighlightedIndex(-1)
|
|
122
|
+
|
|
123
|
+
// Format existing number for new country
|
|
124
|
+
if (inputValue) {
|
|
125
|
+
const formatter = new AsYouType(country.code)
|
|
126
|
+
const formatted = formatter.input(inputValue)
|
|
127
|
+
setInputValue(formatted)
|
|
128
|
+
|
|
129
|
+
// Get E.164 format for onChange
|
|
130
|
+
const phoneNumber = formatter.getNumber()
|
|
131
|
+
onChange?.(phoneNumber?.number)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Handle keyboard navigation
|
|
136
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
137
|
+
if (!isDropdownOpen) return
|
|
138
|
+
|
|
139
|
+
switch (e.key) {
|
|
140
|
+
case 'ArrowDown':
|
|
141
|
+
e.preventDefault()
|
|
142
|
+
setHighlightedIndex(prev =>
|
|
143
|
+
prev < filteredCountries.length - 1 ? prev + 1 : 0
|
|
144
|
+
)
|
|
145
|
+
break
|
|
146
|
+
case 'ArrowUp':
|
|
147
|
+
e.preventDefault()
|
|
148
|
+
setHighlightedIndex(prev =>
|
|
149
|
+
prev > 0 ? prev - 1 : filteredCountries.length - 1
|
|
150
|
+
)
|
|
151
|
+
break
|
|
152
|
+
case 'Enter':
|
|
153
|
+
e.preventDefault()
|
|
154
|
+
if (highlightedIndex >= 0 && highlightedIndex < filteredCountries.length) {
|
|
155
|
+
handleCountrySelect(filteredCountries[highlightedIndex]!)
|
|
156
|
+
}
|
|
157
|
+
break
|
|
158
|
+
case 'Escape':
|
|
159
|
+
e.preventDefault()
|
|
160
|
+
setIsDropdownOpen(false)
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Handle input change
|
|
166
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
167
|
+
const input = e.target.value
|
|
168
|
+
|
|
169
|
+
// Use AsYouType formatter for real-time formatting
|
|
170
|
+
const formatter = new AsYouType(selectedCountry)
|
|
171
|
+
const formatted = formatter.input(input)
|
|
172
|
+
|
|
173
|
+
setInputValue(formatted)
|
|
174
|
+
|
|
175
|
+
// Get the parsed phone number for validation and E.164 format
|
|
176
|
+
const phoneNumber = formatter.getNumber()
|
|
177
|
+
onChange?.(phoneNumber?.number)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Handle paste events to extract phone numbers
|
|
181
|
+
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
182
|
+
const pastedText = e.clipboardData.getData('text')
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
// Try to parse as international number first
|
|
186
|
+
const phoneNumber = parsePhoneNumberFromString(pastedText)
|
|
187
|
+
if (phoneNumber) {
|
|
188
|
+
e.preventDefault()
|
|
189
|
+
setSelectedCountry(phoneNumber.country || selectedCountry)
|
|
190
|
+
setInputValue(phoneNumber.nationalNumber)
|
|
191
|
+
onChange?.(phoneNumber.number)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// Let default paste behavior handle it
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div className={cn("relative flex", className)} onKeyDown={handleKeyDown}>
|
|
201
|
+
{/* Country Dropdown */}
|
|
202
|
+
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
|
|
203
|
+
<DropdownMenuTrigger asChild>
|
|
204
|
+
<Button
|
|
205
|
+
variant="outline"
|
|
206
|
+
size="sm"
|
|
207
|
+
className="h-10 px-3 rounded-r-none border-r-0 flex items-center gap-2"
|
|
208
|
+
disabled={disabled}
|
|
209
|
+
>
|
|
210
|
+
<span className="text-base">{currentCountry.flag}</span>
|
|
211
|
+
<span className="text-sm font-mono">{currentCountry.dialCode}</span>
|
|
212
|
+
<ChevronDown className="h-3 w-3 opacity-50" />
|
|
213
|
+
</Button>
|
|
214
|
+
</DropdownMenuTrigger>
|
|
215
|
+
<DropdownMenuContent align="start" className="w-80 max-h-80 p-0">
|
|
216
|
+
{/* Search Input */}
|
|
217
|
+
<div className="p-2 border-b">
|
|
218
|
+
<div className="relative">
|
|
219
|
+
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
220
|
+
<Input
|
|
221
|
+
placeholder="Search countries..."
|
|
222
|
+
value={searchQuery}
|
|
223
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
224
|
+
className="pl-8 h-8"
|
|
225
|
+
autoFocus
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{/* Countries List */}
|
|
231
|
+
<div className="max-h-60 overflow-y-auto">
|
|
232
|
+
{filteredCountries.length === 0 ? (
|
|
233
|
+
<div className="p-4 text-sm text-muted-foreground text-center">
|
|
234
|
+
No countries found
|
|
235
|
+
</div>
|
|
236
|
+
) : (
|
|
237
|
+
filteredCountries.map((country, index) => (
|
|
238
|
+
<DropdownMenuItem
|
|
239
|
+
key={country.code}
|
|
240
|
+
onClick={() => handleCountrySelect(country)}
|
|
241
|
+
className={cn(
|
|
242
|
+
"flex items-center gap-3 px-3 py-2 cursor-pointer",
|
|
243
|
+
index === highlightedIndex && "bg-accent"
|
|
244
|
+
)}
|
|
245
|
+
>
|
|
246
|
+
<span className="text-base">{country.flag}</span>
|
|
247
|
+
<span className="flex-1 text-sm">{country.name}</span>
|
|
248
|
+
<span className="text-sm font-mono text-muted-foreground">
|
|
249
|
+
{country.dialCode}
|
|
250
|
+
</span>
|
|
251
|
+
</DropdownMenuItem>
|
|
252
|
+
))
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
</DropdownMenuContent>
|
|
256
|
+
</DropdownMenu>
|
|
257
|
+
|
|
258
|
+
{/* Phone Input */}
|
|
259
|
+
<Input
|
|
260
|
+
ref={ref}
|
|
261
|
+
type="tel"
|
|
262
|
+
value={inputValue}
|
|
263
|
+
onChange={handleInputChange}
|
|
264
|
+
onPaste={handlePaste}
|
|
265
|
+
placeholder={placeholder}
|
|
266
|
+
disabled={disabled}
|
|
267
|
+
className="rounded-l-none border-l-0 flex-1"
|
|
268
|
+
{...props}
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
PhoneInput.displayName = "PhoneInput"
|
|
276
|
+
|
|
277
|
+
export { PhoneInput }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Toaster as Sonner } from 'sonner';
|
|
4
|
+
import { useResolvedTheme } from '../hooks/useResolvedTheme';
|
|
5
|
+
|
|
6
|
+
type ToasterProps = React.ComponentProps<typeof Sonner>
|
|
7
|
+
|
|
8
|
+
const Toaster = ({ ...props }: ToasterProps) => {
|
|
9
|
+
const theme = useResolvedTheme()
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<Sonner
|
|
13
|
+
theme={theme as ToasterProps["theme"]}
|
|
14
|
+
className="toaster group"
|
|
15
|
+
richColors
|
|
16
|
+
toastOptions={{
|
|
17
|
+
classNames: {
|
|
18
|
+
toast:
|
|
19
|
+
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
|
20
|
+
description: "group-[.toast]:text-muted-foreground",
|
|
21
|
+
actionButton:
|
|
22
|
+
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
|
23
|
+
cancelButton:
|
|
24
|
+
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
|
25
|
+
},
|
|
26
|
+
}}
|
|
27
|
+
{...props}
|
|
28
|
+
/>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { Toaster }
|
package/src/hooks/index.ts
CHANGED
|
@@ -13,3 +13,7 @@ export { useMediaQuery } from './useMediaQuery';
|
|
|
13
13
|
export { useCopy } from './useCopy';
|
|
14
14
|
export { useImageLoader } from './useImageLoader';
|
|
15
15
|
export { useToast, toast } from './useToast';
|
|
16
|
+
export { useResolvedTheme } from './useResolvedTheme';
|
|
17
|
+
export type { ResolvedTheme } from './useResolvedTheme';
|
|
18
|
+
export { useLocalStorage } from './useLocalStorage';
|
|
19
|
+
export { useSessionStorage } from './useSessionStorage';
|
package/src/hooks/useCopy.ts
CHANGED
|
@@ -20,19 +20,11 @@ export const useCopy = (options: UseCopyOptions = {}) => {
|
|
|
20
20
|
const copyToClipboard = useCallback(async (text: string, customSuccessMessage?: string) => {
|
|
21
21
|
try {
|
|
22
22
|
await navigator.clipboard.writeText(text);
|
|
23
|
-
toast(
|
|
24
|
-
title: "Success!",
|
|
25
|
-
description: customSuccessMessage || successMessage,
|
|
26
|
-
variant: "default",
|
|
27
|
-
});
|
|
23
|
+
toast.success(customSuccessMessage || successMessage);
|
|
28
24
|
return true;
|
|
29
25
|
} catch (error) {
|
|
30
26
|
console.error('Failed to copy:', error);
|
|
31
|
-
toast(
|
|
32
|
-
title: "Error",
|
|
33
|
-
description: errorMessage,
|
|
34
|
-
variant: "destructive",
|
|
35
|
-
});
|
|
27
|
+
toast.error(errorMessage);
|
|
36
28
|
return false;
|
|
37
29
|
}
|
|
38
30
|
}, [toast, successMessage, errorMessage]);
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Storage wrapper format with metadata
|
|
7
|
+
* Used when TTL is specified
|
|
8
|
+
*/
|
|
9
|
+
interface StorageWrapper<T> {
|
|
10
|
+
_meta: {
|
|
11
|
+
createdAt: number;
|
|
12
|
+
ttl: number;
|
|
13
|
+
};
|
|
14
|
+
_value: T;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options for useLocalStorage hook
|
|
19
|
+
*/
|
|
20
|
+
export interface UseLocalStorageOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Time-to-live in milliseconds.
|
|
23
|
+
* After this time, value is considered expired and initialValue is returned.
|
|
24
|
+
* Data is automatically cleaned up on next read.
|
|
25
|
+
* @example 24 * 60 * 60 * 1000 // 24 hours
|
|
26
|
+
*/
|
|
27
|
+
ttl?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if data is in new wrapped format with _meta
|
|
32
|
+
*/
|
|
33
|
+
function isWrappedFormat<T>(data: unknown): data is StorageWrapper<T> {
|
|
34
|
+
return (
|
|
35
|
+
data !== null &&
|
|
36
|
+
typeof data === 'object' &&
|
|
37
|
+
'_meta' in data &&
|
|
38
|
+
'_value' in data &&
|
|
39
|
+
typeof (data as StorageWrapper<T>)._meta === 'object' &&
|
|
40
|
+
typeof (data as StorageWrapper<T>)._meta.createdAt === 'number'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if wrapped data is expired
|
|
46
|
+
*/
|
|
47
|
+
function isExpired<T>(wrapped: StorageWrapper<T>): boolean {
|
|
48
|
+
if (!wrapped._meta.ttl) return false;
|
|
49
|
+
const age = Date.now() - wrapped._meta.createdAt;
|
|
50
|
+
return age > wrapped._meta.ttl;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Simple localStorage hook with better error handling and optional TTL support
|
|
55
|
+
*
|
|
56
|
+
* IMPORTANT: To prevent hydration mismatch, this hook:
|
|
57
|
+
* - Always returns initialValue on first render (same as SSR)
|
|
58
|
+
* - Reads from localStorage only after component mounts
|
|
59
|
+
*
|
|
60
|
+
* @param key - Storage key
|
|
61
|
+
* @param initialValue - Default value if key doesn't exist
|
|
62
|
+
* @param options - Optional configuration (ttl for auto-expiration)
|
|
63
|
+
* @returns [value, setValue, removeValue] - Current value, setter function, and remove function
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* // Without TTL (backwards compatible)
|
|
67
|
+
* const [value, setValue] = useLocalStorage('key', 'default');
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* // With TTL (24 hours)
|
|
71
|
+
* const [value, setValue] = useLocalStorage('key', 'default', {
|
|
72
|
+
* ttl: 24 * 60 * 60 * 1000
|
|
73
|
+
* });
|
|
74
|
+
*/
|
|
75
|
+
export function useLocalStorage<T>(
|
|
76
|
+
key: string,
|
|
77
|
+
initialValue: T,
|
|
78
|
+
options?: UseLocalStorageOptions
|
|
79
|
+
) {
|
|
80
|
+
const ttl = options?.ttl;
|
|
81
|
+
// Always start with initialValue to match SSR
|
|
82
|
+
const [storedValue, setStoredValue] = useState<T>(initialValue);
|
|
83
|
+
const [isHydrated, setIsHydrated] = useState(false);
|
|
84
|
+
const isInitialized = useRef(false);
|
|
85
|
+
|
|
86
|
+
// Read from localStorage after mount (avoids hydration mismatch)
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (isInitialized.current) return;
|
|
89
|
+
isInitialized.current = true;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const item = window.localStorage.getItem(key);
|
|
93
|
+
if (item !== null) {
|
|
94
|
+
// Try to parse as JSON first, fallback to string
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(item);
|
|
97
|
+
|
|
98
|
+
// Check if new format with _meta
|
|
99
|
+
if (isWrappedFormat<T>(parsed)) {
|
|
100
|
+
// Check TTL expiration
|
|
101
|
+
if (isExpired(parsed)) {
|
|
102
|
+
// Expired! Clean up and use initial value
|
|
103
|
+
window.localStorage.removeItem(key);
|
|
104
|
+
// Keep initialValue (already set)
|
|
105
|
+
} else {
|
|
106
|
+
// Not expired, extract value
|
|
107
|
+
setStoredValue(parsed._value);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
// Old format (backwards compatible)
|
|
111
|
+
setStoredValue(parsed as T);
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// If JSON.parse fails, return as string
|
|
115
|
+
setStoredValue(item as T);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error(`Error reading localStorage key "${key}":`, error);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
setIsHydrated(true);
|
|
123
|
+
}, [key]);
|
|
124
|
+
|
|
125
|
+
// Check data size and limit
|
|
126
|
+
const checkDataSize = (data: any): boolean => {
|
|
127
|
+
try {
|
|
128
|
+
const jsonString = JSON.stringify(data);
|
|
129
|
+
const sizeInBytes = new Blob([jsonString]).size;
|
|
130
|
+
const sizeInKB = sizeInBytes / 1024;
|
|
131
|
+
|
|
132
|
+
// Limit to 1MB per item
|
|
133
|
+
if (sizeInKB > 1024) {
|
|
134
|
+
console.warn(`Data size (${sizeInKB.toFixed(2)}KB) exceeds 1MB limit for key "${key}"`);
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return true;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error(`Error checking data size for key "${key}":`, error);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Clear old data when localStorage is full
|
|
146
|
+
const clearOldData = () => {
|
|
147
|
+
try {
|
|
148
|
+
const keys = Object.keys(localStorage).filter(key => key && typeof key === 'string');
|
|
149
|
+
// Remove oldest items if we have more than 50 items
|
|
150
|
+
if (keys.length > 50) {
|
|
151
|
+
const itemsToRemove = Math.ceil(keys.length * 0.2);
|
|
152
|
+
for (let i = 0; i < itemsToRemove; i++) {
|
|
153
|
+
try {
|
|
154
|
+
const key = keys[i];
|
|
155
|
+
if (key) {
|
|
156
|
+
localStorage.removeItem(key);
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// Ignore errors when removing items
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.error('Error clearing old localStorage data:', error);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Force clear all data if quota is exceeded
|
|
169
|
+
const forceClearAll = () => {
|
|
170
|
+
try {
|
|
171
|
+
const keys = Object.keys(localStorage);
|
|
172
|
+
for (const key of keys) {
|
|
173
|
+
try {
|
|
174
|
+
localStorage.removeItem(key);
|
|
175
|
+
} catch {
|
|
176
|
+
// Ignore errors when removing items
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error('Error force clearing localStorage:', error);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Prepare data for storage (with or without TTL wrapper)
|
|
185
|
+
const prepareForStorage = (value: T): string => {
|
|
186
|
+
if (ttl) {
|
|
187
|
+
// Wrap with _meta for TTL support
|
|
188
|
+
const wrapped: StorageWrapper<T> = {
|
|
189
|
+
_meta: {
|
|
190
|
+
createdAt: Date.now(),
|
|
191
|
+
ttl,
|
|
192
|
+
},
|
|
193
|
+
_value: value,
|
|
194
|
+
};
|
|
195
|
+
return JSON.stringify(wrapped);
|
|
196
|
+
}
|
|
197
|
+
// Old format (no wrapper) - for strings, store directly
|
|
198
|
+
if (typeof value === 'string') {
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
return JSON.stringify(value);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Update localStorage when value changes
|
|
205
|
+
const setValue = (value: T | ((val: T) => T)) => {
|
|
206
|
+
try {
|
|
207
|
+
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
|
208
|
+
|
|
209
|
+
// Check data size before attempting to save
|
|
210
|
+
if (!checkDataSize(valueToStore)) {
|
|
211
|
+
console.warn(`Data size too large for key "${key}", removing key`);
|
|
212
|
+
// Remove the key if data is too large
|
|
213
|
+
try {
|
|
214
|
+
window.localStorage.removeItem(key);
|
|
215
|
+
} catch {
|
|
216
|
+
// Ignore errors when removing
|
|
217
|
+
}
|
|
218
|
+
// Still update the state
|
|
219
|
+
setStoredValue(valueToStore);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
setStoredValue(valueToStore);
|
|
224
|
+
|
|
225
|
+
if (typeof window !== 'undefined') {
|
|
226
|
+
const dataToStore = prepareForStorage(valueToStore);
|
|
227
|
+
|
|
228
|
+
// Try to set the value
|
|
229
|
+
try {
|
|
230
|
+
window.localStorage.setItem(key, dataToStore);
|
|
231
|
+
} catch (storageError: any) {
|
|
232
|
+
// If quota exceeded, clear old data and try again
|
|
233
|
+
if (storageError.name === 'QuotaExceededError' ||
|
|
234
|
+
storageError.code === 22 ||
|
|
235
|
+
storageError.message?.includes('quota')) {
|
|
236
|
+
console.warn('localStorage quota exceeded, clearing old data...');
|
|
237
|
+
clearOldData();
|
|
238
|
+
|
|
239
|
+
// Try again after clearing
|
|
240
|
+
try {
|
|
241
|
+
window.localStorage.setItem(key, dataToStore);
|
|
242
|
+
} catch (retryError) {
|
|
243
|
+
console.error(`Failed to set localStorage key "${key}" after clearing old data:`, retryError);
|
|
244
|
+
// If still fails, force clear all and try one more time
|
|
245
|
+
try {
|
|
246
|
+
forceClearAll();
|
|
247
|
+
window.localStorage.setItem(key, dataToStore);
|
|
248
|
+
} catch (finalError) {
|
|
249
|
+
console.error(`Failed to set localStorage key "${key}" after force clearing:`, finalError);
|
|
250
|
+
// If still fails, just update the state without localStorage
|
|
251
|
+
setStoredValue(valueToStore);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
throw storageError;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.error(`Error setting localStorage key "${key}":`, error);
|
|
261
|
+
// Still update the state even if localStorage fails
|
|
262
|
+
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
|
263
|
+
setStoredValue(valueToStore);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Remove value from localStorage
|
|
268
|
+
const removeValue = () => {
|
|
269
|
+
try {
|
|
270
|
+
setStoredValue(initialValue);
|
|
271
|
+
if (typeof window !== 'undefined') {
|
|
272
|
+
try {
|
|
273
|
+
window.localStorage.removeItem(key);
|
|
274
|
+
} catch (removeError: any) {
|
|
275
|
+
// If removal fails due to quota, try to clear some data first
|
|
276
|
+
if (removeError.name === 'QuotaExceededError' ||
|
|
277
|
+
removeError.code === 22 ||
|
|
278
|
+
removeError.message?.includes('quota')) {
|
|
279
|
+
console.warn('localStorage quota exceeded during removal, clearing old data...');
|
|
280
|
+
clearOldData();
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
window.localStorage.removeItem(key);
|
|
284
|
+
} catch (retryError) {
|
|
285
|
+
console.error(`Failed to remove localStorage key "${key}" after clearing:`, retryError);
|
|
286
|
+
// If still fails, force clear all
|
|
287
|
+
forceClearAll();
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
throw removeError;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch (error) {
|
|
295
|
+
console.error(`Error removing localStorage key "${key}":`, error);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
return [storedValue, setValue, removeValue] as const;
|
|
300
|
+
}
|