uifolio 0.5.1 → 0.5.4
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 +70 -269
- package/cli/uifolio.mjs +446 -164
- package/package.json +3 -2
- package/src/components/ui/audio-player.tsx +7 -7
- package/src/components/ui/calendar.tsx +5 -5
- package/src/components/ui/color-picker.tsx +17 -17
- package/src/components/ui/command-menu.tsx +5 -5
- package/src/components/ui/file-upload.tsx +11 -11
- package/src/components/ui/icon-picker.tsx +12 -12
- package/src/components/ui/resizable.tsx +1 -1
- package/src/components/ui/rich-text-editor.tsx +17 -18
- package/src/components/ui/stories.tsx +4 -4
- package/src/components/ui/table.tsx +164 -0
- package/src/components/ui/video-player.tsx +49 -49
- package/src/lib/date.ts +30 -30
- package/src/lib/uploader.ts +13 -13
- package/src/lib/use-controllable.ts +9 -9
- package/src/lib/use-hotkeys.ts +1 -1
- package/src/lib/use-theme.ts +2 -2
- package/src/lib/video.ts +12 -12
- package/src/lib/vtt.ts +1 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
import { useControllableValue } from '@/lib/use-controllable'
|
|
6
|
+
|
|
7
|
+
export interface TableColumn<T> {
|
|
8
|
+
/** Stable identifier, also used as the React key of the column. */
|
|
9
|
+
key: string
|
|
10
|
+
header: ReactNode
|
|
11
|
+
cell: (row: T) => ReactNode
|
|
12
|
+
/** Applied to the header and body cells: width, nowrap… */
|
|
13
|
+
className?: string
|
|
14
|
+
align?: 'left' | 'center' | 'right'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TableProps<T> {
|
|
18
|
+
columns: ReadonlyArray<TableColumn<T>>
|
|
19
|
+
rows: ReadonlyArray<T>
|
|
20
|
+
rowKey: (row: T) => string | number
|
|
21
|
+
selectable?: boolean
|
|
22
|
+
selectedKeys?: string[]
|
|
23
|
+
defaultSelectedKeys?: string[]
|
|
24
|
+
onSelectionChange?: (keys: string[]) => void
|
|
25
|
+
actions?: (row: T) => ReactNode
|
|
26
|
+
actionsHeader?: ReactNode
|
|
27
|
+
onRowClick?: (row: T) => void
|
|
28
|
+
emptyText?: string
|
|
29
|
+
className?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function alignClass(align: TableColumn<unknown>['align']): string {
|
|
33
|
+
if (align === 'center') return 'text-center'
|
|
34
|
+
if (align === 'right') return 'text-right'
|
|
35
|
+
return 'text-left'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Data table with a shadcn-like API: typed columns, optional selection and a
|
|
40
|
+
* dedicated actions column. Every cell on the left shares the same `px-4`
|
|
41
|
+
* padding; the actions column keeps a wider `pl-10` gap with the last column.
|
|
42
|
+
*/
|
|
43
|
+
export function Table<T>({
|
|
44
|
+
columns,
|
|
45
|
+
rows,
|
|
46
|
+
rowKey,
|
|
47
|
+
selectable = false,
|
|
48
|
+
selectedKeys,
|
|
49
|
+
defaultSelectedKeys = [],
|
|
50
|
+
onSelectionChange,
|
|
51
|
+
actions,
|
|
52
|
+
actionsHeader = 'Actions',
|
|
53
|
+
onRowClick,
|
|
54
|
+
emptyText = 'No data.',
|
|
55
|
+
className,
|
|
56
|
+
}: Readonly<TableProps<T>>) {
|
|
57
|
+
const [selection, setSelection] = useControllableValue<string[]>(
|
|
58
|
+
selectedKeys,
|
|
59
|
+
defaultSelectedKeys,
|
|
60
|
+
onSelectionChange,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const toggle = (key: string, checked: boolean) => {
|
|
64
|
+
setSelection((current) =>
|
|
65
|
+
checked
|
|
66
|
+
? current.includes(key)
|
|
67
|
+
? current
|
|
68
|
+
: [...current, key]
|
|
69
|
+
: current.filter((entry) => entry !== key),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const columnsCount = columns.length + (actions ? 1 : 0)
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className={cn('w-full overflow-x-auto rounded-sm border bg-card', className)}>
|
|
77
|
+
<table className="w-full min-w-[860px] border-collapse text-left text-xs">
|
|
78
|
+
<thead>
|
|
79
|
+
<tr className="border-b bg-muted/50">
|
|
80
|
+
{columns.map((column) => (
|
|
81
|
+
<th
|
|
82
|
+
key={column.key}
|
|
83
|
+
scope="col"
|
|
84
|
+
className={cn(
|
|
85
|
+
'whitespace-nowrap px-4 py-3.5 text-[11px] font-medium text-muted-foreground',
|
|
86
|
+
alignClass(column.align),
|
|
87
|
+
column.className,
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
{column.header}
|
|
91
|
+
</th>
|
|
92
|
+
))}
|
|
93
|
+
{actions && (
|
|
94
|
+
<th
|
|
95
|
+
scope="col"
|
|
96
|
+
className="whitespace-nowrap py-3.5 pl-10 pr-4 text-right text-[11px] font-medium text-muted-foreground"
|
|
97
|
+
>
|
|
98
|
+
{actionsHeader}
|
|
99
|
+
</th>
|
|
100
|
+
)}
|
|
101
|
+
</tr>
|
|
102
|
+
</thead>
|
|
103
|
+
<tbody>
|
|
104
|
+
{rows.length === 0 ? (
|
|
105
|
+
<tr>
|
|
106
|
+
<td colSpan={columnsCount} className="px-4 py-10 text-center text-muted-foreground">
|
|
107
|
+
{emptyText}
|
|
108
|
+
</td>
|
|
109
|
+
</tr>
|
|
110
|
+
) : (
|
|
111
|
+
rows.map((row) => {
|
|
112
|
+
const key = String(rowKey(row))
|
|
113
|
+
const selected = selection.includes(key)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<tr
|
|
117
|
+
key={key}
|
|
118
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
119
|
+
className={cn(
|
|
120
|
+
'border-b transition last:border-b-0',
|
|
121
|
+
onRowClick && 'cursor-pointer',
|
|
122
|
+
selected ? 'bg-primary/5' : 'hover:bg-muted/30',
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{columns.map((column, index) => (
|
|
126
|
+
<td
|
|
127
|
+
key={column.key}
|
|
128
|
+
className={cn(
|
|
129
|
+
'px-4 py-3.5 align-middle',
|
|
130
|
+
alignClass(column.align),
|
|
131
|
+
column.className,
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
{selectable && index === 0 ? (
|
|
135
|
+
<div className="flex items-center gap-3">
|
|
136
|
+
<input
|
|
137
|
+
type="checkbox"
|
|
138
|
+
checked={selected}
|
|
139
|
+
onChange={(event) => toggle(key, event.target.checked)}
|
|
140
|
+
onClick={(event) => event.stopPropagation()}
|
|
141
|
+
aria-label={`Select row ${key}`}
|
|
142
|
+
className="size-4 shrink-0 cursor-pointer rounded-sm border-border accent-primary"
|
|
143
|
+
/>
|
|
144
|
+
{column.cell(row)}
|
|
145
|
+
</div>
|
|
146
|
+
) : (
|
|
147
|
+
column.cell(row)
|
|
148
|
+
)}
|
|
149
|
+
</td>
|
|
150
|
+
))}
|
|
151
|
+
{actions && (
|
|
152
|
+
<td className="whitespace-nowrap py-3.5 pl-10 pr-4">
|
|
153
|
+
<div className="flex items-center justify-end gap-1.5">{actions(row)}</div>
|
|
154
|
+
</td>
|
|
155
|
+
)}
|
|
156
|
+
</tr>
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
)}
|
|
160
|
+
</tbody>
|
|
161
|
+
</table>
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
@@ -90,7 +90,7 @@ export interface VideoPlayerHandle {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
export interface VideoPlayerProps {
|
|
93
|
-
/**
|
|
93
|
+
/** A source, a local file, a list of qualities, an HLS stream or a YouTube/Vimeo URL. */
|
|
94
94
|
sources: PlayerSource | PlayerSource[]
|
|
95
95
|
poster?: string
|
|
96
96
|
title?: string
|
|
@@ -101,10 +101,10 @@ export interface VideoPlayerProps {
|
|
|
101
101
|
rounded?: VideoRounded
|
|
102
102
|
frame?: VideoFrame
|
|
103
103
|
controls?: VideoControlsMode | boolean
|
|
104
|
-
/** Position
|
|
104
|
+
/** Position of the control bar inside the frame. */
|
|
105
105
|
controlsPosition?: 'bottom' | 'top'
|
|
106
106
|
accent?: string
|
|
107
|
-
/**
|
|
107
|
+
/** Font applied to the control bar and the menus. */
|
|
108
108
|
fontFamily?: string
|
|
109
109
|
/** Bordure du cadre (px) et couleur CSS. */
|
|
110
110
|
borderWidth?: number
|
|
@@ -161,17 +161,17 @@ const iconButton =
|
|
|
161
161
|
const menuPosition = (position: 'bottom' | 'top') =>
|
|
162
162
|
position === 'top' ? 'top-full mt-1.5' : 'bottom-full mb-1.5'
|
|
163
163
|
|
|
164
|
-
/**
|
|
164
|
+
/** The bars follow the global theme radius (`--radius`), like the rest of the catalog. */
|
|
165
165
|
const barRadius = 'rounded-sm'
|
|
166
166
|
|
|
167
167
|
const rangeThumb =
|
|
168
168
|
'[&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:border-0 [&::-webkit-slider-thumb]:bg-[var(--vp-accent)] [&::-moz-range-thumb]:size-2.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-[var(--vp-accent)]'
|
|
169
169
|
|
|
170
|
-
/**
|
|
170
|
+
/** Turns the sources (URL, File, Blob, qualities) into playable sources and manages object URLs. */
|
|
171
171
|
interface SourceResolver {
|
|
172
172
|
sources: ResolvedSource[]
|
|
173
173
|
signature: string
|
|
174
|
-
/**
|
|
174
|
+
/** Revokes the object URLs created for this resolution. */
|
|
175
175
|
dispose: () => void
|
|
176
176
|
}
|
|
177
177
|
|
|
@@ -209,7 +209,7 @@ function resolveSources(input: PlayerSource | PlayerSource[]): SourceResolver {
|
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
/**
|
|
212
|
+
/** Default active track: the one marked `default`, otherwise the first one with cues. */
|
|
213
213
|
function preferDefaultActiveSubtitle(
|
|
214
214
|
loaded: ResolvedSubtitleTrack[],
|
|
215
215
|
list: SubtitleTrack[],
|
|
@@ -227,14 +227,14 @@ function useResolvedSources(input: PlayerSource | PlayerSource[]) {
|
|
|
227
227
|
resolver: resolveSources(input),
|
|
228
228
|
}))
|
|
229
229
|
|
|
230
|
-
//
|
|
230
|
+
// Render-time adjustment when the source really changes (no effect).
|
|
231
231
|
if (entry.signature !== signature) {
|
|
232
232
|
setEntry({ signature, resolver: resolveSources(input) })
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
const resolver = entry.resolver
|
|
236
|
-
//
|
|
237
|
-
//
|
|
236
|
+
// Releases the object URLs of the replaced resolver (the one from mount is not
|
|
237
|
+
// released on unmount: StrictMode would simulate an unmount/remount).
|
|
238
238
|
const previousResolver = useRef(resolver)
|
|
239
239
|
useEffect(() => {
|
|
240
240
|
if (previousResolver.current !== resolver) {
|
|
@@ -317,8 +317,8 @@ export function VideoPlayer({
|
|
|
317
317
|
const [retryKey, setRetryKey] = useState(0)
|
|
318
318
|
const [prevSignature, setPrevSignature] = useState(signature)
|
|
319
319
|
|
|
320
|
-
//
|
|
321
|
-
//
|
|
320
|
+
// Render-time adjustment: the source changed ⇒ we start from a clean state,
|
|
321
|
+
// with no effect and no extra render.
|
|
322
322
|
if (prevSignature !== signature) {
|
|
323
323
|
setPrevSignature(signature)
|
|
324
324
|
setIndex(0)
|
|
@@ -338,8 +338,8 @@ export function VideoPlayer({
|
|
|
338
338
|
[subtitles],
|
|
339
339
|
)
|
|
340
340
|
|
|
341
|
-
//
|
|
342
|
-
//
|
|
341
|
+
// Tracks already loaded no longer match the current signature:
|
|
342
|
+
// we drop them during the reload instead of using a reset effect.
|
|
343
343
|
const [loadedSubtitles, setLoadedSubtitles] = useState<{
|
|
344
344
|
sig: string
|
|
345
345
|
tracks: ResolvedSubtitleTrack[]
|
|
@@ -367,7 +367,7 @@ export function VideoPlayer({
|
|
|
367
367
|
const shouldLoad = inView || !lazy || !observerSupported
|
|
368
368
|
const playbackActive = isEmbed ? embedPlaying : playing
|
|
369
369
|
// L'iframe ne transmet pas les mouvements du pointeur : pour un embed, la barre
|
|
370
|
-
//
|
|
370
|
+
// stays visible, otherwise it could never be called again.
|
|
371
371
|
const showControls =
|
|
372
372
|
isEmbed || !controlsHidden || !playbackActive || openMenu !== null
|
|
373
373
|
|
|
@@ -407,7 +407,7 @@ export function VideoPlayer({
|
|
|
407
407
|
iframeRef.current?.contentWindow?.postMessage(message, '*')
|
|
408
408
|
}, [])
|
|
409
409
|
|
|
410
|
-
/**
|
|
410
|
+
/** Sends a command to the iframe: the custom bar drives the platform player. */
|
|
411
411
|
const postEmbedCommand = useCallback(
|
|
412
412
|
(command: EmbedCommand) => {
|
|
413
413
|
if (!embedKind) return
|
|
@@ -416,7 +416,7 @@ export function VideoPlayer({
|
|
|
416
416
|
[embedKind, postEmbed],
|
|
417
417
|
)
|
|
418
418
|
|
|
419
|
-
/**
|
|
419
|
+
/** Subscribes the player to the iframe events (time, duration, state, volume). */
|
|
420
420
|
const sendEmbedHandshake = useCallback(() => {
|
|
421
421
|
if (!embedKind) return
|
|
422
422
|
for (const message of embedHandshake(embedKind)) postEmbed(message)
|
|
@@ -426,7 +426,7 @@ export function VideoPlayer({
|
|
|
426
426
|
postEmbedCommand({ type: 'rate', rate })
|
|
427
427
|
}, [embedKind, postEmbed, postEmbedCommand, muted, volume, rate])
|
|
428
428
|
|
|
429
|
-
/**
|
|
429
|
+
/** Mounts the iframe (facade → player): playback starts with the user gesture. */
|
|
430
430
|
const startEmbed = useCallback(() => {
|
|
431
431
|
if (!embedId || !embedKind) return
|
|
432
432
|
const effectiveMuted = muted ?? mutedProp
|
|
@@ -445,7 +445,7 @@ export function VideoPlayer({
|
|
|
445
445
|
}, [embedId, embedKind, loop, muted, mutedProp, startAt])
|
|
446
446
|
|
|
447
447
|
const toggleEmbedPlay = useCallback(() => {
|
|
448
|
-
//
|
|
448
|
+
// The iframe is not mounted yet: start it instead of sending a command into the void.
|
|
449
449
|
if (!embedActive) {
|
|
450
450
|
startEmbed()
|
|
451
451
|
return
|
|
@@ -535,7 +535,7 @@ export function VideoPlayer({
|
|
|
535
535
|
void document.exitFullscreen()
|
|
536
536
|
} else if (element.requestFullscreen) {
|
|
537
537
|
void element.requestFullscreen().catch(() => {
|
|
538
|
-
/* iOS Safari
|
|
538
|
+
/* iOS Safari: native fullscreen unavailable on a div */
|
|
539
539
|
})
|
|
540
540
|
}
|
|
541
541
|
}, [])
|
|
@@ -550,7 +550,7 @@ export function VideoPlayer({
|
|
|
550
550
|
await video.requestPictureInPicture()
|
|
551
551
|
}
|
|
552
552
|
} catch {
|
|
553
|
-
/*
|
|
553
|
+
/* refused by the browser */
|
|
554
554
|
}
|
|
555
555
|
}, [])
|
|
556
556
|
|
|
@@ -614,11 +614,11 @@ export function VideoPlayer({
|
|
|
614
614
|
)
|
|
615
615
|
|
|
616
616
|
/* ------------------------------------------------------------------ */
|
|
617
|
-
/*
|
|
617
|
+
/* Prop / state synchronization */
|
|
618
618
|
/* ------------------------------------------------------------------ */
|
|
619
619
|
|
|
620
|
-
//
|
|
621
|
-
//
|
|
620
|
+
// State resets happen during render (see above); only the refs are kept
|
|
621
|
+
// here, and they must not be written during render.
|
|
622
622
|
useEffect(() => {
|
|
623
623
|
startApplied.current = null
|
|
624
624
|
}, [signature])
|
|
@@ -674,8 +674,8 @@ export function VideoPlayer({
|
|
|
674
674
|
postEmbedCommand({ type: 'volume', volume })
|
|
675
675
|
}, [embedActive, isEmbed, postEmbedCommand, volume])
|
|
676
676
|
|
|
677
|
-
//
|
|
678
|
-
//
|
|
677
|
+
// Wires the iframe to our controls: the handshake is replayed while the
|
|
678
|
+
// platform player is not ready (it ignores messages before that).
|
|
679
679
|
useEffect(() => {
|
|
680
680
|
if (!isEmbed || !embedActive) return
|
|
681
681
|
sendEmbedHandshake()
|
|
@@ -687,7 +687,7 @@ export function VideoPlayer({
|
|
|
687
687
|
}
|
|
688
688
|
}, [embedActive, isEmbed, sendEmbedHandshake])
|
|
689
689
|
|
|
690
|
-
//
|
|
690
|
+
// Time, duration and playback state come from the iframe.
|
|
691
691
|
useEffect(() => {
|
|
692
692
|
if (!isEmbed || !embedActive) return
|
|
693
693
|
const onMessage = (event: MessageEvent) => {
|
|
@@ -732,7 +732,7 @@ export function VideoPlayer({
|
|
|
732
732
|
(entries) => {
|
|
733
733
|
const entry = entries[0]
|
|
734
734
|
setInView(entry.isIntersecting)
|
|
735
|
-
//
|
|
735
|
+
// Optimization: playback is paused off-screen.
|
|
736
736
|
if (!entry.isIntersecting) {
|
|
737
737
|
videoRef.current?.pause()
|
|
738
738
|
if (isEmbed) {
|
|
@@ -774,16 +774,16 @@ export function VideoPlayer({
|
|
|
774
774
|
}, 2600)
|
|
775
775
|
}, [])
|
|
776
776
|
|
|
777
|
-
//
|
|
778
|
-
//
|
|
777
|
+
// When playback resumes, the controls are visible again: a render-time
|
|
778
|
+
// adjustment rather than a synchronous setState in the effect.
|
|
779
779
|
const [prevPlaybackActive, setPrevPlaybackActive] = useState(playbackActive)
|
|
780
780
|
if (prevPlaybackActive !== playbackActive) {
|
|
781
781
|
setPrevPlaybackActive(playbackActive)
|
|
782
782
|
if (playbackActive) setControlsHidden(false)
|
|
783
783
|
}
|
|
784
784
|
|
|
785
|
-
//
|
|
786
|
-
//
|
|
785
|
+
// The effect only arms the timer (external system); the reveal is
|
|
786
|
+
// handled by pointer events or by the render adjustment above.
|
|
787
787
|
useEffect(() => {
|
|
788
788
|
if (!playbackActive) {
|
|
789
789
|
if (hideTimer.current) {
|
|
@@ -841,12 +841,12 @@ export function VideoPlayer({
|
|
|
841
841
|
if (autoPlay) void attemptPlay()
|
|
842
842
|
})
|
|
843
843
|
instance.on(HlsConstructor.Events.ERROR, (_event, data) => {
|
|
844
|
-
if (data.fatal) setError(
|
|
844
|
+
if (data.fatal) setError('The HLS stream could not be loaded.')
|
|
845
845
|
})
|
|
846
846
|
holder.current = instance
|
|
847
847
|
})
|
|
848
848
|
.catch(() => {
|
|
849
|
-
setError(
|
|
849
|
+
setError('The hls.js library could not be loaded.')
|
|
850
850
|
})
|
|
851
851
|
}
|
|
852
852
|
|
|
@@ -859,7 +859,7 @@ export function VideoPlayer({
|
|
|
859
859
|
}, [active, attemptPlay, autoPlay, kind, retryKey, shouldLoad])
|
|
860
860
|
|
|
861
861
|
/* ------------------------------------------------------------------ */
|
|
862
|
-
/*
|
|
862
|
+
/* Media events */
|
|
863
863
|
/* ------------------------------------------------------------------ */
|
|
864
864
|
|
|
865
865
|
const handleLoadedMetadata = () => {
|
|
@@ -935,7 +935,7 @@ export function VideoPlayer({
|
|
|
935
935
|
const message =
|
|
936
936
|
typeof MediaError !== 'undefined' && code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
|
937
937
|
? "Format ou source non pris en charge."
|
|
938
|
-
:
|
|
938
|
+
: 'The video could not be loaded.'
|
|
939
939
|
setError(message)
|
|
940
940
|
setLoading(false)
|
|
941
941
|
onError?.(message)
|
|
@@ -1010,7 +1010,7 @@ export function VideoPlayer({
|
|
|
1010
1010
|
: undefined)
|
|
1011
1011
|
|
|
1012
1012
|
const canPip = typeof document !== 'undefined' && document.pictureInPictureEnabled
|
|
1013
|
-
const qualityLabel = active?.quality ?? '
|
|
1013
|
+
const qualityLabel = active?.quality ?? 'Quality'
|
|
1014
1014
|
const activeCues = activeSubtitle >= 0 ? subtitleTracks[activeSubtitle]?.cues : undefined
|
|
1015
1015
|
const currentCue = activeCues?.find(
|
|
1016
1016
|
(cue) => currentTime >= cue.start && currentTime <= cue.end,
|
|
@@ -1020,7 +1020,7 @@ export function VideoPlayer({
|
|
|
1020
1020
|
<div
|
|
1021
1021
|
ref={containerRef}
|
|
1022
1022
|
role="region"
|
|
1023
|
-
aria-label={title ?? '
|
|
1023
|
+
aria-label={title ?? 'Video player'}
|
|
1024
1024
|
tabIndex={0}
|
|
1025
1025
|
onKeyDown={handleKeyDown}
|
|
1026
1026
|
onPointerMove={handlePointerActivity}
|
|
@@ -1052,7 +1052,7 @@ export function VideoPlayer({
|
|
|
1052
1052
|
ref={iframeRef}
|
|
1053
1053
|
key={`${kind}-${embedId}`}
|
|
1054
1054
|
src={embedSrc ?? undefined}
|
|
1055
|
-
title={title ?? '
|
|
1055
|
+
title={title ?? 'Video player'}
|
|
1056
1056
|
className="absolute inset-0 z-0 h-full w-full border-0"
|
|
1057
1057
|
allow="autoplay; fullscreen; picture-in-picture; encrypted-media; clipboard-write"
|
|
1058
1058
|
referrerPolicy="strict-origin-when-cross-origin"
|
|
@@ -1063,7 +1063,7 @@ export function VideoPlayer({
|
|
|
1063
1063
|
<button
|
|
1064
1064
|
type="button"
|
|
1065
1065
|
onClick={startEmbed}
|
|
1066
|
-
aria-label={title ? `
|
|
1066
|
+
aria-label={title ? `Play ${title}` : 'Play the video'}
|
|
1067
1067
|
className="absolute inset-0 z-0 h-full w-full cursor-pointer"
|
|
1068
1068
|
>
|
|
1069
1069
|
{thumbnail && shouldLoad ? (
|
|
@@ -1153,7 +1153,7 @@ export function VideoPlayer({
|
|
|
1153
1153
|
<button
|
|
1154
1154
|
type="button"
|
|
1155
1155
|
onClick={() => void attemptPlay()}
|
|
1156
|
-
aria-label={needsGesture ? '
|
|
1156
|
+
aria-label={needsGesture ? 'Start playback' : 'Play the video'}
|
|
1157
1157
|
className="absolute inset-0 z-10 grid place-items-center bg-black/25 transition-colors duration-200 hover:bg-black/10"
|
|
1158
1158
|
>
|
|
1159
1159
|
<span className="grid size-16 place-items-center rounded-full bg-white/15 text-white ring-1 ring-white/30 backdrop-blur-md transition-transform duration-200 hover:scale-105">
|
|
@@ -1166,7 +1166,7 @@ export function VideoPlayer({
|
|
|
1166
1166
|
<button
|
|
1167
1167
|
type="button"
|
|
1168
1168
|
onClick={toggleEmbedPlay}
|
|
1169
|
-
aria-label="
|
|
1169
|
+
aria-label="Play the video"
|
|
1170
1170
|
className="absolute inset-0 z-10 grid place-items-center bg-black/25 transition-colors duration-200 hover:bg-black/10"
|
|
1171
1171
|
>
|
|
1172
1172
|
<span className="grid size-16 place-items-center rounded-full bg-white/15 text-white ring-1 ring-white/30 backdrop-blur-md transition-transform duration-200 hover:scale-105">
|
|
@@ -1297,7 +1297,7 @@ export function VideoPlayer({
|
|
|
1297
1297
|
onClick={() =>
|
|
1298
1298
|
setOpenMenu((menu) => (menu === 'subtitles' ? null : 'subtitles'))
|
|
1299
1299
|
}
|
|
1300
|
-
aria-label="
|
|
1300
|
+
aria-label="Subtitles"
|
|
1301
1301
|
aria-expanded={openMenu === 'subtitles'}
|
|
1302
1302
|
className={cn(iconButton, activeSubtitle >= 0 && 'text-[var(--vp-accent)]')}
|
|
1303
1303
|
>
|
|
@@ -1318,7 +1318,7 @@ export function VideoPlayer({
|
|
|
1318
1318
|
: 'text-white/80 hover:bg-white/10',
|
|
1319
1319
|
)}
|
|
1320
1320
|
>
|
|
1321
|
-
|
|
1321
|
+
Off
|
|
1322
1322
|
{activeSubtitle === -1 && <Check className="size-3.5" />}
|
|
1323
1323
|
</button>
|
|
1324
1324
|
{subtitleTracks.map((track, trackIndex) => (
|
|
@@ -1350,7 +1350,7 @@ export function VideoPlayer({
|
|
|
1350
1350
|
<button
|
|
1351
1351
|
type="button"
|
|
1352
1352
|
onClick={() => setOpenMenu((menu) => (menu === 'speed' ? null : 'speed'))}
|
|
1353
|
-
aria-label="
|
|
1353
|
+
aria-label="Playback speed"
|
|
1354
1354
|
aria-expanded={openMenu === 'speed'}
|
|
1355
1355
|
className="h-9 min-w-11 rounded-full px-2 text-[11px] font-semibold tabular-nums text-white/85 transition hover:bg-white/15 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--vp-accent)]"
|
|
1356
1356
|
>
|
|
@@ -1386,7 +1386,7 @@ export function VideoPlayer({
|
|
|
1386
1386
|
onClick={() =>
|
|
1387
1387
|
setOpenMenu((menu) => (menu === 'quality' ? null : 'quality'))
|
|
1388
1388
|
}
|
|
1389
|
-
aria-label="
|
|
1389
|
+
aria-label="Choose the quality"
|
|
1390
1390
|
aria-expanded={openMenu === 'quality'}
|
|
1391
1391
|
className="flex h-9 items-center gap-1 rounded-full px-2 text-[11px] font-semibold text-white/85 transition hover:bg-white/15 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--vp-accent)]"
|
|
1392
1392
|
>
|
|
@@ -1420,7 +1420,7 @@ export function VideoPlayer({
|
|
|
1420
1420
|
<button
|
|
1421
1421
|
type="button"
|
|
1422
1422
|
onClick={() => void togglePip()}
|
|
1423
|
-
aria-label="
|
|
1423
|
+
aria-label="Picture in picture"
|
|
1424
1424
|
className={cn(iconButton, 'hidden sm:inline-flex')}
|
|
1425
1425
|
>
|
|
1426
1426
|
<PictureInPicture2 className="size-4" />
|
|
@@ -1430,7 +1430,7 @@ export function VideoPlayer({
|
|
|
1430
1430
|
<button
|
|
1431
1431
|
type="button"
|
|
1432
1432
|
onClick={toggleFullscreen}
|
|
1433
|
-
aria-label={fullscreen ? '
|
|
1433
|
+
aria-label={fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
1434
1434
|
className={iconButton}
|
|
1435
1435
|
>
|
|
1436
1436
|
{fullscreen ? (
|
|
@@ -1461,7 +1461,7 @@ export function VideoPlayer({
|
|
|
1461
1461
|
className="inline-flex items-center gap-2 rounded-full bg-white px-3.5 py-1.5 text-xs font-medium text-black transition hover:bg-white/85"
|
|
1462
1462
|
>
|
|
1463
1463
|
<RotateCcw className="size-3.5" />
|
|
1464
|
-
|
|
1464
|
+
Retry
|
|
1465
1465
|
</button>
|
|
1466
1466
|
</div>
|
|
1467
1467
|
)}
|
package/src/lib/date.ts
CHANGED
|
@@ -4,29 +4,29 @@ export interface DateRange {
|
|
|
4
4
|
}
|
|
5
5
|
|
|
6
6
|
export const MONTHS = [
|
|
7
|
-
'
|
|
8
|
-
'
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
12
|
-
'
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
7
|
+
'January',
|
|
8
|
+
'February',
|
|
9
|
+
'March',
|
|
10
|
+
'April',
|
|
11
|
+
'May',
|
|
12
|
+
'June',
|
|
13
|
+
'July',
|
|
14
|
+
'August',
|
|
15
|
+
'September',
|
|
16
|
+
'October',
|
|
17
|
+
'November',
|
|
18
|
+
'December',
|
|
19
19
|
]
|
|
20
20
|
|
|
21
|
-
export const WEEKDAYS_SHORT = ['
|
|
21
|
+
export const WEEKDAYS_SHORT = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
|
|
22
22
|
export const WEEKDAYS_LONG = [
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
23
|
+
'Monday',
|
|
24
|
+
'Tuesday',
|
|
25
|
+
'Wednesday',
|
|
26
|
+
'Thursday',
|
|
27
|
+
'Friday',
|
|
28
|
+
'Saturday',
|
|
29
|
+
'Sunday',
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
export function startOfDay(date: Date): Date {
|
|
@@ -60,7 +60,7 @@ export function daysInMonth(year: number, month: number): number {
|
|
|
60
60
|
return new Date(year, month + 1, 0).getDate()
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
63
|
+
/** 6-week calendar grid, Monday first. */
|
|
64
64
|
export function getMonthMatrix(year: number, month: number): Date[] {
|
|
65
65
|
const first = new Date(year, month, 1)
|
|
66
66
|
const offset = (first.getDay() + 6) % 7
|
|
@@ -74,13 +74,13 @@ export function formatMonthYear(date: Date): string {
|
|
|
74
74
|
|
|
75
75
|
export function formatLongDate(date: Date): string {
|
|
76
76
|
const weekday = WEEKDAYS_LONG[(date.getDay() + 6) % 7]
|
|
77
|
-
return `${weekday} ${date.
|
|
77
|
+
return `${weekday}, ${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
export function formatShortDate(date: Date): string {
|
|
81
81
|
return [
|
|
82
|
-
String(date.getDate()).padStart(2, '0'),
|
|
83
82
|
String(date.getMonth() + 1).padStart(2, '0'),
|
|
83
|
+
String(date.getDate()).padStart(2, '0'),
|
|
84
84
|
date.getFullYear(),
|
|
85
85
|
].join('/')
|
|
86
86
|
}
|
|
@@ -95,10 +95,10 @@ export function parseDateInput(text: string): Date | null {
|
|
|
95
95
|
return Number.isNaN(date.getTime()) ? null : date
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
const year = Number(
|
|
101
|
-
const date = new Date(year, Number(
|
|
98
|
+
const monthFirst = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{2,4})$/.exec(value)
|
|
99
|
+
if (monthFirst) {
|
|
100
|
+
const year = Number(monthFirst[3]) < 100 ? 2000 + Number(monthFirst[3]) : Number(monthFirst[3])
|
|
101
|
+
const date = new Date(year, Number(monthFirst[1]) - 1, Number(monthFirst[2]))
|
|
102
102
|
return Number.isNaN(date.getTime()) ? null : date
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -120,7 +120,7 @@ export function clampDate(date: Date, min?: Date, max?: Date): Date {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
export function formatBytes(bytes: number): string {
|
|
123
|
-
if (bytes < 1024) return `${bytes}
|
|
124
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}
|
|
125
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}
|
|
123
|
+
if (bytes < 1024) return `${bytes} B`
|
|
124
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
|
125
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
126
126
|
}
|