@uniweb/kit 0.1.4 → 0.1.6
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/package.json +3 -2
- package/src/components/Asset/Asset.jsx +31 -81
- package/src/components/Media/Media.jsx +27 -125
- package/src/components/SocialIcon/index.jsx +146 -0
- package/src/hooks/index.js +7 -0
- package/src/hooks/useAccordion.js +143 -0
- package/src/hooks/useActiveRoute.js +97 -0
- package/src/hooks/useGridLayout.js +71 -0
- package/src/hooks/useMobileMenu.js +58 -0
- package/src/hooks/useRouting.js +119 -0
- package/src/hooks/useScrolled.js +48 -0
- package/src/hooks/useTheme.js +205 -0
- package/src/index.js +29 -10
- package/src/styled/Asset/Asset.jsx +161 -0
- package/src/styled/Asset/index.js +1 -0
- package/src/{components → styled}/Disclaimer/Disclaimer.jsx +1 -1
- package/src/styled/Media/Media.jsx +322 -0
- package/src/styled/Media/index.js +1 -0
- package/src/{components → styled}/Section/Render.jsx +4 -4
- package/src/{components → styled}/Section/index.js +6 -0
- package/src/{components → styled}/Section/renderers/Alert.jsx +1 -1
- package/src/{components → styled}/Section/renderers/Details.jsx +1 -1
- package/src/{components → styled}/Section/renderers/Table.jsx +1 -1
- package/src/{components → styled}/Section/renderers/index.js +1 -1
- package/src/styled/SidebarLayout/SidebarLayout.jsx +310 -0
- package/src/styled/SidebarLayout/index.js +1 -0
- package/src/styled/index.js +40 -0
- /package/src/{components → styled}/Disclaimer/index.js +0 -0
- /package/src/{components → styled}/Section/Section.jsx +0 -0
- /package/src/{components → styled}/Section/renderers/Code.jsx +0 -0
- /package/src/{components → styled}/Section/renderers/Divider.jsx +0 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Component
|
|
3
|
+
*
|
|
4
|
+
* Video player supporting:
|
|
5
|
+
* - YouTube embeds
|
|
6
|
+
* - Vimeo embeds
|
|
7
|
+
* - Local/direct video files
|
|
8
|
+
* - Thumbnail facades
|
|
9
|
+
* - Playback tracking
|
|
10
|
+
*
|
|
11
|
+
* @module @uniweb/kit/Media
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
15
|
+
import { cn } from '../../utils/index.js'
|
|
16
|
+
import { detectMediaType } from '../../utils/index.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extract YouTube video ID from URL
|
|
20
|
+
* @param {string} url
|
|
21
|
+
* @returns {string|null}
|
|
22
|
+
*/
|
|
23
|
+
function getYouTubeId(url) {
|
|
24
|
+
if (!url) return null
|
|
25
|
+
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&?/]+)/)
|
|
26
|
+
return match?.[1] || null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Extract Vimeo video ID from URL
|
|
31
|
+
* @param {string} url
|
|
32
|
+
* @returns {string|null}
|
|
33
|
+
*/
|
|
34
|
+
function getVimeoId(url) {
|
|
35
|
+
if (!url) return null
|
|
36
|
+
const match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/)
|
|
37
|
+
return match?.[1] || null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get thumbnail URL for a video
|
|
42
|
+
* @param {string} src - Video URL
|
|
43
|
+
* @param {string} type - Media type
|
|
44
|
+
* @returns {string|null}
|
|
45
|
+
*/
|
|
46
|
+
function getVideoThumbnail(src, type) {
|
|
47
|
+
if (type === 'youtube') {
|
|
48
|
+
const id = getYouTubeId(src)
|
|
49
|
+
return id ? `https://img.youtube.com/vi/${id}/maxresdefault.jpg` : null
|
|
50
|
+
}
|
|
51
|
+
// Vimeo requires API call, return null for now
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* YouTube Player Component
|
|
57
|
+
*/
|
|
58
|
+
function YouTubePlayer({ videoId, autoplay, muted, loop, onReady, onStateChange, className }) {
|
|
59
|
+
const iframeRef = useRef(null)
|
|
60
|
+
|
|
61
|
+
const params = new URLSearchParams({
|
|
62
|
+
enablejsapi: '1',
|
|
63
|
+
autoplay: autoplay ? '1' : '0',
|
|
64
|
+
mute: muted ? '1' : '0',
|
|
65
|
+
loop: loop ? '1' : '0',
|
|
66
|
+
playlist: loop ? videoId : '',
|
|
67
|
+
rel: '0',
|
|
68
|
+
modestbranding: '1'
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<iframe
|
|
73
|
+
ref={iframeRef}
|
|
74
|
+
src={`https://www.youtube.com/embed/${videoId}?${params}`}
|
|
75
|
+
className={cn('w-full h-full', className)}
|
|
76
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
77
|
+
allowFullScreen
|
|
78
|
+
title="YouTube video"
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Vimeo Player Component
|
|
85
|
+
*/
|
|
86
|
+
function VimeoPlayer({ videoId, autoplay, muted, loop, className }) {
|
|
87
|
+
const params = new URLSearchParams({
|
|
88
|
+
autoplay: autoplay ? '1' : '0',
|
|
89
|
+
muted: muted ? '1' : '0',
|
|
90
|
+
loop: loop ? '1' : '0',
|
|
91
|
+
dnt: '1'
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<iframe
|
|
96
|
+
src={`https://player.vimeo.com/video/${videoId}?${params}`}
|
|
97
|
+
className={cn('w-full h-full', className)}
|
|
98
|
+
allow="autoplay; fullscreen; picture-in-picture"
|
|
99
|
+
allowFullScreen
|
|
100
|
+
title="Vimeo video"
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Local/Direct Video Player Component
|
|
107
|
+
*/
|
|
108
|
+
function LocalVideo({ src, autoplay, muted, loop, controls, poster, onProgress, className }) {
|
|
109
|
+
const videoRef = useRef(null)
|
|
110
|
+
const [milestones, setMilestones] = useState({ 25: false, 50: false, 75: false, 95: false })
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const video = videoRef.current
|
|
114
|
+
if (!video || !onProgress) return
|
|
115
|
+
|
|
116
|
+
const handleTimeUpdate = () => {
|
|
117
|
+
const percent = (video.currentTime / video.duration) * 100
|
|
118
|
+
|
|
119
|
+
Object.entries({ 25: 25, 50: 50, 75: 75, 95: 95 }).forEach(([key, threshold]) => {
|
|
120
|
+
if (percent >= threshold && !milestones[key]) {
|
|
121
|
+
setMilestones((prev) => ({ ...prev, [key]: true }))
|
|
122
|
+
onProgress({ milestone: key, percent, currentTime: video.currentTime })
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
video.addEventListener('timeupdate', handleTimeUpdate)
|
|
128
|
+
return () => video.removeEventListener('timeupdate', handleTimeUpdate)
|
|
129
|
+
}, [milestones, onProgress])
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<video
|
|
133
|
+
ref={videoRef}
|
|
134
|
+
src={src}
|
|
135
|
+
autoPlay={autoplay}
|
|
136
|
+
muted={muted}
|
|
137
|
+
loop={loop}
|
|
138
|
+
controls={controls}
|
|
139
|
+
poster={poster}
|
|
140
|
+
playsInline
|
|
141
|
+
className={cn('w-full h-full object-cover', className)}
|
|
142
|
+
/>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Play Button Overlay
|
|
148
|
+
*/
|
|
149
|
+
function PlayButton({ onClick, className }) {
|
|
150
|
+
return (
|
|
151
|
+
<button
|
|
152
|
+
onClick={onClick}
|
|
153
|
+
className={cn(
|
|
154
|
+
'absolute inset-0 flex items-center justify-center',
|
|
155
|
+
'bg-black/30 hover:bg-black/40 transition-colors',
|
|
156
|
+
'group cursor-pointer',
|
|
157
|
+
className
|
|
158
|
+
)}
|
|
159
|
+
aria-label="Play video"
|
|
160
|
+
>
|
|
161
|
+
<div className="w-16 h-16 rounded-full bg-white/90 group-hover:bg-white flex items-center justify-center transition-colors">
|
|
162
|
+
<svg className="w-8 h-8 text-gray-900 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
|
163
|
+
<path d="M8 5v14l11-7z" />
|
|
164
|
+
</svg>
|
|
165
|
+
</div>
|
|
166
|
+
</button>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Media - Video player component
|
|
172
|
+
*
|
|
173
|
+
* @param {Object} props
|
|
174
|
+
* @param {string|Object} props.src - Video URL or media object
|
|
175
|
+
* @param {Object} [props.media] - Media object with src/caption
|
|
176
|
+
* @param {string} [props.thumbnail] - Thumbnail URL
|
|
177
|
+
* @param {boolean} [props.autoplay=false] - Auto-play video
|
|
178
|
+
* @param {boolean} [props.muted=false] - Mute video
|
|
179
|
+
* @param {boolean} [props.loop=false] - Loop video
|
|
180
|
+
* @param {boolean} [props.controls=true] - Show controls
|
|
181
|
+
* @param {boolean} [props.facade=false] - Show thumbnail with play button
|
|
182
|
+
* @param {string} [props.aspectRatio='16/9'] - Aspect ratio
|
|
183
|
+
* @param {string} [props.className] - Additional CSS classes
|
|
184
|
+
* @param {Function} [props.onProgress] - Progress callback for tracking
|
|
185
|
+
* @param {Object} [props.block] - Block object for event tracking
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* // YouTube video
|
|
189
|
+
* <Media src="https://youtube.com/watch?v=abc123" />
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* // With thumbnail facade
|
|
193
|
+
* <Media
|
|
194
|
+
* src="https://youtube.com/watch?v=abc123"
|
|
195
|
+
* thumbnail="/images/video-poster.jpg"
|
|
196
|
+
* facade
|
|
197
|
+
* />
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* // Local video
|
|
201
|
+
* <Media src="/videos/intro.mp4" controls autoplay={false} />
|
|
202
|
+
*/
|
|
203
|
+
export function Media({
|
|
204
|
+
src,
|
|
205
|
+
media,
|
|
206
|
+
thumbnail,
|
|
207
|
+
autoplay = false,
|
|
208
|
+
muted = false,
|
|
209
|
+
loop = false,
|
|
210
|
+
controls = true,
|
|
211
|
+
facade = false,
|
|
212
|
+
aspectRatio = '16/9',
|
|
213
|
+
className,
|
|
214
|
+
onProgress,
|
|
215
|
+
block,
|
|
216
|
+
...props
|
|
217
|
+
}) {
|
|
218
|
+
const [showVideo, setShowVideo] = useState(!facade)
|
|
219
|
+
|
|
220
|
+
// Normalize source
|
|
221
|
+
const videoSrc = typeof src === 'string' ? src : (src?.src || media?.src || '')
|
|
222
|
+
const caption = media?.caption || src?.caption || ''
|
|
223
|
+
|
|
224
|
+
// Detect video type
|
|
225
|
+
const mediaType = detectMediaType(videoSrc)
|
|
226
|
+
|
|
227
|
+
// Get thumbnail
|
|
228
|
+
const thumbnailSrc = thumbnail || getVideoThumbnail(videoSrc, mediaType)
|
|
229
|
+
|
|
230
|
+
// Handle play click (for facade mode)
|
|
231
|
+
const handlePlay = useCallback(() => {
|
|
232
|
+
setShowVideo(true)
|
|
233
|
+
}, [])
|
|
234
|
+
|
|
235
|
+
// Handle progress tracking
|
|
236
|
+
const handleProgress = useCallback((data) => {
|
|
237
|
+
onProgress?.(data)
|
|
238
|
+
|
|
239
|
+
// Track via block if available
|
|
240
|
+
if (block?.trackEvent && typeof window !== 'undefined' && window.uniweb?.analytics?.initialized) {
|
|
241
|
+
block.trackEvent(`video_milestone_${data.milestone}`, {
|
|
242
|
+
milestone: `${data.milestone}%`,
|
|
243
|
+
src: videoSrc
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
}, [onProgress, block, videoSrc])
|
|
247
|
+
|
|
248
|
+
// Render facade (thumbnail with play button)
|
|
249
|
+
if (facade && !showVideo && thumbnailSrc) {
|
|
250
|
+
return (
|
|
251
|
+
<div
|
|
252
|
+
className={cn('relative overflow-hidden', className)}
|
|
253
|
+
style={{ aspectRatio }}
|
|
254
|
+
{...props}
|
|
255
|
+
>
|
|
256
|
+
<img
|
|
257
|
+
src={thumbnailSrc}
|
|
258
|
+
alt={caption || 'Video thumbnail'}
|
|
259
|
+
className="w-full h-full object-cover"
|
|
260
|
+
/>
|
|
261
|
+
<PlayButton onClick={handlePlay} />
|
|
262
|
+
</div>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Render video player
|
|
267
|
+
const videoContent = (() => {
|
|
268
|
+
switch (mediaType) {
|
|
269
|
+
case 'youtube': {
|
|
270
|
+
const videoId = getYouTubeId(videoSrc)
|
|
271
|
+
if (!videoId) return null
|
|
272
|
+
return (
|
|
273
|
+
<YouTubePlayer
|
|
274
|
+
videoId={videoId}
|
|
275
|
+
autoplay={autoplay || (facade && showVideo)}
|
|
276
|
+
muted={muted}
|
|
277
|
+
loop={loop}
|
|
278
|
+
/>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
case 'vimeo': {
|
|
283
|
+
const videoId = getVimeoId(videoSrc)
|
|
284
|
+
if (!videoId) return null
|
|
285
|
+
return (
|
|
286
|
+
<VimeoPlayer
|
|
287
|
+
videoId={videoId}
|
|
288
|
+
autoplay={autoplay || (facade && showVideo)}
|
|
289
|
+
muted={muted}
|
|
290
|
+
loop={loop}
|
|
291
|
+
/>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case 'video':
|
|
296
|
+
default:
|
|
297
|
+
return (
|
|
298
|
+
<LocalVideo
|
|
299
|
+
src={videoSrc}
|
|
300
|
+
autoplay={autoplay || (facade && showVideo)}
|
|
301
|
+
muted={muted}
|
|
302
|
+
loop={loop}
|
|
303
|
+
controls={controls}
|
|
304
|
+
poster={thumbnailSrc}
|
|
305
|
+
onProgress={handleProgress}
|
|
306
|
+
/>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
})()
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<div
|
|
313
|
+
className={cn('relative overflow-hidden bg-black', className)}
|
|
314
|
+
style={{ aspectRatio }}
|
|
315
|
+
{...props}
|
|
316
|
+
>
|
|
317
|
+
{videoContent}
|
|
318
|
+
</div>
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export default Media
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Media, default } from './Media.jsx'
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
import React from 'react'
|
|
11
11
|
import { cn } from '../../utils/index.js'
|
|
12
|
-
import { SafeHtml } from '
|
|
13
|
-
import { Image } from '
|
|
14
|
-
import { Media } from '
|
|
15
|
-
import { Link } from '
|
|
12
|
+
import { SafeHtml } from '../../components/SafeHtml/index.js'
|
|
13
|
+
import { Image } from '../../components/Image/index.js'
|
|
14
|
+
import { Media } from '../../components/Media/index.js'
|
|
15
|
+
import { Link } from '../../components/Link/index.js'
|
|
16
16
|
import { Code } from './renderers/Code.jsx'
|
|
17
17
|
import { Alert } from './renderers/Alert.jsx'
|
|
18
18
|
import { Table } from './renderers/Table.jsx'
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import React, { useEffect } from 'react'
|
|
2
|
+
import { cn } from '../../utils/index.js'
|
|
3
|
+
import { useMobileMenu } from '../../hooks/useMobileMenu.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SidebarLayout Component
|
|
7
|
+
*
|
|
8
|
+
* A flexible layout with optional left and/or right sidebars.
|
|
9
|
+
* On desktop, sidebars appear inline at their configured breakpoints.
|
|
10
|
+
* On mobile, the left panel is accessible via a slide-out drawer with FAB toggle.
|
|
11
|
+
* The right panel is hidden on mobile (common pattern: nav essential, TOC optional).
|
|
12
|
+
*
|
|
13
|
+
* The layout is "sandwiched" - header and footer span the full width,
|
|
14
|
+
* with the sidebars and content area between them.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // In foundation's src/exports.js - use as-is
|
|
18
|
+
* import { SidebarLayout } from '@uniweb/kit'
|
|
19
|
+
*
|
|
20
|
+
* export default {
|
|
21
|
+
* Layout: SidebarLayout,
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* // With custom configuration
|
|
26
|
+
* import { SidebarLayout } from '@uniweb/kit'
|
|
27
|
+
*
|
|
28
|
+
* function CustomLayout(props) {
|
|
29
|
+
* return (
|
|
30
|
+
* <SidebarLayout
|
|
31
|
+
* {...props}
|
|
32
|
+
* leftBreakpoint="lg"
|
|
33
|
+
* rightBreakpoint="xl"
|
|
34
|
+
* leftWidth="w-72"
|
|
35
|
+
* />
|
|
36
|
+
* )
|
|
37
|
+
* }
|
|
38
|
+
*
|
|
39
|
+
* export default { Layout: CustomLayout }
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Hamburger menu icon
|
|
44
|
+
*/
|
|
45
|
+
function MenuIcon({ className }) {
|
|
46
|
+
return (
|
|
47
|
+
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
48
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
49
|
+
</svg>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Close (X) icon
|
|
55
|
+
*/
|
|
56
|
+
function CloseIcon({ className }) {
|
|
57
|
+
return (
|
|
58
|
+
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
59
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
60
|
+
</svg>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Mobile drawer component (always slides from left)
|
|
66
|
+
*/
|
|
67
|
+
function MobileDrawer({ isOpen, onClose, width, stickyHeader, children }) {
|
|
68
|
+
// Prevent body scroll when drawer is open
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (isOpen) {
|
|
71
|
+
document.body.style.overflow = 'hidden'
|
|
72
|
+
return () => {
|
|
73
|
+
document.body.style.overflow = ''
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}, [isOpen])
|
|
77
|
+
|
|
78
|
+
// Position below header if sticky, otherwise from top
|
|
79
|
+
const topOffset = stickyHeader ? 'top-16' : 'top-0'
|
|
80
|
+
const height = stickyHeader ? 'h-[calc(100vh-4rem)]' : 'h-screen'
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
{/* Backdrop */}
|
|
85
|
+
<div
|
|
86
|
+
className={cn(
|
|
87
|
+
'fixed inset-0 bg-black/50 z-40 transition-opacity duration-300',
|
|
88
|
+
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
|
89
|
+
)}
|
|
90
|
+
onClick={onClose}
|
|
91
|
+
aria-hidden="true"
|
|
92
|
+
/>
|
|
93
|
+
|
|
94
|
+
{/* Drawer (always from left) */}
|
|
95
|
+
<div
|
|
96
|
+
className={cn(
|
|
97
|
+
'fixed left-0 bg-white z-50 shadow-xl',
|
|
98
|
+
topOffset,
|
|
99
|
+
height,
|
|
100
|
+
width,
|
|
101
|
+
'transform transition-transform duration-300 ease-in-out',
|
|
102
|
+
isOpen ? 'translate-x-0' : '-translate-x-full'
|
|
103
|
+
)}
|
|
104
|
+
role="dialog"
|
|
105
|
+
aria-modal="true"
|
|
106
|
+
aria-label="Sidebar navigation"
|
|
107
|
+
>
|
|
108
|
+
{/* Close button */}
|
|
109
|
+
<button
|
|
110
|
+
onClick={onClose}
|
|
111
|
+
className="absolute top-4 right-4 p-1.5 rounded-md hover:bg-gray-100 transition-colors"
|
|
112
|
+
aria-label="Close sidebar"
|
|
113
|
+
>
|
|
114
|
+
<CloseIcon className="w-5 h-5 text-gray-500" />
|
|
115
|
+
</button>
|
|
116
|
+
|
|
117
|
+
{/* Drawer content */}
|
|
118
|
+
<div className="h-full overflow-y-auto overscroll-contain">
|
|
119
|
+
{children}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Floating action button for mobile menu (always bottom-left)
|
|
128
|
+
*/
|
|
129
|
+
function FloatingMenuButton({ onClick }) {
|
|
130
|
+
return (
|
|
131
|
+
<button
|
|
132
|
+
onClick={onClick}
|
|
133
|
+
className={cn(
|
|
134
|
+
'fixed bottom-4 left-4 z-30',
|
|
135
|
+
'p-3 bg-primary text-white rounded-full shadow-lg',
|
|
136
|
+
'hover:bg-primary/90 active:scale-95 transition-all',
|
|
137
|
+
'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2'
|
|
138
|
+
)}
|
|
139
|
+
aria-label="Open navigation menu"
|
|
140
|
+
>
|
|
141
|
+
<MenuIcon className="w-6 h-6" />
|
|
142
|
+
</button>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get responsive classes for showing/hiding at breakpoint
|
|
148
|
+
*/
|
|
149
|
+
function getBreakpointClasses(breakpoint) {
|
|
150
|
+
const showClass = {
|
|
151
|
+
sm: 'sm:block',
|
|
152
|
+
md: 'md:block',
|
|
153
|
+
lg: 'lg:block',
|
|
154
|
+
xl: 'xl:block',
|
|
155
|
+
}[breakpoint] || 'md:block'
|
|
156
|
+
|
|
157
|
+
const hideClass = {
|
|
158
|
+
sm: 'sm:hidden',
|
|
159
|
+
md: 'md:hidden',
|
|
160
|
+
lg: 'lg:hidden',
|
|
161
|
+
xl: 'xl:hidden',
|
|
162
|
+
}[breakpoint] || 'md:hidden'
|
|
163
|
+
|
|
164
|
+
return { showClass, hideClass }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* SidebarLayout main component
|
|
169
|
+
*
|
|
170
|
+
* @param {Object} props
|
|
171
|
+
* @param {React.ReactNode} props.header - Header content (from @header sections)
|
|
172
|
+
* @param {React.ReactNode} props.body - Main body content (page sections)
|
|
173
|
+
* @param {React.ReactNode} props.footer - Footer content (from @footer sections)
|
|
174
|
+
* @param {React.ReactNode} props.left - Left panel content (from @left sections)
|
|
175
|
+
* @param {React.ReactNode} props.right - Right panel content (from @right sections)
|
|
176
|
+
* @param {React.ReactNode} props.leftPanel - Alias for left (backwards compatibility)
|
|
177
|
+
* @param {React.ReactNode} props.rightPanel - Alias for right (backwards compatibility)
|
|
178
|
+
* @param {string} [props.leftWidth='w-64'] - Tailwind width class for left sidebar
|
|
179
|
+
* @param {string} [props.rightWidth='w-64'] - Tailwind width class for right sidebar
|
|
180
|
+
* @param {string} [props.drawerWidth='w-72'] - Tailwind width class for mobile drawer
|
|
181
|
+
* @param {string} [props.leftBreakpoint='md'] - Breakpoint for showing left sidebar inline
|
|
182
|
+
* @param {string} [props.rightBreakpoint='xl'] - Breakpoint for showing right sidebar inline
|
|
183
|
+
* @param {boolean} [props.stickyHeader=true] - Whether header sticks to top
|
|
184
|
+
* @param {boolean} [props.stickySidebar=true] - Whether sidebars stick below header
|
|
185
|
+
* @param {string} [props.maxWidth='max-w-7xl'] - Max width of content area
|
|
186
|
+
* @param {string} [props.contentPadding='px-4 py-8 sm:px-6 lg:px-8'] - Padding for main content
|
|
187
|
+
* @param {string} [props.className] - Additional classes for the root element
|
|
188
|
+
*/
|
|
189
|
+
export function SidebarLayout({
|
|
190
|
+
// Pre-rendered layout areas from runtime
|
|
191
|
+
header,
|
|
192
|
+
body,
|
|
193
|
+
footer,
|
|
194
|
+
left,
|
|
195
|
+
right,
|
|
196
|
+
leftPanel,
|
|
197
|
+
rightPanel,
|
|
198
|
+
// Configuration
|
|
199
|
+
leftWidth = 'w-64',
|
|
200
|
+
rightWidth = 'w-64',
|
|
201
|
+
drawerWidth = 'w-72',
|
|
202
|
+
leftBreakpoint = 'md',
|
|
203
|
+
rightBreakpoint = 'xl',
|
|
204
|
+
stickyHeader = true,
|
|
205
|
+
stickySidebar = true,
|
|
206
|
+
maxWidth = 'max-w-7xl',
|
|
207
|
+
contentPadding = 'px-4 py-8 sm:px-6 lg:px-8',
|
|
208
|
+
className,
|
|
209
|
+
}) {
|
|
210
|
+
const { isOpen, open, close } = useMobileMenu()
|
|
211
|
+
|
|
212
|
+
// Resolve panel content (support both naming conventions)
|
|
213
|
+
const leftContent = left || leftPanel
|
|
214
|
+
const rightContent = right || rightPanel
|
|
215
|
+
|
|
216
|
+
// Get breakpoint classes for each panel
|
|
217
|
+
const leftClasses = getBreakpointClasses(leftBreakpoint)
|
|
218
|
+
const rightClasses = getBreakpointClasses(rightBreakpoint)
|
|
219
|
+
|
|
220
|
+
// Sticky positioning
|
|
221
|
+
const headerClasses = stickyHeader
|
|
222
|
+
? 'sticky top-0 z-30'
|
|
223
|
+
: ''
|
|
224
|
+
|
|
225
|
+
const sidebarClasses = stickySidebar && stickyHeader
|
|
226
|
+
? 'sticky top-16 h-[calc(100vh-4rem)]'
|
|
227
|
+
: stickySidebar
|
|
228
|
+
? 'sticky top-0 h-screen'
|
|
229
|
+
: ''
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<div className={cn('min-h-screen flex flex-col bg-white', className)}>
|
|
233
|
+
{/* Header */}
|
|
234
|
+
{header && (
|
|
235
|
+
<header className={cn(
|
|
236
|
+
'w-full border-b border-gray-200 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/80',
|
|
237
|
+
headerClasses
|
|
238
|
+
)}>
|
|
239
|
+
{header}
|
|
240
|
+
</header>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* Mobile Drawer (left panel only) */}
|
|
244
|
+
{leftContent && (
|
|
245
|
+
<div className={leftClasses.hideClass}>
|
|
246
|
+
<MobileDrawer
|
|
247
|
+
isOpen={isOpen}
|
|
248
|
+
onClose={close}
|
|
249
|
+
width={drawerWidth}
|
|
250
|
+
stickyHeader={stickyHeader}
|
|
251
|
+
>
|
|
252
|
+
{leftContent}
|
|
253
|
+
</MobileDrawer>
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{/* Main Content Area */}
|
|
258
|
+
<div className={cn('flex-1 w-full mx-auto', maxWidth)}>
|
|
259
|
+
<div className="flex">
|
|
260
|
+
{/* Left Sidebar (desktop) */}
|
|
261
|
+
{leftContent && (
|
|
262
|
+
<aside className={cn(
|
|
263
|
+
'hidden flex-shrink-0 overflow-y-auto border-r border-gray-200',
|
|
264
|
+
leftClasses.showClass,
|
|
265
|
+
leftWidth,
|
|
266
|
+
sidebarClasses
|
|
267
|
+
)}>
|
|
268
|
+
{leftContent}
|
|
269
|
+
</aside>
|
|
270
|
+
)}
|
|
271
|
+
|
|
272
|
+
{/* Main Content */}
|
|
273
|
+
<main className="flex-1 min-w-0">
|
|
274
|
+
<div className={contentPadding}>
|
|
275
|
+
{body}
|
|
276
|
+
</div>
|
|
277
|
+
</main>
|
|
278
|
+
|
|
279
|
+
{/* Right Sidebar (desktop only, hidden on mobile) */}
|
|
280
|
+
{rightContent && (
|
|
281
|
+
<aside className={cn(
|
|
282
|
+
'hidden flex-shrink-0 overflow-y-auto border-l border-gray-200',
|
|
283
|
+
rightClasses.showClass,
|
|
284
|
+
rightWidth,
|
|
285
|
+
sidebarClasses
|
|
286
|
+
)}>
|
|
287
|
+
{rightContent}
|
|
288
|
+
</aside>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
{/* Footer */}
|
|
294
|
+
{footer && (
|
|
295
|
+
<footer className="w-full border-t border-gray-200">
|
|
296
|
+
{footer}
|
|
297
|
+
</footer>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{/* Mobile FAB (only if left panel exists) */}
|
|
301
|
+
{leftContent && (
|
|
302
|
+
<div className={leftClasses.hideClass}>
|
|
303
|
+
<FloatingMenuButton onClick={open} />
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export default SidebarLayout
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SidebarLayout, default } from './SidebarLayout.jsx'
|