create-fluxstack 1.8.3 → 1.10.1

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.
Files changed (52) hide show
  1. package/LIVE_COMPONENTS_REVIEW.md +781 -0
  2. package/README.md +653 -275
  3. package/app/client/src/App.tsx +39 -43
  4. package/app/client/src/lib/eden-api.ts +2 -7
  5. package/app/client/src/live/FileUploadExample.tsx +359 -0
  6. package/app/client/src/live/MinimalLiveClock.tsx +47 -0
  7. package/app/client/src/live/QuickUploadTest.tsx +193 -0
  8. package/app/client/src/main.tsx +10 -10
  9. package/app/client/src/vite-env.d.ts +1 -1
  10. package/app/client/tsconfig.app.json +45 -44
  11. package/app/client/tsconfig.node.json +25 -25
  12. package/app/server/index.ts +30 -103
  13. package/app/server/live/LiveFileUploadComponent.ts +77 -0
  14. package/app/server/live/register-components.ts +19 -19
  15. package/core/build/bundler.ts +202 -55
  16. package/core/build/index.ts +126 -2
  17. package/core/build/live-components-generator.ts +68 -1
  18. package/core/cli/generators/plugin.ts +6 -6
  19. package/core/cli/index.ts +232 -4
  20. package/core/client/LiveComponentsProvider.tsx +3 -9
  21. package/core/client/hooks/AdaptiveChunkSizer.ts +215 -0
  22. package/core/client/hooks/useChunkedUpload.ts +112 -61
  23. package/core/client/hooks/useHybridLiveComponent.ts +80 -26
  24. package/core/client/hooks/useTypedLiveComponent.ts +133 -0
  25. package/core/client/hooks/useWebSocket.ts +4 -16
  26. package/core/client/index.ts +20 -2
  27. package/core/framework/server.ts +181 -8
  28. package/core/live/ComponentRegistry.ts +5 -1
  29. package/core/plugins/built-in/index.ts +8 -5
  30. package/core/plugins/built-in/live-components/commands/create-live-component.ts +55 -63
  31. package/core/plugins/built-in/vite/index.ts +75 -187
  32. package/core/plugins/built-in/vite/vite-dev.ts +88 -0
  33. package/core/plugins/registry.ts +54 -2
  34. package/core/plugins/types.ts +86 -2
  35. package/core/server/index.ts +1 -2
  36. package/core/server/live/ComponentRegistry.ts +14 -5
  37. package/core/server/live/FileUploadManager.ts +22 -25
  38. package/core/server/live/auto-generated-components.ts +29 -26
  39. package/core/server/live/websocket-plugin.ts +19 -5
  40. package/core/server/plugins/static-files-plugin.ts +49 -240
  41. package/core/server/plugins/swagger.ts +33 -33
  42. package/core/types/build.ts +22 -0
  43. package/core/types/plugin.ts +9 -1
  44. package/core/types/types.ts +137 -0
  45. package/core/utils/logger/startup-banner.ts +20 -4
  46. package/core/utils/version.ts +6 -6
  47. package/create-fluxstack.ts +7 -7
  48. package/eslint.config.js +23 -23
  49. package/package.json +3 -2
  50. package/plugins/crypto-auth/server/middlewares.ts +19 -19
  51. package/tsconfig.json +52 -51
  52. package/workspace.json +5 -5
