@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.
- package/LICENSE +201 -0
- package/README.md +345 -0
- package/package.json +47 -0
- package/src/components/Asset/Asset.jsx +161 -0
- package/src/components/Asset/index.js +1 -0
- package/src/components/Disclaimer/Disclaimer.jsx +198 -0
- package/src/components/Disclaimer/index.js +1 -0
- package/src/components/FileLogo/FileLogo.jsx +148 -0
- package/src/components/FileLogo/index.js +1 -0
- package/src/components/Icon/Icon.jsx +214 -0
- package/src/components/Icon/index.js +1 -0
- package/src/components/Image/Image.jsx +194 -0
- package/src/components/Image/index.js +1 -0
- package/src/components/Link/Link.jsx +261 -0
- package/src/components/Link/index.js +1 -0
- package/src/components/Media/Media.jsx +322 -0
- package/src/components/Media/index.js +1 -0
- package/src/components/MediaIcon/MediaIcon.jsx +95 -0
- package/src/components/MediaIcon/index.js +1 -0
- package/src/components/SafeHtml/SafeHtml.jsx +93 -0
- package/src/components/SafeHtml/index.js +1 -0
- package/src/components/Section/Render.jsx +245 -0
- package/src/components/Section/Section.jsx +131 -0
- package/src/components/Section/index.js +3 -0
- package/src/components/Section/renderers/Alert.jsx +101 -0
- package/src/components/Section/renderers/Code.jsx +70 -0
- package/src/components/Section/renderers/Details.jsx +77 -0
- package/src/components/Section/renderers/Divider.jsx +42 -0
- package/src/components/Section/renderers/Table.jsx +55 -0
- package/src/components/Section/renderers/index.js +11 -0
- package/src/components/Text/Text.jsx +207 -0
- package/src/components/Text/index.js +14 -0
- package/src/hooks/index.js +1 -0
- package/src/hooks/useWebsite.js +77 -0
- package/src/index.js +69 -0
- package/src/styles/index.css +8 -0
- package/src/utils/index.js +104 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icon Component
|
|
3
|
+
*
|
|
4
|
+
* Renders SVG icons from various sources:
|
|
5
|
+
* - Direct SVG content
|
|
6
|
+
* - URL to SVG file
|
|
7
|
+
* - Built-in icons
|
|
8
|
+
*
|
|
9
|
+
* @module @uniweb/kit/Icon
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useState, useEffect, useMemo } from 'react'
|
|
13
|
+
import { cn } from '../../utils/index.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Built-in demo icons (simple SVG paths)
|
|
17
|
+
*/
|
|
18
|
+
const BUILT_IN_ICONS = {
|
|
19
|
+
check: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>',
|
|
20
|
+
alert: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>',
|
|
21
|
+
user: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>',
|
|
22
|
+
heart: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>',
|
|
23
|
+
settings: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>',
|
|
24
|
+
star: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>',
|
|
25
|
+
close: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>',
|
|
26
|
+
menu: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>',
|
|
27
|
+
chevronDown: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>',
|
|
28
|
+
chevronRight: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>',
|
|
29
|
+
externalLink: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>',
|
|
30
|
+
download: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>',
|
|
31
|
+
play: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse SVG content and extract the inner elements
|
|
36
|
+
* @param {string} svgContent - Raw SVG string
|
|
37
|
+
* @returns {Object} { viewBox, content, width, height }
|
|
38
|
+
*/
|
|
39
|
+
function parseSvg(svgContent) {
|
|
40
|
+
if (!svgContent) return null
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const parser = new DOMParser()
|
|
44
|
+
const doc = parser.parseFromString(svgContent, 'image/svg+xml')
|
|
45
|
+
const svg = doc.querySelector('svg')
|
|
46
|
+
|
|
47
|
+
if (!svg) return null
|
|
48
|
+
|
|
49
|
+
const viewBox = svg.getAttribute('viewBox') || '0 0 24 24'
|
|
50
|
+
const width = svg.getAttribute('width')
|
|
51
|
+
const height = svg.getAttribute('height')
|
|
52
|
+
|
|
53
|
+
// Get inner content
|
|
54
|
+
const content = svg.innerHTML
|
|
55
|
+
|
|
56
|
+
return { viewBox, content, width, height }
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.warn('[Icon] Error parsing SVG:', error)
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Icon - SVG icon component
|
|
65
|
+
*
|
|
66
|
+
* @param {Object} props
|
|
67
|
+
* @param {string} [props.name] - Built-in icon name or custom name
|
|
68
|
+
* @param {string} [props.svg] - Direct SVG content
|
|
69
|
+
* @param {string} [props.url] - URL to fetch SVG from
|
|
70
|
+
* @param {string} [props.size='24'] - Icon size in pixels
|
|
71
|
+
* @param {string} [props.color] - Icon color (defaults to currentColor)
|
|
72
|
+
* @param {boolean} [props.preserveColors=false] - Keep original SVG colors
|
|
73
|
+
* @param {string} [props.className] - Additional CSS classes
|
|
74
|
+
* @param {React.ReactNode} [props.loadingComponent] - Custom loading UI
|
|
75
|
+
* @param {React.ReactNode} [props.errorComponent] - Custom error UI
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* // Built-in icon
|
|
79
|
+
* <Icon name="check" size="20" color="green" />
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* // From URL
|
|
83
|
+
* <Icon url="/icons/custom.svg" size="32" />
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* // Direct SVG content
|
|
87
|
+
* <Icon svg="<svg>...</svg>" />
|
|
88
|
+
*/
|
|
89
|
+
export function Icon({
|
|
90
|
+
name,
|
|
91
|
+
svg,
|
|
92
|
+
url,
|
|
93
|
+
icon, // Legacy prop - can be string (URL) or object
|
|
94
|
+
size = '24',
|
|
95
|
+
color,
|
|
96
|
+
preserveColors = false,
|
|
97
|
+
className,
|
|
98
|
+
loadingComponent,
|
|
99
|
+
errorComponent,
|
|
100
|
+
...props
|
|
101
|
+
}) {
|
|
102
|
+
const [fetchedSvg, setFetchedSvg] = useState(null)
|
|
103
|
+
const [loading, setLoading] = useState(false)
|
|
104
|
+
const [error, setError] = useState(false)
|
|
105
|
+
|
|
106
|
+
// Handle legacy icon prop
|
|
107
|
+
const iconUrl = url || (typeof icon === 'string' ? icon : icon?.url)
|
|
108
|
+
const iconSvg = svg || (typeof icon === 'object' ? icon.svg : null)
|
|
109
|
+
const iconName = name || (typeof icon === 'object' ? icon.name : null)
|
|
110
|
+
|
|
111
|
+
// Fetch SVG from URL
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!iconUrl) return
|
|
114
|
+
|
|
115
|
+
setLoading(true)
|
|
116
|
+
setError(false)
|
|
117
|
+
|
|
118
|
+
fetch(iconUrl)
|
|
119
|
+
.then((res) => {
|
|
120
|
+
if (!res.ok) throw new Error('Failed to fetch icon')
|
|
121
|
+
return res.text()
|
|
122
|
+
})
|
|
123
|
+
.then((svgText) => {
|
|
124
|
+
setFetchedSvg(svgText)
|
|
125
|
+
setLoading(false)
|
|
126
|
+
})
|
|
127
|
+
.catch((err) => {
|
|
128
|
+
console.warn('[Icon] Error fetching:', err)
|
|
129
|
+
setError(true)
|
|
130
|
+
setLoading(false)
|
|
131
|
+
})
|
|
132
|
+
}, [iconUrl])
|
|
133
|
+
|
|
134
|
+
// Determine the SVG content to render
|
|
135
|
+
const svgData = useMemo(() => {
|
|
136
|
+
// Priority: direct SVG > fetched SVG > built-in
|
|
137
|
+
if (iconSvg) {
|
|
138
|
+
return parseSvg(iconSvg)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (fetchedSvg) {
|
|
142
|
+
return parseSvg(fetchedSvg)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Built-in icons
|
|
146
|
+
const builtInName = iconName || name
|
|
147
|
+
if (builtInName && BUILT_IN_ICONS[builtInName]) {
|
|
148
|
+
return {
|
|
149
|
+
viewBox: '0 0 24 24',
|
|
150
|
+
content: BUILT_IN_ICONS[builtInName],
|
|
151
|
+
isBuiltIn: true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return null
|
|
156
|
+
}, [iconSvg, fetchedSvg, iconName, name])
|
|
157
|
+
|
|
158
|
+
// Loading state
|
|
159
|
+
if (loading) {
|
|
160
|
+
return loadingComponent || (
|
|
161
|
+
<span
|
|
162
|
+
className={cn('inline-block animate-pulse bg-gray-200 rounded', className)}
|
|
163
|
+
style={{ width: size, height: size }}
|
|
164
|
+
/>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Error state
|
|
169
|
+
if (error) {
|
|
170
|
+
return errorComponent || null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// No content
|
|
174
|
+
if (!svgData) {
|
|
175
|
+
// Fallback: render URL as img if we have a URL but couldn't parse SVG
|
|
176
|
+
if (iconUrl) {
|
|
177
|
+
return (
|
|
178
|
+
<img
|
|
179
|
+
src={iconUrl}
|
|
180
|
+
alt=""
|
|
181
|
+
width={size}
|
|
182
|
+
height={size}
|
|
183
|
+
className={className}
|
|
184
|
+
aria-hidden="true"
|
|
185
|
+
{...props}
|
|
186
|
+
/>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Build style
|
|
193
|
+
const style = {
|
|
194
|
+
width: size,
|
|
195
|
+
height: size,
|
|
196
|
+
...(color && !preserveColors ? { color } : {})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<svg
|
|
201
|
+
viewBox={svgData.viewBox}
|
|
202
|
+
fill={svgData.isBuiltIn ? 'none' : (preserveColors ? undefined : 'currentColor')}
|
|
203
|
+
stroke={svgData.isBuiltIn ? 'currentColor' : undefined}
|
|
204
|
+
className={cn('inline-block', className)}
|
|
205
|
+
style={style}
|
|
206
|
+
role="img"
|
|
207
|
+
aria-hidden="true"
|
|
208
|
+
dangerouslySetInnerHTML={{ __html: svgData.content }}
|
|
209
|
+
{...props}
|
|
210
|
+
/>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export default Icon
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Icon, default } from './Icon.jsx'
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Component
|
|
3
|
+
*
|
|
4
|
+
* Versatile image component supporting:
|
|
5
|
+
* - Profile avatars and banners
|
|
6
|
+
* - Asset-based images
|
|
7
|
+
* - Direct URLs
|
|
8
|
+
* - CSS filters
|
|
9
|
+
* - Responsive sizing
|
|
10
|
+
*
|
|
11
|
+
* @module @uniweb/kit/Image
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useState, useCallback } from 'react'
|
|
15
|
+
import { Link } from '../Link/index.js'
|
|
16
|
+
import { cn } from '../../utils/index.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Size presets for images
|
|
20
|
+
*/
|
|
21
|
+
const SIZE_CLASSES = {
|
|
22
|
+
xs: 'w-8 h-8',
|
|
23
|
+
sm: 'w-12 h-12',
|
|
24
|
+
md: 'w-16 h-16',
|
|
25
|
+
lg: 'w-24 h-24',
|
|
26
|
+
xl: 'w-32 h-32',
|
|
27
|
+
'2xl': 'w-48 h-48',
|
|
28
|
+
full: 'w-full h-full'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build CSS filter string from filter object
|
|
33
|
+
* @param {Object} filter - Filter settings
|
|
34
|
+
* @returns {string} CSS filter value
|
|
35
|
+
*/
|
|
36
|
+
function buildFilterStyle(filter) {
|
|
37
|
+
if (!filter || typeof filter !== 'object') return undefined
|
|
38
|
+
|
|
39
|
+
const filters = []
|
|
40
|
+
|
|
41
|
+
if (filter.blur) filters.push(`blur(${filter.blur}px)`)
|
|
42
|
+
if (filter.brightness) filters.push(`brightness(${filter.brightness}%)`)
|
|
43
|
+
if (filter.contrast) filters.push(`contrast(${filter.contrast}%)`)
|
|
44
|
+
if (filter.grayscale) filters.push(`grayscale(${filter.grayscale}%)`)
|
|
45
|
+
if (filter.saturate) filters.push(`saturate(${filter.saturate}%)`)
|
|
46
|
+
if (filter.sepia) filters.push(`sepia(${filter.sepia}%)`)
|
|
47
|
+
|
|
48
|
+
return filters.length > 0 ? filters.join(' ') : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Image - Versatile image component
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} props
|
|
55
|
+
* @param {Object} [props.profile] - Profile object for avatar/banner images
|
|
56
|
+
* @param {string} [props.type] - Image type: 'avatar', 'banner', or 'image'
|
|
57
|
+
* @param {string} [props.size] - Size preset: 'xs', 'sm', 'md', 'lg', 'xl', '2xl', 'full'
|
|
58
|
+
* @param {string|Object} [props.value] - Asset identifier or object
|
|
59
|
+
* @param {string} [props.src] - Direct image URL
|
|
60
|
+
* @param {string} [props.url] - Direct image URL (alias)
|
|
61
|
+
* @param {string} [props.alt] - Alt text for accessibility
|
|
62
|
+
* @param {string} [props.href] - Make image a clickable link
|
|
63
|
+
* @param {boolean|string} [props.rounded] - true for full rounding, or custom class
|
|
64
|
+
* @param {Object} [props.filter] - CSS filter settings
|
|
65
|
+
* @param {string} [props.loading='lazy'] - Loading strategy
|
|
66
|
+
* @param {string} [props.className] - Additional CSS classes
|
|
67
|
+
* @param {boolean} [props.ariaHidden] - Hide from screen readers
|
|
68
|
+
* @param {Function} [props.onError] - Error callback
|
|
69
|
+
* @param {Function} [props.onLoad] - Load callback
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* // Direct URL
|
|
73
|
+
* <Image src="/images/hero.jpg" alt="Hero image" />
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* // Profile avatar
|
|
77
|
+
* <Image profile={profile} type="avatar" size="lg" rounded />
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* // With filters
|
|
81
|
+
* <Image src="/photo.jpg" filter={{ grayscale: 100, brightness: 110 }} />
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* // Clickable image
|
|
85
|
+
* <Image src="/logo.png" href="/about" alt="Company logo" />
|
|
86
|
+
*/
|
|
87
|
+
export function Image({
|
|
88
|
+
profile,
|
|
89
|
+
type,
|
|
90
|
+
size,
|
|
91
|
+
value,
|
|
92
|
+
src,
|
|
93
|
+
url,
|
|
94
|
+
alt = '',
|
|
95
|
+
href,
|
|
96
|
+
rounded,
|
|
97
|
+
filter,
|
|
98
|
+
loading = 'lazy',
|
|
99
|
+
className,
|
|
100
|
+
ariaHidden,
|
|
101
|
+
onError,
|
|
102
|
+
onLoad,
|
|
103
|
+
...props
|
|
104
|
+
}) {
|
|
105
|
+
const [hasError, setHasError] = useState(false)
|
|
106
|
+
const [imageSrc, setImageSrc] = useState(null)
|
|
107
|
+
|
|
108
|
+
// Determine the image source
|
|
109
|
+
let resolvedSrc = src || url || ''
|
|
110
|
+
let resolvedAlt = alt
|
|
111
|
+
|
|
112
|
+
// Handle profile-based images
|
|
113
|
+
if (profile && type) {
|
|
114
|
+
if (type === 'avatar' || type === 'banner') {
|
|
115
|
+
// Use profile methods if available
|
|
116
|
+
if (typeof profile.getImageInfo === 'function') {
|
|
117
|
+
const imageInfo = profile.getImageInfo(type, size)
|
|
118
|
+
resolvedSrc = imageInfo?.url || resolvedSrc
|
|
119
|
+
resolvedAlt = imageInfo?.alt || resolvedAlt
|
|
120
|
+
}
|
|
121
|
+
} else if (value && typeof profile.getAssetInfo === 'function') {
|
|
122
|
+
const assetInfo = profile.getAssetInfo(value, true, alt)
|
|
123
|
+
resolvedSrc = assetInfo?.src || resolvedSrc
|
|
124
|
+
resolvedAlt = assetInfo?.alt || resolvedAlt
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Handle value as direct source
|
|
129
|
+
if (!resolvedSrc && value) {
|
|
130
|
+
if (typeof value === 'string') {
|
|
131
|
+
resolvedSrc = value
|
|
132
|
+
} else if (value.url || value.src) {
|
|
133
|
+
resolvedSrc = value.url || value.src
|
|
134
|
+
resolvedAlt = value.alt || resolvedAlt
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Build classes
|
|
139
|
+
const sizeClass = size && SIZE_CLASSES[size]
|
|
140
|
+
const roundedClass = rounded === true ? 'rounded-full' : (typeof rounded === 'string' ? rounded : '')
|
|
141
|
+
|
|
142
|
+
const imageClasses = cn(
|
|
143
|
+
'object-cover',
|
|
144
|
+
sizeClass,
|
|
145
|
+
roundedClass,
|
|
146
|
+
className
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
// Build filter style
|
|
150
|
+
const filterStyle = buildFilterStyle(filter)
|
|
151
|
+
|
|
152
|
+
// Handle error
|
|
153
|
+
const handleError = useCallback((e) => {
|
|
154
|
+
setHasError(true)
|
|
155
|
+
onError?.(e)
|
|
156
|
+
}, [onError])
|
|
157
|
+
|
|
158
|
+
// Handle load
|
|
159
|
+
const handleLoad = useCallback((e) => {
|
|
160
|
+
onLoad?.(e)
|
|
161
|
+
}, [onLoad])
|
|
162
|
+
|
|
163
|
+
// Don't render if no source or error
|
|
164
|
+
if (!resolvedSrc || hasError) {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const imageElement = (
|
|
169
|
+
<img
|
|
170
|
+
src={resolvedSrc}
|
|
171
|
+
alt={resolvedAlt}
|
|
172
|
+
loading={loading}
|
|
173
|
+
className={imageClasses}
|
|
174
|
+
style={filterStyle ? { filter: filterStyle } : undefined}
|
|
175
|
+
onError={handleError}
|
|
176
|
+
onLoad={handleLoad}
|
|
177
|
+
aria-hidden={ariaHidden}
|
|
178
|
+
{...props}
|
|
179
|
+
/>
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
// Wrap in link if href provided
|
|
183
|
+
if (href) {
|
|
184
|
+
return (
|
|
185
|
+
<Link to={href} className="inline-block">
|
|
186
|
+
{imageElement}
|
|
187
|
+
</Link>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return imageElement
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export default Image
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Image, default } from './Image.jsx'
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link Component
|
|
3
|
+
*
|
|
4
|
+
* A smart link wrapper that handles:
|
|
5
|
+
* - Internal navigation via React Router
|
|
6
|
+
* - External links with appropriate attributes
|
|
7
|
+
* - File downloads
|
|
8
|
+
* - Auto-generated accessible titles
|
|
9
|
+
*
|
|
10
|
+
* @module @uniweb/kit/Link
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React from 'react'
|
|
14
|
+
import { useWebsite } from '../../hooks/useWebsite.js'
|
|
15
|
+
import { isExternalUrl, isFileUrl } from '../../utils/index.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Social media platforms for auto-generating link titles
|
|
19
|
+
*/
|
|
20
|
+
const SOCIAL_PLATFORMS = {
|
|
21
|
+
'twitter.com': 'Twitter',
|
|
22
|
+
'x.com': 'X',
|
|
23
|
+
'facebook.com': 'Facebook',
|
|
24
|
+
'linkedin.com': 'LinkedIn',
|
|
25
|
+
'instagram.com': 'Instagram',
|
|
26
|
+
'youtube.com': 'YouTube',
|
|
27
|
+
'github.com': 'GitHub',
|
|
28
|
+
'medium.com': 'Medium'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect if URL is a social media link
|
|
33
|
+
* @param {string} url
|
|
34
|
+
* @returns {string|null} Platform name or null
|
|
35
|
+
*/
|
|
36
|
+
function detectSocialPlatform(url) {
|
|
37
|
+
if (!url) return null
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const urlObj = new URL(url)
|
|
41
|
+
const host = urlObj.hostname.replace('www.', '')
|
|
42
|
+
|
|
43
|
+
for (const [domain, name] of Object.entries(SOCIAL_PLATFORMS)) {
|
|
44
|
+
if (host.includes(domain)) return name
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Invalid URL
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generate an accessible title for a link
|
|
55
|
+
* @param {string} href - The link URL
|
|
56
|
+
* @param {Function} localize - Localization function
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function generateTitle(href, localize) {
|
|
60
|
+
if (!href) return ''
|
|
61
|
+
|
|
62
|
+
// Social media links
|
|
63
|
+
const platform = detectSocialPlatform(href)
|
|
64
|
+
if (platform) {
|
|
65
|
+
return localize({
|
|
66
|
+
en: `View on ${platform}`,
|
|
67
|
+
fr: `Voir sur ${platform}`,
|
|
68
|
+
es: `Ver en ${platform}`
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Email links
|
|
73
|
+
if (href.startsWith('mailto:')) {
|
|
74
|
+
const email = href.replace('mailto:', '').split('?')[0]
|
|
75
|
+
return localize({
|
|
76
|
+
en: `Send email to ${email}`,
|
|
77
|
+
fr: `Envoyer un e-mail à ${email}`,
|
|
78
|
+
es: `Enviar correo a ${email}`
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Phone links
|
|
83
|
+
if (href.startsWith('tel:')) {
|
|
84
|
+
const phone = href.replace('tel:', '')
|
|
85
|
+
return localize({
|
|
86
|
+
en: `Call ${phone}`,
|
|
87
|
+
fr: `Appeler ${phone}`,
|
|
88
|
+
es: `Llamar a ${phone}`
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// File downloads
|
|
93
|
+
if (isFileUrl(href)) {
|
|
94
|
+
return localize({
|
|
95
|
+
en: 'Download file',
|
|
96
|
+
fr: 'Télécharger le fichier',
|
|
97
|
+
es: 'Descargar archivo'
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// External links
|
|
102
|
+
if (isExternalUrl(href)) {
|
|
103
|
+
return localize({
|
|
104
|
+
en: 'Open external link',
|
|
105
|
+
fr: 'Ouvrir le lien externe',
|
|
106
|
+
es: 'Abrir enlace externo'
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Internal links - humanize the path
|
|
111
|
+
try {
|
|
112
|
+
const url = new URL(href, window.location.origin)
|
|
113
|
+
const path = decodeURIComponent(url.pathname)
|
|
114
|
+
.replace(/^\/+/, '')
|
|
115
|
+
.replace(/[-_]/g, ' ')
|
|
116
|
+
.replace(/\.\w+$/, '')
|
|
117
|
+
.trim()
|
|
118
|
+
|
|
119
|
+
if (path) {
|
|
120
|
+
return localize({
|
|
121
|
+
en: `Go to ${path}`,
|
|
122
|
+
fr: `Aller à ${path}`,
|
|
123
|
+
es: `Ir a ${path}`
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Invalid URL
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return ''
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Link - Smart link component for Uniweb foundations
|
|
135
|
+
*
|
|
136
|
+
* @param {Object} props
|
|
137
|
+
* @param {string} [props.to] - Destination URL (alias for href)
|
|
138
|
+
* @param {string} [props.href] - Destination URL
|
|
139
|
+
* @param {string} [props.title] - Custom title/tooltip (auto-generated if not provided)
|
|
140
|
+
* @param {string} [props.target] - Link target (_blank, _self, etc.)
|
|
141
|
+
* @param {string} [props.className] - CSS classes
|
|
142
|
+
* @param {boolean} [props.download] - Force download behavior
|
|
143
|
+
* @param {React.ReactNode} props.children - Link content
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* // Internal link
|
|
147
|
+
* <Link to="/about">About Us</Link>
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* // External link (automatically opens in new tab)
|
|
151
|
+
* <Link href="https://github.com">GitHub</Link>
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* // Download link
|
|
155
|
+
* <Link href="/files/report.pdf" download>Download Report</Link>
|
|
156
|
+
*/
|
|
157
|
+
export function Link({
|
|
158
|
+
to,
|
|
159
|
+
href,
|
|
160
|
+
title,
|
|
161
|
+
target,
|
|
162
|
+
download,
|
|
163
|
+
className,
|
|
164
|
+
children,
|
|
165
|
+
...props
|
|
166
|
+
}) {
|
|
167
|
+
const { localize, makeHref, getRoutingComponents } = useWebsite()
|
|
168
|
+
const RouterLink = getRoutingComponents()?.Link
|
|
169
|
+
|
|
170
|
+
// Normalize href
|
|
171
|
+
let linkHref = href || to || ''
|
|
172
|
+
|
|
173
|
+
// Handle topic: protocol (internal reference)
|
|
174
|
+
if (linkHref.startsWith('topic:')) {
|
|
175
|
+
linkHref = makeHref(linkHref)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Auto-generate title if not provided
|
|
179
|
+
const linkTitle = title || generateTitle(linkHref, localize)
|
|
180
|
+
|
|
181
|
+
// Determine if this should be a download
|
|
182
|
+
const isDownload = download || isFileUrl(linkHref)
|
|
183
|
+
|
|
184
|
+
// Determine if external
|
|
185
|
+
const isExternal = isExternalUrl(linkHref)
|
|
186
|
+
|
|
187
|
+
// File downloads
|
|
188
|
+
if (isDownload) {
|
|
189
|
+
return (
|
|
190
|
+
<a
|
|
191
|
+
href={linkHref}
|
|
192
|
+
download
|
|
193
|
+
target="_blank"
|
|
194
|
+
rel="noopener noreferrer"
|
|
195
|
+
title={linkTitle}
|
|
196
|
+
className={className}
|
|
197
|
+
{...props}
|
|
198
|
+
>
|
|
199
|
+
{children}
|
|
200
|
+
</a>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// External links
|
|
205
|
+
if (isExternal) {
|
|
206
|
+
return (
|
|
207
|
+
<a
|
|
208
|
+
href={linkHref}
|
|
209
|
+
target={target || '_blank'}
|
|
210
|
+
rel="noopener noreferrer"
|
|
211
|
+
title={linkTitle}
|
|
212
|
+
className={className}
|
|
213
|
+
{...props}
|
|
214
|
+
>
|
|
215
|
+
{children}
|
|
216
|
+
</a>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Special protocols (mailto:, tel:)
|
|
221
|
+
if (linkHref.startsWith('mailto:') || linkHref.startsWith('tel:')) {
|
|
222
|
+
return (
|
|
223
|
+
<a
|
|
224
|
+
href={linkHref}
|
|
225
|
+
title={linkTitle}
|
|
226
|
+
className={className}
|
|
227
|
+
{...props}
|
|
228
|
+
>
|
|
229
|
+
{children}
|
|
230
|
+
</a>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Internal links - use React Router if available
|
|
235
|
+
if (RouterLink) {
|
|
236
|
+
return (
|
|
237
|
+
<RouterLink
|
|
238
|
+
to={linkHref}
|
|
239
|
+
title={linkTitle}
|
|
240
|
+
className={className}
|
|
241
|
+
{...props}
|
|
242
|
+
>
|
|
243
|
+
{children}
|
|
244
|
+
</RouterLink>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Fallback to regular anchor
|
|
249
|
+
return (
|
|
250
|
+
<a
|
|
251
|
+
href={linkHref}
|
|
252
|
+
title={linkTitle}
|
|
253
|
+
className={className}
|
|
254
|
+
{...props}
|
|
255
|
+
>
|
|
256
|
+
{children}
|
|
257
|
+
</a>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export default Link
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Link, default } from './Link.jsx'
|