@uniweb/kit 0.1.5 → 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.
Files changed (30) hide show
  1. package/package.json +3 -2
  2. package/src/components/Asset/Asset.jsx +31 -81
  3. package/src/components/Media/Media.jsx +27 -125
  4. package/src/components/SocialIcon/index.jsx +146 -0
  5. package/src/hooks/index.js +6 -0
  6. package/src/hooks/useAccordion.js +143 -0
  7. package/src/hooks/useActiveRoute.js +97 -0
  8. package/src/hooks/useGridLayout.js +71 -0
  9. package/src/hooks/useMobileMenu.js +58 -0
  10. package/src/hooks/useScrolled.js +48 -0
  11. package/src/hooks/useTheme.js +205 -0
  12. package/src/index.js +29 -10
  13. package/src/styled/Asset/Asset.jsx +161 -0
  14. package/src/styled/Asset/index.js +1 -0
  15. package/src/{components → styled}/Disclaimer/Disclaimer.jsx +1 -1
  16. package/src/styled/Media/Media.jsx +322 -0
  17. package/src/styled/Media/index.js +1 -0
  18. package/src/{components → styled}/Section/Render.jsx +4 -4
  19. package/src/{components → styled}/Section/index.js +6 -0
  20. package/src/{components → styled}/Section/renderers/Alert.jsx +1 -1
  21. package/src/{components → styled}/Section/renderers/Details.jsx +1 -1
  22. package/src/{components → styled}/Section/renderers/Table.jsx +1 -1
  23. package/src/{components → styled}/Section/renderers/index.js +1 -1
  24. package/src/styled/SidebarLayout/SidebarLayout.jsx +310 -0
  25. package/src/styled/SidebarLayout/index.js +1 -0
  26. package/src/styled/index.js +40 -0
  27. /package/src/{components → styled}/Disclaimer/index.js +0 -0
  28. /package/src/{components → styled}/Section/Section.jsx +0 -0
  29. /package/src/{components → styled}/Section/renderers/Code.jsx +0 -0
  30. /package/src/{components → styled}/Section/renderers/Divider.jsx +0 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Asset Component
3
+ *
4
+ * File asset preview with download functionality.
5
+ *
6
+ * @module @uniweb/kit/Asset
7
+ */
8
+
9
+ import React, { useState, useCallback, forwardRef, useImperativeHandle } from 'react'
10
+ import { cn } from '../../utils/index.js'
11
+ import { FileLogo } from '../FileLogo/index.js'
12
+ import { Image } from '../Image/index.js'
13
+ import { useWebsite } from '../../hooks/useWebsite.js'
14
+
15
+ /**
16
+ * Check if file is an image
17
+ * @param {string} filename
18
+ * @returns {boolean}
19
+ */
20
+ function isImageFile(filename) {
21
+ if (!filename) return false
22
+ const ext = filename.toLowerCase().split('.').pop()
23
+ return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)
24
+ }
25
+
26
+ /**
27
+ * Asset - File preview with download
28
+ *
29
+ * @param {Object} props
30
+ * @param {string|Object} props.value - Asset identifier or object
31
+ * @param {Object} [props.profile] - Profile object for asset resolution
32
+ * @param {boolean} [props.withDownload=true] - Show download button
33
+ * @param {string} [props.className] - Additional CSS classes
34
+ *
35
+ * @example
36
+ * <Asset value="document.pdf" profile={profile} />
37
+ *
38
+ * @example
39
+ * <Asset value={{ src: "/files/report.pdf", filename: "report.pdf" }} />
40
+ */
41
+ export const Asset = forwardRef(function Asset(
42
+ { value, profile, withDownload = true, className, ...props },
43
+ ref
44
+ ) {
45
+ const { localize } = useWebsite()
46
+ const [imageError, setImageError] = useState(false)
47
+ const [isHovered, setIsHovered] = useState(false)
48
+
49
+ // Resolve asset info
50
+ let src = ''
51
+ let filename = ''
52
+ let alt = ''
53
+
54
+ if (typeof value === 'string') {
55
+ src = value
56
+ filename = value.split('/').pop() || value
57
+ } else if (value && typeof value === 'object') {
58
+ src = value.src || value.url || ''
59
+ filename = value.filename || value.name || src.split('/').pop() || ''
60
+ alt = value.alt || filename
61
+ }
62
+
63
+ // Use profile to resolve asset if available
64
+ if (profile && typeof profile.getAssetInfo === 'function') {
65
+ const assetInfo = profile.getAssetInfo(value, true, filename)
66
+ src = assetInfo?.src || src
67
+ filename = assetInfo?.filename || filename
68
+ alt = assetInfo?.alt || alt
69
+ }
70
+
71
+ const isImage = isImageFile(filename)
72
+
73
+ // Handle download
74
+ const handleDownload = useCallback(async () => {
75
+ if (!src) return
76
+
77
+ try {
78
+ // Try to trigger download
79
+ const downloadUrl = src.includes('?') ? `${src}&download=true` : `${src}?download=true`
80
+ const response = await fetch(downloadUrl)
81
+ const blob = await response.blob()
82
+
83
+ const link = document.createElement('a')
84
+ link.href = URL.createObjectURL(blob)
85
+ link.download = filename
86
+ document.body.appendChild(link)
87
+ link.click()
88
+ document.body.removeChild(link)
89
+ URL.revokeObjectURL(link.href)
90
+ } catch (error) {
91
+ // Fallback: open in new tab
92
+ window.open(src, '_blank')
93
+ }
94
+ }, [src, filename])
95
+
96
+ // Expose download method via ref
97
+ useImperativeHandle(ref, () => ({
98
+ triggerDownload: handleDownload
99
+ }), [handleDownload])
100
+
101
+ // Handle image error
102
+ const handleImageError = useCallback(() => {
103
+ setImageError(true)
104
+ }, [])
105
+
106
+ if (!src) return null
107
+
108
+ return (
109
+ <div
110
+ className={cn(
111
+ 'relative inline-block rounded-lg overflow-hidden border border-gray-200',
112
+ 'transition-shadow hover:shadow-md',
113
+ className
114
+ )}
115
+ onMouseEnter={() => setIsHovered(true)}
116
+ onMouseLeave={() => setIsHovered(false)}
117
+ {...props}
118
+ >
119
+ {/* Preview */}
120
+ <div className="w-32 h-32 flex items-center justify-center bg-gray-50">
121
+ {isImage && !imageError ? (
122
+ <Image
123
+ src={src}
124
+ alt={alt}
125
+ className="w-full h-full object-cover"
126
+ onError={handleImageError}
127
+ />
128
+ ) : (
129
+ <FileLogo filename={filename} size="48" className="text-gray-400" />
130
+ )}
131
+ </div>
132
+
133
+ {/* Filename */}
134
+ <div className="px-2 py-1 text-xs text-gray-600 truncate max-w-[128px]" title={filename}>
135
+ {filename}
136
+ </div>
137
+
138
+ {/* Download overlay */}
139
+ {withDownload && (
140
+ <button
141
+ onClick={handleDownload}
142
+ className={cn(
143
+ 'absolute inset-0 flex items-center justify-center',
144
+ 'bg-black/50 text-white transition-opacity',
145
+ isHovered ? 'opacity-100' : 'opacity-0'
146
+ )}
147
+ aria-label={localize({
148
+ en: 'Download file',
149
+ fr: 'Télécharger le fichier'
150
+ })}
151
+ >
152
+ <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
153
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
154
+ </svg>
155
+ </button>
156
+ )}
157
+ </div>
158
+ )
159
+ })
160
+
161
+ export default Asset
@@ -0,0 +1 @@
1
+ export { Asset, default } from './Asset.jsx'
@@ -9,7 +9,7 @@
9
9
  import React, { useState, useCallback, useEffect } from 'react'