@@ -1,49 +1,13 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { api } from './lib/eden-api'
3
- import { FaFire, FaBook, FaGithub, FaClock } from 'react-icons/fa'
4
- import { LiveComponentsProvider, useHybridLiveComponent } from '@/core/client'
5
-
6
- interface LiveClockState {
7
- currentTime: string
8
- timeZone: string
9
- format: '12h' | '24h'
10
- showSeconds: boolean
11
- showDate: boolean
12
- lastSync: Date
13
- serverUptime: number
14
- }
15
-
16
- const initialClockState: LiveClockState = {
17
- currentTime: "Loading...",
18
- timeZone: "America/Sao_Paulo",
19
- format: "24h",
20
- showSeconds: true,
21
- showDate: true,
22
- lastSync: new Date(),
23
- serverUptime: 0,
24
- }
25
-
26
- // Minimal Live Clock Component
27
- function MinimalLiveClock() {
28
- const { state } = useHybridLiveComponent<LiveClockState>(
29
- 'LiveClock',
30
- initialClockState
31
- )
32
-
33
- return (
34
- <div className="bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-xl p-4 border border-blue-400/20">
35
- <div className="text-4xl font-mono font-bold text-white text-center tracking-wider">
36
- {state.currentTime}
37
- </div>
38
- <div className="text-center mt-2">
39
- <span className="text-xs text-gray-400">{state.timeZone}</span>
40
- </div>
41
- </div>
42
- )
43
- }
3
+ import { FaFire, FaBook, FaGithub, FaClock, FaImage } from 'react-icons/fa'
4
+ import { LiveComponentsProvider } from '@/core/client'
5
+ import { FileUploadExample } from './live/FileUploadExample'
6
+ import { MinimalLiveClock } from './live/MinimalLiveClock'
44
7
 
