uifolio 0.5.1 → 0.5.3

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.
@@ -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
  }
@@ -1,9 +1,9 @@
1
1
  export interface UploaderOptions {
2
- /** Endpoint de signature. Défaut : /api/upload/sign */
2
+ /** Signing endpoint. Default: /api/upload/sign */
3
3
  signEndpoint?: string
4
- /** Nombre de tentatives supplémentaires en cas d'échec. Défaut : 2 */
4
+ /** Number of extra attempts on failure. Default: 2 */
5
5
  retries?: number
6
- /** Délai de base entre les tentatives (ms). Défaut : 800 */
6
+ /** Base delay between attempts (ms). Default: 800 */
7
7
  retryDelayMs?: number
8
8
  }
9
9
 
@@ -42,7 +42,7 @@ function putWithProgress(
42
42
  onProgress(1)
43
43
  resolvePromise()
44
44
  } else {
45
- reject(new UploadError(`Le serveur a répondu ${request.status}`, request.status))
45
+ reject(new UploadError(`The server returned ${request.status}`, request.status))
46
46
  }
47
47
  }
48
48
  request.onerror = () => reject(new UploadError('Connexion interrompue pendant l’envoi'))
@@ -52,12 +52,12 @@ function putWithProgress(
52
52
  }
53
53
 
54
54
  /**
55
- * Uploader « réel » branché sur /api/upload/sign :
56
- * 1. demande une URL signée au serveur,
57
- * 2. envoie le fichier en PUT avec progression (XHR),
58
- * 3. réessaie automatiquement en réutilisant la même URL signée (reprise).
55
+ * “Real” uploader wired to /api/upload/sign:
56
+ * 1. asks the server for a signed URL,
57
+ * 2. sends the file with a PUT and progress (XHR),
58
+ * 3. retries automatically, reusing the same signed URL (resume).
59
59
  *
60
- * Renvoie l'URL publique du fichier déposé.
60
+ * Returns the public URL of the uploaded file.
61
61
  */
62
62
  export function createUploader(options: UploaderOptions = {}) {
63
63
  const {
@@ -73,7 +73,7 @@ export function createUploader(options: UploaderOptions = {}) {
73
73
  body: JSON.stringify({ name: file.name, size: file.size, type: file.type }),
74
74
  })
75
75
  if (!signResponse.ok) {
76
- throw new UploadError(`Signature refusée (${signResponse.status})`, signResponse.status)
76
+ throw new UploadError(`Signature refused (${signResponse.status})`, signResponse.status)
77
77
  }
78
78
  const signed = (await signResponse.json()) as SignedUpload
79
79
 
@@ -85,7 +85,7 @@ export function createUploader(options: UploaderOptions = {}) {
85
85
  } catch (error) {
86
86
  lastError = error
87
87
  const status = error instanceof UploadError ? error.status : undefined
88
- // 4xx = refus définitif (taille, clé, auth) : inutile de réessayer
88
+ // 4xx = final refusal (size, key, auth): no point retrying
89
89
  if (status !== undefined && status >= 400 && status < 500) break
90
90
  if (attempt < retries) {
91
91
  await new Promise((resolvePromise) =>
@@ -96,11 +96,11 @@ export function createUploader(options: UploaderOptions = {}) {
96
96
  }
97
97
  throw lastError instanceof Error
98
98
  ? lastError
99
- : new UploadError('Échec de l’envoi après plusieurs tentatives')
99
+ : new UploadError('Upload failed after several attempts')
100
100
  }
101
101
  }
102
102
 
103
- /** File d'attente par URL signée : permet la reprise d'un envoi interrompu. */
103
+ /** Queue per signed URL: allows resuming an interrupted upload. */
104
104
  export function resumeUpload(
105
105
  signed: SignedUpload,
106
106
  file: File,
@@ -1,14 +1,14 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
2
 
3
3
  /**
4
- * Retourne un état local piloté par une prop quand celle-ci est explicite.
4
+ * Returns local state driven by a prop when that prop is explicit.
5
5
  *
6
- * Quand le parent fournit explicitement la prop, l'état local se
7
- * synchronise dessus. Quand la prop est `undefined`, le composant garde
8
- * sa propre valeur locale.
6
+ * When the parent explicitly provides the prop, the local state stays
7
+ * in sync with it. When the prop is `undefined`, the component keeps
8
+ * its own local value.
9
9
  *
10
- * Cette version est compatible avec React Compiler : elle n'accède jamais
11
- * aux refs pendant le rendu.
10
+ * This version is compatible with React Compiler: it never reads
11
+ * refs during render.
12
12
  */
13
13
  export function useControllableValue<T>(
14
14
  prop: T | undefined,
@@ -21,14 +21,14 @@ export function useControllableValue<T>(
21
21
 
22
22
  const isControlled = prop !== undefined
23
23
 
24
- // Synchronise l'état local quand le contrôle parent change.
24
+ // Keeps the local state in sync when the parent control changes.
25
25
  useEffect(() => {
26
26
  controlledRef.current = isControlled
27
27
  previousPropRef.current = prop
28
28
  })
29
29
 
30
- // Premier rendu contrôlé : la prop doit remplacer l'état local.
31
- // Passages ultérieurs : synchronise seulement si la prop a changé.
30
+ // First controlled render: the prop must replace the local state.
31
+ // Later renders: only sync when the prop changed.
32
32
  useEffect(() => {
33
33
  if (!isControlled) return
34
34
  const next = prop as T
@@ -25,7 +25,7 @@ function matches(pattern: string, event: KeyboardEvent): boolean {
25
25
  return event.key.toLowerCase() === key
26
26
  }
27
27
 
28
- /** Enregistre des raccourcis globaux : `mod+k`, `shift+?`, `escape`, `arrowup`… */
28
+ /** Registers global shortcuts: `mod+k`, `shift+?`, `escape`, `arrowup`… */
29
29
  export function useHotkeys(map: HotkeyMap, options: { enabled?: boolean } = {}) {
30
30
  const handlers = useRef(map)
31
31
  const enabled = options.enabled ?? true
@@ -21,8 +21,8 @@ function applyTheme(theme: Theme) {
21
21
  }
22
22
 
23
23
  /**
24
- * Thème clair/sombre persistant. `system` suit la préférence système
25
- * tant que l'utilisateur n'a pas fait de choix explicite.
24
+ * Persistent light/dark theme. `system` follows the system preference
25
+ * until the user makes an explicit choice.
26
26
  */
27
27
  export function useTheme() {
28
28
  const [theme, setThemeState] = useState<Theme>(readTheme)