10
10
  import { cn } from '../../utils/index.js'
11
11
  import { useWebsite } from '../../hooks/useWebsite.js'
12
- import { SafeHtml } from '../SafeHtml/index.js'
12
+ import { SafeHtml } from '../../components/SafeHtml/index.js'
13
13
 
14
14
  /**
15
15
  * Disclaimer Modal
@@ -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 '../SafeHtml/index.js'
13
- import { Image } from '../Image/index.js'
14
- import { Media } from '../Media/index.js'
15
- import { Link } from '../Link/index.js'
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'
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Section Component (Tailwind-styled)
3
+ *
4
+ * Rich content section renderer with full styling.
5
+ */
6
+
1
7
  export { Section, default } from './Section.jsx'
2
8
  export { Render } from './Render.jsx'
3
9
  export * from './renderers/index.js'
@@ -8,7 +8,7 @@
8
8
 
9
9
  import React from 'react'
10
10
  import { cn } from '../../../utils/index.js'
11
- import { SafeHtml } from '../../SafeHtml/index.js'
11
+ import { SafeHtml } from '../../../components/SafeHtml/index.js'
12
12
 
13
13
  /**
14
14
  * Alert type configurations
@@ -8,7 +8,7 @@
8
8
 
9
9
  import React, { useState } from 'react'
10
10
  import { cn } from '../../../utils/index.js'
11
- import { SafeHtml } from '../../SafeHtml/index.js'
11
+ import { SafeHtml } from '../../../components/SafeHtml/index.js'
12
12
 
13
13
  /**
14
14
  * Details - Collapsible section
@@ -8,7 +8,7 @@
8
8
 
9
9
  import React from 'react'
10
10
  import { cn } from '../../../utils/index.js'
11
- import { SafeHtml } from '../../SafeHtml/index.js'
11
+ import { SafeHtml } from '../../../components/SafeHtml/index.js'
12
12
 
13
13
  /**
14
14
  * Table - Table renderer
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Section Renderers
2
+ * Section Renderers (Tailwind-styled)
3
3
  *
4
4
  * Individual content type renderers for the Section component.
5
5
  */