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.
@@ -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
- /** Une source, un fichier local, une liste de qualités, un flux HLS ou une URL YouTube/Vimeo. */
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 de la barre de contrôles dans le cadre. */
104
+ /** Position of the control bar inside the frame. */
105
105
  controlsPosition?: 'bottom' | 'top'
106
106
  accent?: string
107
- /** Police appliquée à la barre de contrôles et aux menus. */
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
- /** Les barres suivent l'arrondi global (`--radius`) du thème, comme le reste du catalogue. */
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
- /** Transforme les sources (URL, File, Blob, qualités) en sources lisibles et gère les object URLs. */
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
- /** Révoque les object URLs créées pour cette résolution. */
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
- /** Piste active par défaut : celle marquée `default`, sinon la première avec des cues. */
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
- // Ajustement au rendu quand la source change réellement (pas d'effet).
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
- // Libère les object URLs du résolveur remplacé (celui du montage n'est pas
237
- // libéré au démontage : StrictMode simulerait un unmount/remount).
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
- // Ajustement au rendu : la source change ⇒ on repart d'un état propre,
321
- // sans effet ni render supplémentaire.
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
- // Les pistes déjà chargées ne correspondent plus à la signature courante :
342
- // on les écarte pendant le rechargement plutôt que par un effet de reset.
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
- // reste affichée, sinon elle ne pourrait plus jamais être rappelée.
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
- /** Envoie une commande à l'iframe : la barre maison pilote le lecteur de la plateforme. */
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
- /** Abonne le lecteur aux événements de l'iframe (temps, durée, état, volume). */
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
- /** Monte l'iframe (façade → lecteur) : la lecture démarre avec le geste de l'utilisateur. */
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
- // L'iframe n'est pas encore montée : on la lance au lieu d'envoyer une commande dans le vide.
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 : plein écran natif indisponible sur un div */
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
- /* refusé par le navigateur */
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
- /* Synchronisation des props / de l'état */
617
+ /* Prop / state synchronization */
618
618
  /* ------------------------------------------------------------------ */
619
619
 
620
- // La réinitialisation d'état se fait au rendu (voir ci-dessus) ; on ne garde
621
- // ici que les refs, qui ne doivent pas être écrites pendant le rendu.
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
- // Branche l'iframe sur nos contrôles : on rejoue le handshake tant que le
678
- // lecteur de la plateforme n'est pas prêt (il ignore les messages avant).
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
- // Le temps, la durée et l'état de lecture viennent de l'iframe.
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
- // Optimisation : on coupe la lecture hors écran.
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
- // À la reprise de lecture, les contrôles repartent visibles : ajustement au
778
- // rendu plutôt qu'un setState synchrone dans l'effet.
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
- // L'effet ne fait qu'armer le minuteur (système externe) ; le reveal est
786
- // géré par les événements pointeur ou par l'ajustement au rendu ci-dessus.
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("Le flux HLS n'a pas pu être chargé.")
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("La librairie hls.js n'a pas pu être chargée.")
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
- /* Evenements média */
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
- : "La vidéo n'a pas pu être chargée."
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 ?? 'Qualité'
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 ?? 'Lecteur vidéo'}
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 ?? 'Lecteur vidéo'}
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 ? `Lire ${title}` : 'Lire la vidéo'}
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 ? 'Lancer la lecture' : 'Lire la vidéo'}
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="Lire la vidéo"
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="Sous-titres"
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
- Désactivés
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="Vitesse de lecture"
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="Choisir la qualité"
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="Image dans l'image"
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 ? 'Quitter le plein écran' : 'Plein écran'}
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
- Réessayer
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
- 'janvier',
8
- 'février',
9
- 'mars',
10
- 'avril',
11
- 'mai',
12
- 'juin',
13
- 'juillet',
14
- 'août',
15
- 'septembre',
16
- 'octobre',
17
- 'novembre',
18
- 'décembre',
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 = ['L', 'M', 'M', 'J', 'V', 'S', 'D']
21
+ export const WEEKDAYS_SHORT = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
22
22
  export const WEEKDAYS_LONG = [
23
- 'lundi',
24
- 'mardi',
25
- 'mercredi',
26
- 'jeudi',
27
- 'vendredi',
28
- 'samedi',
29
- 'dimanche',
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
- /** Calendrier de 6 semaines, lundi en premier. */
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.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}`
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 french = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{2,4})$/.exec(value)
99
- if (french) {
100
- const year = Number(french[3]) < 100 ? 2000 + Number(french[3]) : Number(french[3])
101
- const date = new Date(year, Number(french[2]) - 1, Number(french[1]))
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} o`
124
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
125
- return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
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
  }