@uniweb/kit 0.1.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 (37) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +345 -0
  3. package/package.json +47 -0
  4. package/src/components/Asset/Asset.jsx +161 -0
  5. package/src/components/Asset/index.js +1 -0
  6. package/src/components/Disclaimer/Disclaimer.jsx +198 -0
  7. package/src/components/Disclaimer/index.js +1 -0
  8. package/src/components/FileLogo/FileLogo.jsx +148 -0
  9. package/src/components/FileLogo/index.js +1 -0
  10. package/src/components/Icon/Icon.jsx +214 -0
  11. package/src/components/Icon/index.js +1 -0
  12. package/src/components/Image/Image.jsx +194 -0
  13. package/src/components/Image/index.js +1 -0
  14. package/src/components/Link/Link.jsx +261 -0
  15. package/src/components/Link/index.js +1 -0
  16. package/src/components/Media/Media.jsx +322 -0
  17. package/src/components/Media/index.js +1 -0
  18. package/src/components/MediaIcon/MediaIcon.jsx +95 -0
  19. package/src/components/MediaIcon/index.js +1 -0
  20. package/src/components/SafeHtml/SafeHtml.jsx +93 -0
  21. package/src/components/SafeHtml/index.js +1 -0
  22. package/src/components/Section/Render.jsx +245 -0
  23. package/src/components/Section/Section.jsx +131 -0
  24. package/src/components/Section/index.js +3 -0
  25. package/src/components/Section/renderers/Alert.jsx +101 -0
  26. package/src/components/Section/renderers/Code.jsx +70 -0
  27. package/src/components/Section/renderers/Details.jsx +77 -0
  28. package/src/components/Section/renderers/Divider.jsx +42 -0
  29. package/src/components/Section/renderers/Table.jsx +55 -0
  30. package/src/components/Section/renderers/index.js +11 -0
  31. package/src/components/Text/Text.jsx +207 -0
  32. package/src/components/Text/index.js +14 -0
  33. package/src/hooks/index.js +1 -0
  34. package/src/hooks/useWebsite.js +77 -0
  35. package/src/index.js +69 -0
  36. package/src/styles/index.css +8 -0
  37. package/src/utils/index.js +104 -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'
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Disclaimer Component
3
+ *
4
+ * Modal disclaimer dialog for terms, privacy notices, etc.
5
+ *
6
+ * @module @uniweb/kit/Disclaimer
7
+ */
8
+
9
+ import React, { useState, useCallback, useEffect } from 'react'
10
+ import { cn } from '../../utils/index.js'
11
+ import { useWebsite } from '../../hooks/useWebsite.js'
12
+ import { SafeHtml } from '../SafeHtml/index.js'
13
+
14
+ /**
15
+ * Disclaimer Modal
16
+ */
17
+ function DisclaimerModal({ isOpen, onClose, title, content, className }) {
18
+ // Handle escape key
19
+ useEffect(() => {
20
+ const handleEscape = (e) => {
21
+ if (e.key === 'Escape') onClose()
22
+ }
23
+
24
+ if (isOpen) {
25
+ document.addEventListener('keydown', handleEscape)
26
+ document.body.style.overflow = 'hidden'
27
+ }
28
+
29
+ return () => {
30
+ document.removeEventListener('keydown', handleEscape)
31
+ document.body.style.overflow = ''
32
+ }
33
+ }, [isOpen, onClose])
34
+
35
+ if (!isOpen) return null
36
+
37
+ return (
38
+ <div className="fixed inset-0 z-50 overflow-y-auto">
39
+ {/* Backdrop */}
40
+ <div
41
+ className="fixed inset-0 bg-black/50 transition-opacity"
42
+ onClick={onClose}
43
+ aria-hidden="true"
44
+ />
45
+
46
+ {/* Dialog */}
47
+ <div className="flex min-h-full items-center justify-center p-4">
48
+ <div
49
+ className={cn(
50
+ 'relative w-full max-w-lg transform overflow-hidden rounded-lg',
51
+ 'bg-white shadow-xl transition-all',
52
+ className
53
+ )}
54
+ role="dialog"
55
+ aria-modal="true"
56
+ aria-labelledby="disclaimer-title"
57
+ >
58
+ {/* Header */}
59
+ <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
60
+ <h3 id="disclaimer-title" className="text-lg font-medium text-gray-900">
61
+ {title}
62
+ </h3>
63
+ <button
64
+ onClick={onClose}
65
+ className="rounded-md p-1 text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
66
+ aria-label="Close"
67
+ >
68
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
69
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
70
+ </svg>
71
+ </button>
72
+ </div>
73
+
74
+ {/* Content */}
75
+ <div className="px-4 py-4 max-h-96 overflow-y-auto">
76
+ {typeof content === 'string' ? (
77
+ <SafeHtml value={content} className="prose prose-sm" />
78
+ ) : (
79
+ content
80
+ )}
81
+ </div>
82
+
83
+ {/* Footer */}
84
+ <div className="border-t border-gray-200 px-4 py-3 flex justify-end">
85
+ <button
86
+ onClick={onClose}
87
+ className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
88
+ >
89
+ Close
90
+ </button>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ )
96
+ }
97
+
98
+ /**
99
+ * Disclaimer - Disclaimer trigger and modal
100
+ *
101
+ * @param {Object} props
102
+ * @param {string} [props.type='link'] - Trigger type: 'link', 'button', 'popup'
103
+ * @param {string} props.title - Disclaimer title
104
+ * @param {string|React.ReactNode} props.content - Disclaimer content
105
+ * @param {string} [props.triggerText] - Text for the trigger element
106
+ * @param {string} [props.className] - Additional CSS classes
107
+ * @param {React.ReactNode} [props.children] - Custom trigger element
108
+ *
109
+ * @example
110
+ * <Disclaimer
111
+ * title="Terms of Service"
112
+ * content="<p>Please read our terms...</p>"
113
+ * triggerText="View Terms"
114
+ * />
115
+ *
116
+ * @example
117
+ * <Disclaimer title="Privacy Policy" content={<PrivacyContent />}>
118
+ * <button className="underline">Privacy Policy</button>
119
+ * </Disclaimer>
120
+ */
121
+ export function Disclaimer({
122
+ type = 'link',
123
+ title,
124
+ content,
125
+ triggerText,
126
+ className,
127
+ children,
128
+ ...props
129
+ }) {
130
+ const [isOpen, setIsOpen] = useState(false)
131
+ const { localize } = useWebsite()
132
+
133
+ const handleOpen = useCallback(() => setIsOpen(true), [])
134
+ const handleClose = useCallback(() => setIsOpen(false), [])
135
+
136
+ // Auto-open for popup type
137
+ useEffect(() => {
138
+ if (type === 'popup') {
139
+ setIsOpen(true)
140
+ }
141
+ }, [type])
142
+
143
+ // Localized title and content
144
+ const localizedTitle = typeof title === 'object' ? localize(title) : title
145
+ const localizedContent = typeof content === 'object' && !React.isValidElement(content)
146
+ ? localize(content)
147
+ : content
148
+ const localizedTriggerText = typeof triggerText === 'object'
149
+ ? localize(triggerText)
150
+ : (triggerText || localizedTitle)
151
+
152
+ // Render trigger
153
+ const trigger = children ? (
154
+ React.cloneElement(React.Children.only(children), {
155
+ onClick: handleOpen,
156
+ role: 'button',
157
+ 'aria-haspopup': 'dialog'
158
+ })
159
+ ) : type === 'button' ? (
160
+ <button
161
+ onClick={handleOpen}
162
+ className={cn(
163
+ 'inline-flex items-center px-3 py-1.5 text-sm font-medium',
164
+ 'text-blue-600 hover:text-blue-700',
165
+ 'border border-blue-600 rounded-md hover:bg-blue-50',
166
+ className
167
+ )}
168
+ {...props}
169
+ >
170
+ {localizedTriggerText}
171
+ </button>
172
+ ) : (
173
+ <button
174
+ onClick={handleOpen}
175
+ className={cn(
176
+ 'text-blue-600 hover:text-blue-700 underline text-sm',
177
+ className
178
+ )}
179
+ {...props}
180
+ >
181
+ {localizedTriggerText}
182
+ </button>
183
+ )
184
+
185
+ return (
186
+ <>
187
+ {type !== 'popup' && trigger}
188
+ <DisclaimerModal
189
+ isOpen={isOpen}
190
+ onClose={handleClose}
191
+ title={localizedTitle}
192
+ content={localizedContent}
193
+ />
194
+ </>
195
+ )
196
+ }
197
+
198
+ export default Disclaimer
@@ -0,0 +1 @@
1
+ export { Disclaimer, default } from './Disclaimer.jsx'
@@ -0,0 +1,148 @@
1
+ /**
2
+ * FileLogo Component
3
+ *
4
+ * Displays file type icons based on filename extension.
5
+ *
6
+ * @module @uniweb/kit/FileLogo
7
+ */
8
+
9
+ import React from 'react'
10
+ import { cn } from '../../utils/index.js'
11
+
12
+ /**
13
+ * File type to icon mapping
14
+ * Using simple SVG icons for common file types
15
+ */
16
+ const FILE_ICONS = {
17
+ // Images
18
+ jpg: 'image',
19
+ jpeg: 'image',
20
+ png: 'image',
21
+ gif: 'image',
22
+ webp: 'image',
23
+ svg: 'image',
24
+
25
+ // Documents
26
+ pdf: 'pdf',
27
+ doc: 'word',
28
+ docx: 'word',
29
+ txt: 'text',
30
+ rtf: 'text',
31
+
32
+ // Spreadsheets
33
+ xls: 'excel',
34
+ xlsx: 'excel',
35
+ xlsm: 'excel',
36
+ xlsb: 'excel',
37
+ csv: 'excel',
38
+
39
+ // Presentations
40
+ ppt: 'powerpoint',
41
+ pptx: 'powerpoint',
42
+
43
+ // Code
44
+ html: 'code',
45
+ css: 'code',
46
+ js: 'code',
47
+ json: 'code',
48
+ xml: 'code',
49
+
50
+ // Archives
51
+ zip: 'archive',
52
+ rar: 'archive',
53
+ '7z': 'archive',
54
+ tar: 'archive',
55
+ gz: 'archive',
56
+
57
+ // Media
58
+ mp3: 'audio',
59
+ wav: 'audio',
60
+ mp4: 'video',
61
+ mov: 'video',
62
+ avi: 'video'
63
+ }
64
+
65
+ /**
66
+ * SVG icons for each file type
67
+ */
68
+ const ICONS = {
69
+ image: (
70
+ <path d="M4 5a2 2 0 012-2h12a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm14 0H6v14h12V5zm-3 4a1 1 0 11-2 0 1 1 0 012 0zm-9 10l3-3 2 2 4-4 3 3v2H6v-0z" />
71
+ ),
72
+ pdf: (
73
+ <path d="M7 2a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V8l-6-6H7zm7 1.5L18.5 8H14V3.5zM9 13h1.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5H10v2H9v-5zm1 2h.5a.5.5 0 000-1H10v1zm3-2h1.75c.69 0 1.25.56 1.25 1.25v2.5c0 .69-.56 1.25-1.25 1.25H13v-5zm1 4h.75a.25.25 0 00.25-.25v-2.5a.25.25 0 00-.25-.25H14v3z" />
74
+ ),
75
+ word: (
76
+ <path d="M7 2a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V8l-6-6H7zm7 1.5L18.5 8H14V3.5zM8 13h1l1.5 4 1.5-4h1l-2 6h-1l-2-6zm7 0h1v6h-1v-6z" />
77
+ ),
78
+ excel: (
79
+ <path d="M7 2a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V8l-6-6H7zm7 1.5L18.5 8H14V3.5zM9 12h2l1 2.5 1-2.5h2l-2 3 2 3h-2l-1-2.5-1 2.5H9l2-3-2-3z" />
80
+ ),
81
+ powerpoint: (
82
+ <path d="M7 2a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V8l-6-6H7zm7 1.5L18.5 8H14V3.5zM9 12h3c1.1 0 2 .9 2 2s-.9 2-2 2h-2v2H9v-6zm1 3h2a1 1 0 000-2h-2v2z" />
83
+ ),
84
+ text: (
85
+ <path d="M7 2a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V8l-6-6H7zm7 1.5L18.5 8H14V3.5zM8 12h8v1H8v-1zm0 3h8v1H8v-1zm0 3h5v1H8v-1z" />
86
+ ),
87
+ code: (
88
+ <path d="M7 2a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V8l-6-6H7zm7 1.5L18.5 8H14V3.5zM9.5 12l-2 3 2 3 .7-.7L8.5 15l1.7-2.3-.7-.7zm5 0l-.7.7 1.7 2.3-1.7 2.3.7.7 2-3-2-3z" />
89
+ ),
90
+ archive: (
91
+ <path d="M7 2a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V8l-6-6H7zm7 1.5L18.5 8H14V3.5zM11 10h2v1h-2v-1zm0 2h2v1h-2v-1zm0 2h2v3h-2v-3z" />
92
+ ),
93
+ audio: (
94
+ <path d="M7 2a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V8l-6-6H7zm7 1.5L18.5 8H14V3.5zM12 11a3 3 0 100 6 3 3 0 000-6zm0 1a2 2 0 110 4 2 2 0 010-4zm0 1a1 1 0 100 2 1 1 0 000-2z" />
95
+ ),
96
+ video: (
97
+ <path d="M7 2a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V8l-6-6H7zm7 1.5L18.5 8H14V3.5zM9 12l6 3-6 3v-6z" />
98
+ ),
99
+ default: (
100
+ <path d="M7 2a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V8l-6-6H7zm7 1.5L18.5 8H14V3.5z" />
101
+ )
102
+ }
103
+
104
+ /**
105
+ * Get file extension from filename
106
+ * @param {string} filename
107
+ * @returns {string}
108
+ */
109
+ function getExtension(filename) {
110
+ if (!filename) return ''
111
+ const parts = filename.toLowerCase().split('.')
112
+ return parts.length > 1 ? parts.pop() : ''
113
+ }
114
+
115
+ /**
116
+ * FileLogo - File type icon component
117
+ *
118
+ * @param {Object} props
119
+ * @param {string} props.filename - Filename to determine icon
120
+ * @param {string} [props.size='24'] - Icon size in pixels
121
+ * @param {string} [props.className] - Additional CSS classes
122
+ *
123
+ * @example
124
+ * <FileLogo filename="report.pdf" size="32" />
125
+ *
126
+ * @example
127
+ * <FileLogo filename="data.xlsx" className="text-green-600" />
128
+ */
129
+ export function FileLogo({ filename, size = '24', className, ...props }) {
130
+ const ext = getExtension(filename)
131
+ const iconType = FILE_ICONS[ext] || 'default'
132
+ const icon = ICONS[iconType]
133
+
134
+ return (
135
+ <svg
136
+ viewBox="0 0 24 24"
137
+ fill="currentColor"
138
+ className={cn('inline-block', className)}
139
+ style={{ width: size, height: size }}
140
+ aria-hidden="true"
141
+ {...props}
142
+ >
143
+ {icon}
144
+ </svg>
145
+ )
146
+ }
147
+
148
+ export default FileLogo
@@ -0,0 +1 @@
1
+ export { FileLogo, default } from './FileLogo.jsx'