uifolio 0.3.0 → 0.5.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/README.md +15 -8
- package/cli/uifolio.mjs +538 -147
- package/package.json +25 -8
- package/src/components/ui/audio-player.tsx +1 -1
- package/src/components/ui/calendar.tsx +1 -1
- package/src/components/ui/choice.tsx +1 -1
- package/src/components/ui/color-picker.tsx +3 -3
- package/src/components/ui/command-menu.tsx +1 -1
- package/src/components/ui/file-upload.tsx +1 -1
- package/src/components/ui/icon-picker.tsx +28 -20
- package/src/components/ui/motion.tsx +10 -8
- package/src/components/ui/otp-input.tsx +7 -31
- package/src/components/ui/resizable.tsx +5 -5
- package/src/components/ui/rich-text-editor.tsx +3 -3
- package/src/components/ui/stories.tsx +13 -10
- package/src/components/ui/time-picker.tsx +1 -1
- package/src/components/ui/video-player.tsx +157 -117
- package/src/lib/use-controllable.ts +54 -0
- package/src/lib/video.ts +6 -5
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uifolio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "CLI qui copie des composants React (calendrier, lecteurs vidéo/audio, OTP, éditeur, upload) et leurs helpers dans ton projet, avec des tokens Tailwind CSS 4.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Barronakn <jeanbarronalokpon@gmail.com>",
|
|
8
|
-
"homepage": "https://
|
|
8
|
+
"homepage": "https://ui.folio.barrondev.site",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/Barronakn/UIfolio.git"
|
|
@@ -14,17 +14,32 @@
|
|
|
14
14
|
"url": "https://github.com/Barronakn/UIfolio/issues"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
|
-
"uifolio",
|
|
18
17
|
"react",
|
|
19
|
-
"
|
|
20
|
-
"ui",
|
|
21
|
-
"cli",
|
|
22
|
-
"tailwind",
|
|
18
|
+
"react-component",
|
|
23
19
|
"tailwindcss",
|
|
20
|
+
"ui-components",
|
|
21
|
+
"component-library",
|
|
22
|
+
"cli",
|
|
23
|
+
"uifolio",
|
|
24
24
|
"calendar",
|
|
25
25
|
"video-player",
|
|
26
|
+
"audio-player",
|
|
26
27
|
"otp",
|
|
27
|
-
"
|
|
28
|
+
"color-picker",
|
|
29
|
+
"file-upload",
|
|
30
|
+
"rich-text-editor",
|
|
31
|
+
"command-menu",
|
|
32
|
+
"resizable-panels",
|
|
33
|
+
"time-picker",
|
|
34
|
+
"cadence",
|
|
35
|
+
"storytelling",
|
|
36
|
+
"accordion",
|
|
37
|
+
"text-reveal",
|
|
38
|
+
"hls.js",
|
|
39
|
+
"vtt",
|
|
40
|
+
"shadcn",
|
|
41
|
+
"shadcn-clone",
|
|
42
|
+
"shadcn-like"
|
|
28
43
|
],
|
|
29
44
|
"engines": {
|
|
30
45
|
"node": ">=18"
|
|
@@ -38,9 +53,11 @@
|
|
|
38
53
|
"files": [
|
|
39
54
|
"cli",
|
|
40
55
|
"src/components/ui",
|
|
56
|
+
"!src/components/ui/stories-tokens.ts",
|
|
41
57
|
"src/lib/color.ts",
|
|
42
58
|
"src/lib/date.ts",
|
|
43
59
|
"src/lib/uploader.ts",
|
|
60
|
+
"src/lib/use-controllable.ts",
|
|
44
61
|
"src/lib/use-hotkeys.ts",
|
|
45
62
|
"src/lib/use-reduced-motion.ts",
|
|
46
63
|
"src/lib/use-theme.ts",
|
|
@@ -30,7 +30,7 @@ export function AudioPlayer({
|
|
|
30
30
|
variant = 'card',
|
|
31
31
|
loop = false,
|
|
32
32
|
className,
|
|
33
|
-
}: AudioPlayerProps) {
|
|
33
|
+
}: Readonly<AudioPlayerProps>) {
|
|
34
34
|
const audioRef = useRef<HTMLAudioElement>(null)
|
|
35
35
|
const [playing, setPlaying] = useState(false)
|
|
36
36
|
const [currentTime, setCurrentTime] = useState(0)
|
|
@@ -54,7 +54,7 @@ export function Calendar({
|
|
|
54
54
|
disabled,
|
|
55
55
|
manualInput = true,
|
|
56
56
|
className,
|
|
57
|
-
}: CalendarProps) {
|
|
57
|
+
}: Readonly<CalendarProps>) {
|
|
58
58
|
const [internalValue, setInternalValue] = useState<CalendarValue>(defaultValue ?? null)
|
|
59
59
|
const currentValue = value !== undefined ? value : internalValue
|
|
60
60
|
|
|
@@ -43,12 +43,12 @@ function ChannelField({
|
|
|
43
43
|
value,
|
|
44
44
|
disabled,
|
|
45
45
|
onChange,
|
|
46
|
-
}: {
|
|
46
|
+
}: Readonly<{
|
|
47
47
|
label: string
|
|
48
48
|
value: number
|
|
49
49
|
disabled: boolean
|
|
50
50
|
onChange: (value: number) => void
|
|
51
|
-
}) {
|
|
51
|
+
}>) {
|
|
52
52
|
const [draft, setDraft] = useState<string | null>(null)
|
|
53
53
|
|
|
54
54
|
return (
|
|
@@ -123,7 +123,7 @@ export function ColorPicker({
|
|
|
123
123
|
open,
|
|
124
124
|
defaultOpen = false,
|
|
125
125
|
onOpenChange,
|
|
126
|
-
}: ColorPickerProps) {
|
|
126
|
+
}: Readonly<ColorPickerProps>) {
|
|
127
127
|
const generatedId = useId()
|
|
128
128
|
const inputId = id ?? generatedId
|
|
129
129
|
const panelId = `${inputId}-panel`
|
|
@@ -37,7 +37,7 @@ export function CommandMenu({
|
|
|
37
37
|
emptyText = 'Aucun résultat.',
|
|
38
38
|
emptyState,
|
|
39
39
|
className,
|
|
40
|
-
}: CommandMenuProps) {
|
|
40
|
+
}: Readonly<CommandMenuProps>) {
|
|
41
41
|
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
|
42
42
|
const isOpen = open ?? internalOpen
|
|
43
43
|
const [query, setQuery] = useState('')
|
|
@@ -43,7 +43,7 @@ export function FileUpload({
|
|
|
43
43
|
uploader,
|
|
44
44
|
onChange,
|
|
45
45
|
className,
|
|
46
|
-
}: FileUploadProps) {
|
|
46
|
+
}: Readonly<FileUploadProps>) {
|
|
47
47
|
const inputId = useId()
|
|
48
48
|
const [items, setItems] = useState<UploadItem[]>([])
|
|
49
49
|
const [dragging, setDragging] = useState(false)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react'
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { Search, X, Check, ChevronDown } from 'lucide-react'
|
|
3
3
|
import { cn } from '@/lib/utils'
|
|
4
4
|
|
|
@@ -126,11 +126,11 @@ function IconSvg({
|
|
|
126
126
|
data,
|
|
127
127
|
color,
|
|
128
128
|
className,
|
|
129
|
-
}: {
|
|
129
|
+
}: Readonly<{
|
|
130
130
|
data: IconData | null
|
|
131
131
|
color: string
|
|
132
132
|
className?: string
|
|
133
|
-
}) {
|
|
133
|
+
}>) {
|
|
134
134
|
if (!data) {
|
|
135
135
|
return <span className={cn('inline-block rounded bg-muted', className)} />
|
|
136
136
|
}
|
|
@@ -149,26 +149,31 @@ export function IconGlyph({
|
|
|
149
149
|
icon,
|
|
150
150
|
color = 'currentColor',
|
|
151
151
|
className,
|
|
152
|
-
}: {
|
|
152
|
+
}: Readonly<{
|
|
153
153
|
icon: string
|
|
154
154
|
color?: string
|
|
155
155
|
className?: string
|
|
156
|
-
}) {
|
|
156
|
+
}>) {
|
|
157
157
|
const [data, setData] = useState<IconData | null>(() => iconCache.get(icon) ?? null)
|
|
158
|
+
const [prevIcon, setPrevIcon] = useState(icon)
|
|
159
|
+
const loadRef = useRef<Promise<void> | null>(null)
|
|
160
|
+
|
|
161
|
+
// Ajustement pendant le rendu : une icône déjà en cache s'applique sans effet.
|
|
162
|
+
if (prevIcon !== icon) {
|
|
163
|
+
setPrevIcon(icon)
|
|
164
|
+
setData(iconCache.get(icon) ?? null)
|
|
165
|
+
}
|
|
158
166
|
|
|
159
167
|
useEffect(() => {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
let cancelled = false
|
|
166
|
-
setData(null)
|
|
167
|
-
loadIcons([icon]).then((icons) => {
|
|
168
|
-
if (!cancelled) setData(icons[icon] ?? null)
|
|
168
|
+
if (iconCache.get(icon)) return
|
|
169
|
+
const current = loadIcons([icon]).then((icons) => {
|
|
170
|
+
if (loadRef.current === current) {
|
|
171
|
+
setData(icons[icon] ?? null)
|
|
172
|
+
}
|
|
169
173
|
})
|
|
174
|
+
loadRef.current = current
|
|
170
175
|
return () => {
|
|
171
|
-
|
|
176
|
+
loadRef.current = null
|
|
172
177
|
}
|
|
173
178
|
}, [icon])
|
|
174
179
|
|
|
@@ -181,12 +186,13 @@ export function IconPicker({
|
|
|
181
186
|
onChange,
|
|
182
187
|
placeholder = 'Choisir une icône',
|
|
183
188
|
className,
|
|
184
|
-
}: IconPickerProps) {
|
|
189
|
+
}: Readonly<IconPickerProps>) {
|
|
185
190
|
const [open, setOpen] = useState(false)
|
|
186
191
|
const [search, setSearch] = useState('')
|
|
187
192
|
const [icons, setIcons] = useState<string[]>([])
|
|
188
193
|
const [bodies, setBodies] = useState<Record<string, IconData>>({})
|
|
189
194
|
const [loading, setLoading] = useState(false)
|
|
195
|
+
const loadingRef = useRef<Promise<void> | null>(null)
|
|
190
196
|
|
|
191
197
|
useEffect(() => {
|
|
192
198
|
if (!open) return
|
|
@@ -221,12 +227,14 @@ export function IconPicker({
|
|
|
221
227
|
|
|
222
228
|
useEffect(() => {
|
|
223
229
|
if (icons.length === 0) return
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
230
|
+
const current = loadIcons(icons).then((loaded) => {
|
|
231
|
+
if (loadingRef.current === current) {
|
|
232
|
+
setBodies((prev) => ({ ...prev, ...loaded }))
|
|
233
|
+
}
|
|
227
234
|
})
|
|
235
|
+
loadingRef.current = current
|
|
228
236
|
return () => {
|
|
229
|
-
|
|
237
|
+
loadingRef.current = null
|
|
230
238
|
}
|
|
231
239
|
}, [icons])
|
|
232
240
|
|
|
@@ -21,7 +21,7 @@ export function FadeIn({
|
|
|
21
21
|
duration = 520,
|
|
22
22
|
className,
|
|
23
23
|
style,
|
|
24
|
-
}: MotionProps) {
|
|
24
|
+
}: Readonly<MotionProps>) {
|
|
25
25
|
const reduced = useReducedMotion()
|
|
26
26
|
return (
|
|
27
27
|
<div
|
|
@@ -45,10 +45,12 @@ export function SlideIn({
|
|
|
45
45
|
style,
|
|
46
46
|
direction = 'up',
|
|
47
47
|
distance = 20,
|
|
48
|
-
}:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
}: Readonly<
|
|
49
|
+
MotionProps & {
|
|
50
|
+
direction?: 'up' | 'down' | 'left' | 'right'
|
|
51
|
+
distance?: number
|
|
52
|
+
}
|
|
53
|
+
>) {
|
|
52
54
|
const reduced = useReducedMotion()
|
|
53
55
|
const offset = {
|
|
54
56
|
up: `translate3d(0, ${distance}px, 0)`,
|
|
@@ -82,7 +84,7 @@ export function ScaleIn({
|
|
|
82
84
|
className,
|
|
83
85
|
style,
|
|
84
86
|
from = 0.92,
|
|
85
|
-
}: MotionProps & { from?: number }) {
|
|
87
|
+
}: Readonly<MotionProps & { from?: number }>) {
|
|
86
88
|
const reduced = useReducedMotion()
|
|
87
89
|
return (
|
|
88
90
|
<div
|
|
@@ -108,13 +110,13 @@ export function Stagger({
|
|
|
108
110
|
duration = 460,
|
|
109
111
|
className,
|
|
110
112
|
itemClassName,
|
|
111
|
-
}: {
|
|
113
|
+
}: Readonly<{
|
|
112
114
|
children: ReactNode
|
|
113
115
|
delay?: number
|
|
114
116
|
duration?: number
|
|
115
117
|
className?: string
|
|
116
118
|
itemClassName?: string
|
|
117
|
-
}) {
|
|
119
|
+
}>) {
|
|
118
120
|
return (
|
|
119
121
|
<div className={className}>
|
|
120
122
|
{Children.map(children, (child, index) => (
|
|
@@ -39,14 +39,13 @@ export function OtpInput({
|
|
|
39
39
|
error = false,
|
|
40
40
|
className,
|
|
41
41
|
inputClassName,
|
|
42
|
-
}: OtpInputProps) {
|
|
42
|
+
}: Readonly<OtpInputProps>) {
|
|
43
43
|
const [internalValue, setInternalValue] = useState(defaultValue)
|
|
44
44
|
const current = value ?? internalValue
|
|
45
|
-
const
|
|
45
|
+
const complete = current.length === length && /^\d+$/.test(current)
|
|
46
|
+
const phase = complete && !error ? 'success' : 'idle'
|
|
46
47
|
|
|
47
48
|
const inputs = useRef<Array<HTMLInputElement | null>>([])
|
|
48
|
-
const completed = useRef(false)
|
|
49
|
-
const completedValue = useRef<string | null>(null)
|
|
50
49
|
const onChangeRef = useRef(onChange)
|
|
51
50
|
const onCompleteRef = useRef(onComplete)
|
|
52
51
|
|
|
@@ -55,39 +54,16 @@ export function OtpInput({
|
|
|
55
54
|
onCompleteRef.current = onComplete
|
|
56
55
|
})
|
|
57
56
|
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (autoFocus) inputs.current[0]?.focus()
|
|
59
|
+
}, [autoFocus])
|
|
60
|
+
|
|
58
61
|
const commit = (next: string) => {
|
|
59
62
|
const cleaned = next.replace(/\D/g, '').slice(0, length)
|
|
60
63
|
if (value === undefined) setInternalValue(cleaned)
|
|
61
64
|
onChangeRef.current?.(cleaned)
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
const complete = current.length === length && /^\d+$/.test(current)
|
|
65
|
-
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
if (!complete) {
|
|
68
|
-
completed.current = false
|
|
69
|
-
completedValue.current = null
|
|
70
|
-
setPhase('idle')
|
|
71
|
-
return
|
|
72
|
-
}
|
|
73
|
-
if (completed.current && completedValue.current === current) return
|
|
74
|
-
completed.current = true
|
|
75
|
-
completedValue.current = current
|
|
76
|
-
setPhase('success')
|
|
77
|
-
onCompleteRef.current?.(current)
|
|
78
|
-
}, [complete, current, length])
|
|
79
|
-
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
if (!error) return
|
|
82
|
-
completed.current = false
|
|
83
|
-
completedValue.current = null
|
|
84
|
-
setPhase('idle')
|
|
85
|
-
}, [error])
|
|
86
|
-
|
|
87
|
-
useEffect(() => {
|
|
88
|
-
if (autoFocus) inputs.current[0]?.focus()
|
|
89
|
-
}, [autoFocus])
|
|
90
|
-
|
|
91
67
|
const focusInput = (index: number) => {
|
|
92
68
|
inputs.current[Math.max(0, Math.min(length - 1, index))]?.focus()
|
|
93
69
|
inputs.current[Math.max(0, Math.min(length - 1, index))]?.select()
|
|
@@ -10,11 +10,11 @@ export function ResizablePanelGroup({
|
|
|
10
10
|
direction = 'horizontal',
|
|
11
11
|
className,
|
|
12
12
|
children,
|
|
13
|
-
}: {
|
|
13
|
+
}: Readonly<{
|
|
14
14
|
direction?: ResizableDirection
|
|
15
15
|
className?: string
|
|
16
16
|
children: ReactNode
|
|
17
|
-
}) {
|
|
17
|
+
}>) {
|
|
18
18
|
return (
|
|
19
19
|
<div
|
|
20
20
|
data-resizable-group
|
|
@@ -35,12 +35,12 @@ export function ResizablePanel({
|
|
|
35
35
|
minSize = 10,
|
|
36
36
|
className,
|
|
37
37
|
children,
|
|
38
|
-
}: {
|
|
38
|
+
}: Readonly<{
|
|
39
39
|
defaultSize?: number
|
|
40
40
|
minSize?: number
|
|
41
41
|
className?: string
|
|
42
42
|
children: ReactNode
|
|
43
|
-
}) {
|
|
43
|
+
}>) {
|
|
44
44
|
return (
|
|
45
45
|
<div
|
|
46
46
|
data-resizable-panel
|
|
@@ -54,7 +54,7 @@ export function ResizablePanel({
|
|
|
54
54
|
)
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function ResizableHandle({ className }: { className?: string }) {
|
|
57
|
+
export function ResizableHandle({ className }: Readonly<{ className?: string }>) {
|
|
58
58
|
const ref = useRef<HTMLDivElement>(null)
|
|
59
59
|
const [orientation, setOrientation] = useState<'horizontal' | 'vertical'>('vertical')
|
|
60
60
|
|
|
@@ -43,7 +43,7 @@ export function RichTextEditor({
|
|
|
43
43
|
toolbar = true,
|
|
44
44
|
minHeight = 220,
|
|
45
45
|
className,
|
|
46
|
-
}: RichTextEditorProps) {
|
|
46
|
+
}: Readonly<RichTextEditorProps>) {
|
|
47
47
|
const editorRef = useRef<HTMLDivElement>(null)
|
|
48
48
|
const [internalValue, setInternalValue] = useState(defaultValue)
|
|
49
49
|
const html = value ?? internalValue
|
|
@@ -254,12 +254,12 @@ function ToolButton({
|
|
|
254
254
|
active,
|
|
255
255
|
onClick,
|
|
256
256
|
children,
|
|
257
|
-
}: {
|
|
257
|
+
}: Readonly<{
|
|
258
258
|
label: string
|
|
259
259
|
active?: boolean
|
|
260
260
|
onClick: () => void
|
|
261
261
|
children: ReactNode
|
|
262
|
-
}) {
|
|
262
|
+
}>) {
|
|
263
263
|
return (
|
|
264
264
|
<button
|
|
265
265
|
type="button"
|
|
@@ -37,7 +37,7 @@ export function Stories({
|
|
|
37
37
|
duration = 5000,
|
|
38
38
|
className,
|
|
39
39
|
textBackground = 'linear-gradient(160deg, color-mix(in oklab, var(--primary) 35%, #000) 0%, var(--primary) 55%, color-mix(in oklab, var(--primary) 65%, #fff) 100%)',
|
|
40
|
-
}: StoriesProps) {
|
|
40
|
+
}: Readonly<StoriesProps>) {
|
|
41
41
|
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
|
42
42
|
const isOpen = open ?? internalOpen
|
|
43
43
|
const [index, setIndex] = useState(initialIndex)
|
|
@@ -50,6 +50,11 @@ export function Stories({
|
|
|
50
50
|
|
|
51
51
|
const setOpen = useCallback(
|
|
52
52
|
(next: boolean) => {
|
|
53
|
+
if (next) {
|
|
54
|
+
// Réinitialise la lecture à l'ouverture plutôt que par un effet.
|
|
55
|
+
elapsedRef.current = 0
|
|
56
|
+
setPaused(false)
|
|
57
|
+
}
|
|
53
58
|
if (open === undefined) setInternalOpen(next)
|
|
54
59
|
onOpenChange?.(next)
|
|
55
60
|
},
|
|
@@ -76,13 +81,6 @@ export function Stories({
|
|
|
76
81
|
|
|
77
82
|
const close = useCallback(() => setOpen(false), [setOpen])
|
|
78
83
|
|
|
79
|
-
useEffect(() => {
|
|
80
|
-
if (!isOpen) return
|
|
81
|
-
setIndex(initialIndex)
|
|
82
|
-
setPaused(false)
|
|
83
|
-
elapsedRef.current = 0
|
|
84
|
-
}, [initialIndex, isOpen])
|
|
85
|
-
|
|
86
84
|
useEffect(() => {
|
|
87
85
|
if (!isOpen) return
|
|
88
86
|
const previous = document.body.style.overflow
|
|
@@ -115,14 +113,19 @@ export function Stories({
|
|
|
115
113
|
else void video.play().catch(() => {})
|
|
116
114
|
}, [index, isOpen, paused])
|
|
117
115
|
|
|
116
|
+
const mainLoopRef = useRef<{ startedAt: number; total: number } | null>(null)
|
|
117
|
+
|
|
118
118
|
useEffect(() => {
|
|
119
119
|
if (!isOpen || paused) return
|
|
120
120
|
if (barRef.current) barRef.current.style.transform = 'scaleX(0)'
|
|
121
121
|
|
|
122
|
-
const
|
|
122
|
+
const item = items[index]
|
|
123
|
+
const total = item?.duration ?? duration
|
|
123
124
|
const startedAt = performance.now() - elapsedRef.current
|
|
124
|
-
let frame = 0
|
|
125
125
|
|
|
126
|
+
mainLoopRef.current = { startedAt, total }
|
|
127
|
+
|
|
128
|
+
let frame = 0
|
|
126
129
|
const tick = (now: number) => {
|
|
127
130
|
const elapsed = now - startedAt
|
|
128
131
|
elapsedRef.current = elapsed
|
|
@@ -45,7 +45,7 @@ export function TimePicker({
|
|
|
45
45
|
disabled = false,
|
|
46
46
|
label,
|
|
47
47
|
className,
|
|
48
|
-
}: TimePickerProps) {
|
|
48
|
+
}: Readonly<TimePickerProps>) {
|
|
49
49
|
const [internalValue, setInternalValue] = useState(defaultValue)
|
|
50
50
|
const current = value ?? internalValue
|
|
51
51
|
const inputValue = toInputValue(current, withSeconds)
|