45
8
  function AppContent() {
46
9
  const [apiStatus, setApiStatus] = useState<'checking' | 'online' | 'offline'>('checking')
10
+ const [showDemo, setShowDemo] = useState(false)
47
11
 
48
12
  useEffect(() => {
49
13
  checkApiStatus()
@@ -58,6 +22,31 @@ function AppContent() {
58
22
  }
59
23
  }
60
24
 
25
+ // If demo mode is active, show demo content
26
+ if (showDemo) {
27
+ return (
28
+ <div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
29
+ <div className="container mx-auto px-4 py-8">
30
+ {/* Header with back button */}
31
+ <div className="flex items-center gap-4 mb-8">
32
+ <button
33
+ onClick={() => setShowDemo(false)}
34
+ className="px-4 py-2 bg-white/10 backdrop-blur-sm border border-white/20 text-white rounded-lg font-medium hover:bg-white/20 transition-all"
35
+ >
36
+ ← Back
37
+ </button>
38
+ <h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
39
+ FluxStack Demos
40
+ </h1>
41
+ </div>
42
+
43
+ {/* Demo Content */}
44
+ <FileUploadExample />
45
+ </div>
46
+ </div>
47
+ )
48
+ }
49
+
61
50
  return (
62
51
  <div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
63
52
  <div className="flex flex-col items-center justify-center min-h-screen px-6 text-center">
@@ -130,8 +119,15 @@ function AppContent() {
130
119
 
131
120
  {/* Action Buttons */}
132
121
  <div className="flex flex-wrap gap-4 justify-center">
122
+ <button
123
+ onClick={() => setShowDemo(true)}
124
+ className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-emerald-500 to-teal-600 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-emerald-500/50 transition-all"
125
+ >
126
+ <FaImage />
127
+ View Demos
128
+ </button>
133
129
  <a
134
- href="http://localhost:3000/swagger"
130
+ href="/swagger"
135
131
  target="_blank"
136
132
  rel="noopener noreferrer"
137
133
  className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/50 transition-all"
@@ -149,7 +145,7 @@ function AppContent() {
149
145
  GitHub
150
146
  </a>
151
147
  <a
152
- href="http://localhost:3000/api"
148
+ href="/api"
153
149
  target="_blank"
154
150
  rel="noopener noreferrer"
155
151
  className="inline-flex items-center gap-2 px-6 py-3 bg-white/10 backdrop-blur-sm border border-white/20 text-white rounded-xl font-medium hover:bg-white/20 transition-all"
@@ -8,13 +8,8 @@ import type { App } from '../../../server/app'
8
8
  export const getBaseUrl = () => {
9
9
  if (typeof window === 'undefined') return 'http://localhost:3000'
10
10
 
11
- // Production: use current origin
12
- if (window.location.hostname !== 'localhost') {
13
- return window.location.origin
14
- }
15
-
16
- // Development: use backend server
17
- return 'http://localhost:3000'
11
+ // Always use current origin - works for both dev and production
12
+ return window.location.origin
18
13
  }
19
14
 
20
15
  /**
@@ -0,0 +1,359 @@
1
+ // 📤 File Upload Example - Demonstrates chunked file upload with Live Components
2
+ import { useState, useRef } from 'react'
3
+ import { useTypedLiveComponent, useChunkedUpload, useLiveComponents } from '@/core/client'
4
+
5
+ // Import component type DIRECTLY from backend - full type inference!
6
+ import type { LiveFileUploadComponent } from '@/server/live/LiveFileUploadComponent'
7
+
8
+ export function FileUploadExample() {
9
+ const fileInputRef = useRef<HTMLInputElement>(null)
10
+ const [selectedFile, setSelectedFile] = useState<File | null>(null)
11
+
12
+ // Get sendMessageAndWait from LiveComponents context
13
+ const { sendMessageAndWait } = useLiveComponents()
14
+
15
+ // Setup Live Component with full type inference
16
+ const {
17
+ state,
18
+ call,
19
+ componentId,
20
+ connected
21
+ } = useTypedLiveComponent<LiveFileUploadComponent>('LiveFileUpload', {
22
+ uploadedFiles: [],
23
+ maxFiles: 10
24
+ })
25
+
26
+ // Setup Chunked Upload Hook with Adaptive Chunking
27
+ const {
28
+ uploading,
29
+ progress,
30
+ error: uploadError,
31
+ uploadFile,
32
+ cancelUpload,
33
+ reset: resetUpload,
34
+ bytesUploaded,
35
+ totalBytes
36
+ } = useChunkedUpload(componentId || '', {
37
+ chunkSize: 64 * 1024, // 64KB initial chunk size
38
+ maxFileSize: 500 * 1024 * 1024, // 500MB max (aceita qualquer arquivo)
39
+ allowedTypes: [], // Aceita todos os tipos de arquivo
40
+ sendMessageAndWait,
41
+
42
+ // Enable Adaptive Chunking for optimal upload speed
43
+ adaptiveChunking: true,
44
+ adaptiveConfig: {
45
+ minChunkSize: 16 * 1024, // 16KB minimum
46
+ maxChunkSize: 512 * 1024, // 512KB maximum (safer for web)
47
+ initialChunkSize: 64 * 1024, // 64KB start
48
+ targetLatency: 200, // Target 200ms per chunk
49
+ adjustmentFactor: 1.5, // Moderate adjustment
50
+ measurementWindow: 3 // Measure last 3 chunks
51
+ },
52
+
53
+ onProgress: (progress, uploaded, total) => {
54
+ console.log(`📤 Upload progress: ${progress.toFixed(1)}% (${uploaded}/${total} bytes)`)
55
+ },
56
+
57
+ onComplete: async (response) => {
58
+ console.log('✅ Upload complete:', response)
59
+
60
+ // Notify the Live Component about the successful upload
61
+ if (selectedFile && response.fileUrl) {
62
+ await call('onFileUploaded', {
63
+ filename: selectedFile.name,
64
+ fileUrl: response.fileUrl
65
+ })
66
+ }
67
+
68
+ // Reset state
69
+ setSelectedFile(null)
70
+ resetUpload()
71
+ if (fileInputRef.current) {
72
+ fileInputRef.current.value = ''
73
+ }
74
+ },
75
+
76
+ onError: (error) => {
77
+ console.error('❌ Upload error:', error)
78
+ }
79
+ })
80
+
81
+ const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
82
+ const file = e.target.files?.[0]
83
+ if (file) {
84
+ setSelectedFile(file)
85
+ resetUpload()
86
+ }
87
+ }
88
+
89
+ const handleUpload = async () => {
90
+ if (!selectedFile) return
91
+ await uploadFile(selectedFile)
92
+ }
93
+
94
+ const handleRemoveFile = async (fileId: string) => {
95
+ await call('removeFile', { fileId })
96
+ }
97
+
98
+ const handleClearAll = async () => {
99
+ await call('clearAll', {})
100
+ }
101
+
102
+ const formatBytes = (bytes: number): string => {
103
+ if (bytes === 0) return '0 Bytes'
104
+ const k = 1024
105
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
106
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
107
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
108
+ }
109
+
110
+ const getFileIcon = (filename: string): string => {
111
+ const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'))
112
+ const iconMap: Record<string, string> = {
113
+ '.pdf': '📄',
114
+ '.doc': '📝',
115
+ '.docx': '📝',
116
+ '.xls': '📊',
117
+ '.xlsx': '📊',
118
+ '.ppt': '📽️',
119
+ '.pptx': '📽️',
120
+ '.zip': '🗜️',
121
+ '.rar': '🗜️',
122
+ '.7z': '🗜️',
123
+ '.jpg': '🖼️',
124
+ '.jpeg': '🖼️',
125
+ '.png': '🖼️',
126
+ '.gif': '🖼️',
127
+ '.webp': '🖼️',
128
+ '.mp4': '🎥',
129
+ '.avi': '🎥',
130
+ '.mov': '🎥',
131
+ '.mp3': '🎵',
132
+ '.wav': '🎵',
133
+ '.jar': '☕',
134
+ '.java': '☕',
135
+ '.js': '📜',
136
+ '.ts': '📜',
137
+ '.json': '📋',
138
+ '.xml': '📋',
139
+ '.txt': '📃',
140
+ '.md': '📰'
141
+ }
142
+ return iconMap[ext] || '📎'
143
+ }
144
+
145
+ const isImageFile = (filename: string): boolean => {
146
+ const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'))
147
+ return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'].includes(ext)
148
+ }
149
+
150
+ if (!connected) {
151
+ return (
152
+ <div className="p-6 bg-yellow-50 border border-yellow-200 rounded-lg">
153
+ <p className="text-yellow-800">🔌 Connecting to Live Components...</p>
154
+ </div>
155
+ )
156
+ }
157
+
158
+ const remainingSlots = state.maxFiles - state.uploadedFiles.length
159
+
160
+ return (
161
+ <div className="max-w-4xl mx-auto p-6">
162
+ <div className="bg-white rounded-lg shadow-lg">
163
+ {/* Header */}
164
+ <div className="p-6 border-b border-gray-200">
165
+ <h2 className="text-2xl font-bold text-gray-900">
166
+ 📤 Universal File Upload
167
+ </h2>
168
+ <p className="mt-2 text-gray-600">
169
+ Upload any type of file with real-time progress tracking and adaptive chunking
170
+ </p>
171
+ </div>
172
+
173
+ {/* Upload Section */}
174
+ <div className="p-6 border-b border-gray-200">
175
+ <div className="space-y-4">
176
+ {/* File Input */}
177
+ <div>
178
+ <label className="block text-sm font-medium text-gray-700 mb-2">
179
+ Select Any File
180
+ </label>
181
+ <input
182
+ ref={fileInputRef}
183
+ type="file"
184
+ onChange={handleFileSelect}
185
+ disabled={uploading || remainingSlots === 0}
186
+ className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 disabled:opacity-50"
187
+ />
188
+ <p className="mt-1 text-xs text-gray-500">
189
+ Maximum file size: 500 MB • All file types supported
190
+ </p>
191
+ </div>
192
+
193
+ {/* Selected File Info */}
194
+ {selectedFile && !uploading && (
195
+ <div className="p-4 bg-gray-50 rounded-lg">
196
+ <p className="text-sm text-gray-700">
197
+ <strong>Selected:</strong> {getFileIcon(selectedFile.name)} {selectedFile.name} ({formatBytes(selectedFile.size)})
198
+ </p>
199
+ </div>
200
+ )}
201
+
202
+ {/* Upload Progress */}
203
+ {uploading && (
204
+ <div className="space-y-2">
205
+ <div className="flex justify-between text-sm text-gray-600">
206
+ <span>Uploading {selectedFile?.name}...</span>
207
+ <span>{progress.toFixed(1)}%</span>
208
+ </div>
209
+ <div className="w-full bg-gray-200 rounded-full h-2.5">
210
+ <div
211
+ className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
212
+ style={{ width: `${progress}%` }}
213
+ />
214
+ </div>
215
+ <p className="text-xs text-gray-500">
216
+ {formatBytes(bytesUploaded)} / {formatBytes(totalBytes)}
217
+ </p>
218
+ </div>
219
+ )}
220
+
221
+ {/* Error Display */}
222
+ {uploadError && (
223
+ <div className="p-4 bg-red-50 border border-red-200 rounded-lg">
224
+ <p className="text-sm text-red-800">❌ {uploadError}</p>
225
+ </div>
226
+ )}
227
+
228
+ {/* Action Buttons */}
229
+ <div className="flex gap-2">
230
+ <button
231
+ onClick={handleUpload}
232
+ disabled={!selectedFile || uploading || remainingSlots === 0}
233
+ className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
234
+ >
235
+ {uploading ? '⏳ Uploading...' : '📤 Upload'}
236
+ </button>
237
+
238
+ {uploading && (
239
+ <button
240
+ onClick={cancelUpload}
241
+ className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 font-medium"
242
+ >
243
+ ❌ Cancel
244
+ </button>
245
+ )}
246
+
247
+ {state.uploadedFiles.length > 0 && (
248
+ <button
249
+ onClick={handleClearAll}
250
+ disabled={uploading}
251
+ className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 disabled:opacity-50 font-medium ml-auto"
252
+ >
253
+ 🗑️ Clear All
254
+ </button>
255
+ )}
256
+ </div>
257
+
258
+ {/* Slots Info */}
259
+ <div className="text-sm text-gray-600">
260
+ {remainingSlots > 0 ? (
261
+ <span>✅ {remainingSlots} upload slot(s) remaining</span>
262
+ ) : (
263
+ <span className="text-red-600">⚠️ Maximum files reached</span>
264
+ )}
265
+ </div>
266
+ </div>
267
+ </div>
268
+
269
+ {/* Uploaded Files Grid */}
270
+ <div className="p-6">
271
+ <h3 className="text-lg font-semibold text-gray-900 mb-4">
272
+ Uploaded Files ({state.uploadedFiles.length}/{state.maxFiles})
273
+ </h3>
274
+
275
+ {state.uploadedFiles.length === 0 ? (
276
+ <div className="text-center py-12 bg-gray-50 rounded-lg">
277
+ <p className="text-gray-500">No files uploaded yet</p>
278
+ </div>
279
+ ) : (
280
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
281
+ {state.uploadedFiles.map((file) => (
282
+ <div
283
+ key={file.id}
284
+ className="relative bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
285
+ >
286
+ {/* File Preview */}
287
+ <div className="aspect-video bg-gray-100 flex items-center justify-center">
288
+ {isImageFile(file.filename) ? (
289
+ <img
290
+ src={file.url}
291
+ alt={file.filename}
292
+ className="max-w-full max-h-full object-contain"
293
+ onError={(e) => {
294
+ const target = e.target as HTMLImageElement
295
+ target.style.display = 'none'
296
+ target.parentElement!.innerHTML = `<div class="text-6xl">${getFileIcon(file.filename)}</div>`
297
+ }}
298
+ />
299
+ ) : (
300
+ <div className="text-6xl">{getFileIcon(file.filename)}</div>
301
+ )}
302
+ </div>
303
+
304
+ {/* Info */}
305
+ <div className="p-3">
306
+ <p className="text-sm font-medium text-gray-900 truncate" title={file.filename}>
307
+ {file.filename}
308
+ </p>
309
+ <p className="text-xs text-gray-500">
310
+ {new Date(file.uploadedAt).toLocaleString()}
311
+ </p>
312
+ <a
313
+ href={file.url}
314
+ target="_blank"
315
+ rel="noopener noreferrer"
316
+ className="text-xs text-blue-600 hover:underline mt-1 inline-block"
317
+ >
318
+ Download
319
+ </a>
320
+ </div>
321
+
322
+ {/* Remove Button */}
323
+ <button
324
+ onClick={() => handleRemoveFile(file.id)}
325
+ className="absolute top-2 right-2 p-2 bg-red-600 text-white rounded-full hover:bg-red-700 transition-colors shadow-lg"
326
+ title="Remove file"
327
+ >
328
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
329
+ <path
330
+ fillRule="evenodd"
331
+ d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
332
+ clipRule="evenodd"
333
+ />
334
+ </svg>
335
+ </button>
336
+ </div>
337
+ ))}
338
+ </div>
339
+ )}
340
+ </div>
341
+ </div>
342
+
343
+ {/* Technical Info */}
344
+ <div className="mt-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
345
+ <h4 className="font-semibold text-gray-900 mb-2">📋 Technical Details</h4>
346
+ <ul className="text-sm text-gray-600 space-y-1">
347
+ <li>✅ <strong>Adaptive Chunking:</strong> Enabled (16KB - 512KB)</li>
348
+ <li>✅ <strong>Initial Chunk Size:</strong> 64KB</li>
349
+ <li>✅ <strong>Max File Size:</strong> 500MB</li>
350
+ <li>✅ <strong>Allowed Types:</strong> All file types supported</li>
351
+ <li>✅ <strong>Real-time Progress:</strong> Shows bytes uploaded and percentage</li>
352
+ <li>✅ <strong>State Sync:</strong> Uploaded files synced via Live Component</li>
353
+ <li>✅ <strong>Component ID:</strong> {componentId || 'Not connected'}</li>
354
+ <li>🚀 <strong>Optimization:</strong> Chunk size adjusts based on connection speed</li>
355
+ </ul>
356
+ </div>
357
+ </div>
358
+ )
359
+ }
@@ -0,0 +1,47 @@
1
+ // 🔥 MinimalLiveClock - Live Component
2
+ import { useTypedLiveComponent } from '@/core/client';
3
+
4
+ // Import component type DIRECTLY from backend - full type inference!
5
+ import type { LiveClockComponent } from '@/server/live/LiveClockComponent';
6
+
7
+ export function MinimalLiveClock() {
8
+ const { state, setValue } = useTypedLiveComponent<LiveClockComponent>(
9
+ 'LiveClock',
10
+ {
11
+ currentTime: "Loading...",
12
+ timeZone: "America/Sao_Paulo",
13
+ format: "12h",
14
+ showSeconds: true,
15
+ showDate: true,
16
+ lastSync: new Date(),
17
+ serverUptime: 0,
18
+ },
19
+ {
20
+ // Called when WebSocket connects (component not mounted yet - can't use setValue)
21
+ onConnect: () => {
22
+ console.log('onConnect called - WebSocket connected')
23
+ },
24
+ // Called after fresh mount (no prior state)
25
+ onMount: () => {
26
+ console.log('onMount called - changing format to 12h')
27
+ setValue('format', '12h')
28
+ },
29
+ // Called after successful rehydration (restoring prior state)
30
+ onRehydrate: () => {
31
+ console.log('onRehydrate called - keeping format 24h')
32
+ setValue('format', '24h')
33
+ }
34
+ }
35
+ )
36
+
37
+ return (
38
+ <div className="bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-xl p-4 border border-blue-400/20">
39
+ <div className="text-4xl font-mono font-bold text-white text-center tracking-wider">
40
+ {state.currentTime}
41
+ </div>
42
+ <div className="text-center mt-2">
43
+ <span className="text-xs text-gray-400">{state.timeZone} ({state.format})</span>
44
+ </div>
45
+ </div>
46
+ )
47
+ }