@djangocfg/ui-tools 2.1.201 → 2.1.203

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.
Files changed (41) hide show
  1. package/README.md +22 -4
  2. package/dist/{chunk-7HP3GZFT.mjs → chunk-O55KZXKD.mjs} +123 -11
  3. package/dist/chunk-O55KZXKD.mjs.map +1 -0
  4. package/dist/{chunk-KHHTBDWW.cjs → chunk-XMZWMGKE.cjs} +122 -9
  5. package/dist/chunk-XMZWMGKE.cjs.map +1 -0
  6. package/dist/components-OZEGOPNP.cjs +46 -0
  7. package/dist/{components-7L3KMPQ5.cjs.map → components-OZEGOPNP.cjs.map} +1 -1
  8. package/dist/components-R4CC6JGG.mjs +5 -0
  9. package/dist/{components-IPSHDNXP.mjs.map → components-R4CC6JGG.mjs.map} +1 -1
  10. package/dist/index.cjs +48 -36
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +49 -2
  13. package/dist/index.d.ts +49 -2
  14. package/dist/index.mjs +15 -4
  15. package/dist/index.mjs.map +1 -1
  16. package/package.json +6 -6
  17. package/src/index.ts +2 -0
  18. package/src/tools/AudioPlayer/AudioPlayer.story.tsx +26 -5
  19. package/src/tools/AudioPlayer/README.md +38 -2
  20. package/src/tools/AudioPlayer/components/HybridCompactPlayer.tsx +153 -0
  21. package/src/tools/AudioPlayer/components/HybridSimplePlayer.tsx +9 -5
  22. package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +3 -1
  23. package/src/tools/AudioPlayer/components/index.ts +1 -0
  24. package/src/tools/AudioPlayer/hooks/index.ts +4 -0
  25. package/src/tools/AudioPlayer/hooks/useAudioBus.ts +76 -0
  26. package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +18 -2
  27. package/src/tools/AudioPlayer/index.ts +6 -0
  28. package/src/tools/AudioPlayer/lazy.tsx +21 -1
  29. package/src/tools/Uploader/README.md +31 -6
  30. package/src/tools/Uploader/Uploader.story.tsx +44 -0
  31. package/src/tools/Uploader/components/UploadDropzone.tsx +13 -1
  32. package/src/tools/Uploader/components/UploadPreviewItem.tsx +10 -5
  33. package/src/tools/Uploader/hooks/useClipboardPaste.ts +15 -4
  34. package/src/tools/Uploader/index.ts +1 -0
  35. package/src/tools/Uploader/types/index.ts +7 -0
  36. package/src/tools/Uploader/utils/formatters.ts +28 -0
  37. package/src/tools/Uploader/utils/index.ts +1 -1
  38. package/dist/chunk-7HP3GZFT.mjs.map +0 -1
  39. package/dist/chunk-KHHTBDWW.cjs.map +0 -1
  40. package/dist/components-7L3KMPQ5.cjs +0 -42
  41. package/dist/components-IPSHDNXP.mjs +0 -5
@@ -13,11 +13,14 @@ Audio player with native HTML5 streaming and audio-reactive visualizations.
13
13
  ## Quick Start
14
14
 
15
15
  ```tsx
16
- import { HybridSimplePlayer } from '@djangocfg/ui-nextjs';
16
+ import { HybridSimplePlayer, HybridCompactPlayer } from '@djangocfg/ui-nextjs';
17
17
 
18
18
  // Simple usage
19
19
  <HybridSimplePlayer src="https://example.com/audio.mp3" />
20
20
 
21
+ // Compact single-row player (for lists, sidebars)
22
+ <HybridCompactPlayer src="https://example.com/audio.mp3" title="Rain & Thunder" />
23
+
21
24
  // With metadata and reactive cover
22
25
  <HybridSimplePlayer
23
26
  src={audioUrl}
@@ -156,6 +159,38 @@ Album art wrapper with audio-reactive effects:
156
159
  </AudioReactiveCover>
157
160
  ```
158
161
 
162
+ ### HybridCompactPlayer
163
+
164
+ Single-row player — play/pause + waveform + timer. No cover art, no volume slider.
165
+
166
+ ```tsx
167
+ <HybridCompactPlayer
168
+ src={audioUrl}
169
+ title="Track name" // used as tooltip
170
+ waveformMode="frequency" // 'frequency' | 'static'
171
+ showTimer={true}
172
+ autoPlay={false}
173
+ />
174
+ ```
175
+
176
+ | Prop | Type | Default | Description |
177
+ |------|------|---------|-------------|
178
+ | `src` | `string` | required | Audio URL |
179
+ | `title` | `string` | - | Tooltip / aria-label |
180
+ | `waveformMode` | `'frequency' \| 'static'` | `'frequency'` | Visualization mode |
181
+ | `showTimer` | `boolean` | `true` | Show current/total time |
182
+ | `autoPlay` | `boolean` | `false` | Auto-play on load |
183
+ | `loop` | `boolean` | `false` | Loop playback |
184
+ | `initialVolume` | `number` | `1` | Initial volume 0-1 |
185
+
186
+ For lazy-loading (preferred in Next.js apps):
187
+
188
+ ```tsx
189
+ import { LazyHybridCompactPlayer } from '@djangocfg/ui-tools';
190
+
191
+ <LazyHybridCompactPlayer src={url} title="Track" autoPlay />
192
+ ```
193
+
159
194
  ### HybridWaveform
160
195
 
161
196
  Real-time frequency visualization:
@@ -193,7 +228,8 @@ AudioPlayer/
193
228
  │ └── HybridAudioProvider.tsx # Audio context provider
194
229
  ├── components/
195
230
  │ ├── HybridAudioPlayer.tsx # Main player component
196
- │ ├── HybridSimplePlayer.tsx # All-in-one wrapper
231
+ │ ├── HybridSimplePlayer.tsx # All-in-one wrapper (with cover, volume, effects)
232
+ │ ├── HybridCompactPlayer.tsx # Compact single-row player
197
233
  │ ├── HybridWaveform.tsx # Frequency visualization
198
234
  │ └── ReactiveCover/ # Reactive effects
199
235
  ├── effects/ # Effect calculations
@@ -0,0 +1,153 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * HybridCompactPlayer - Single-row audio player
5
+ *
6
+ * Designed for tight spaces: play/pause + waveform + timer.
7
+ * No cover art, no volume slider, no skip buttons.
8
+ *
9
+ * @example
10
+ * <HybridCompactPlayer src="https://example.com/audio.mp3" title="Rain & Thunder" />
11
+ *
12
+ * @example
13
+ * // Lazy-loaded (preferred in app)
14
+ * <LazyHybridCompactPlayer src={url} autoPlay />
15
+ */
16
+
17
+ import { type ReactNode } from 'react';
18
+ import { Play, Pause, Loader2 } from 'lucide-react';
19
+ import { cn } from '@djangocfg/ui-core';
20
+ import { Button } from '../../_shared';
21
+
22
+ import { HybridAudioProvider } from '../context/HybridAudioProvider';
23
+ import { HybridWaveform } from './HybridWaveform';
24
+ import { useHybridAudioContext } from '../context/HybridAudioProvider';
25
+ import { formatTime } from '../utils';
26
+
27
+ // =============================================================================
28
+ // TYPES
29
+ // =============================================================================
30
+
31
+ export interface HybridCompactPlayerProps {
32
+ /** Audio source URL */
33
+ src: string;
34
+ /** Track title (shown as tooltip / aria-label) */
35
+ title?: string;
36
+ /** Auto-play on load */
37
+ autoPlay?: boolean;
38
+ /** Loop playback */
39
+ loop?: boolean;
40
+ /** Initial volume (0-1) */
41
+ initialVolume?: number;
42
+ /** Waveform visualization mode */
43
+ waveformMode?: 'frequency' | 'static';
44
+ /** Show timer */
45
+ showTimer?: boolean;
46
+ /** Additional class name */
47
+ className?: string;
48
+ /** Callbacks */
49
+ onPlay?: () => void;
50
+ onPause?: () => void;
51
+ onEnded?: () => void;
52
+ onError?: (error: Error) => void;
53
+ }
54
+
55
+ // =============================================================================
56
+ // COMPONENT
57
+ // =============================================================================
58
+
59
+ export function HybridCompactPlayer({
60
+ src,
61
+ title,
62
+ autoPlay = false,
63
+ loop = false,
64
+ initialVolume = 1,
65
+ waveformMode = 'frequency',
66
+ showTimer = true,
67
+ className,
68
+ onPlay,
69
+ onPause,
70
+ onEnded,
71
+ onError,
72
+ }: HybridCompactPlayerProps): ReactNode {
73
+ return (
74
+ <HybridAudioProvider
75
+ src={src}
76
+ autoPlay={autoPlay}
77
+ loop={loop}
78
+ initialVolume={initialVolume}
79
+ onPlay={onPlay}
80
+ onPause={onPause}
81
+ onEnded={onEnded}
82
+ onError={onError}
83
+ >
84
+ <HybridCompactPlayerInner
85
+ title={title}
86
+ waveformMode={waveformMode}
87
+ showTimer={showTimer}
88
+ className={className}
89
+ />
90
+ </HybridAudioProvider>
91
+ );
92
+ }
93
+
94
+ // =============================================================================
95
+ // INNER (needs context)
96
+ // =============================================================================
97
+
98
+ interface InnerProps {
99
+ title?: string;
100
+ waveformMode: 'frequency' | 'static';
101
+ showTimer: boolean;
102
+ className?: string;
103
+ }
104
+
105
+ function HybridCompactPlayerInner({ title, waveformMode, showTimer, className }: InnerProps) {
106
+ const { state, controls } = useHybridAudioContext();
107
+ const isLoading = !state.isReady;
108
+
109
+ return (
110
+ <div className={cn('flex items-center gap-2 w-full', className)}>
111
+ {/* Play / Pause */}
112
+ <Button
113
+ variant="outline"
114
+ size="icon"
115
+ className="h-8 w-8 flex-shrink-0"
116
+ onClick={controls.togglePlay}
117
+ disabled={!state.isReady && !isLoading}
118
+ title={title ?? (state.isPlaying ? 'Pause' : 'Play')}
119
+ aria-label={state.isPlaying ? 'Pause' : 'Play'}
120
+ >
121
+ {isLoading ? (
122
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
123
+ ) : state.isPlaying ? (
124
+ <Pause className="h-3.5 w-3.5" />
125
+ ) : (
126
+ <Play className="h-3.5 w-3.5 ml-0.5" />
127
+ )}
128
+ </Button>
129
+
130
+ {/* Waveform */}
131
+ <div className="flex-1 min-w-0">
132
+ <HybridWaveform
133
+ mode={waveformMode}
134
+ height={32}
135
+ barWidth={2}
136
+ barGap={1}
137
+ className={cn(isLoading && 'opacity-40')}
138
+ />
139
+ </div>
140
+
141
+ {/* Timer */}
142
+ {showTimer && (
143
+ <span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
144
+ {formatTime(state.currentTime)}
145
+ <span className="text-muted-foreground/50"> / </span>
146
+ {formatTime(state.duration)}
147
+ </span>
148
+ )}
149
+ </div>
150
+ );
151
+ }
152
+
153
+ export default HybridCompactPlayer;
@@ -118,6 +118,10 @@ const COVER_SIZES = {
118
118
  lg: 'w-48 h-48',
119
119
  };
120
120
 
121
+ // In horizontal layout cover stays fixed-size; in vertical it fills the full width
122
+ const COVER_SIZE_FOR_LAYOUT = (coverSize: 'sm' | 'md' | 'lg', isHorizontal: boolean) =>
123
+ isHorizontal ? coverSize : 'full';
124
+
121
125
  // =============================================================================
122
126
  // COMPONENT
123
127
  // =============================================================================
@@ -209,16 +213,16 @@ function HybridSimplePlayerContent({
209
213
  >
210
214
  {/* Cover Art */}
211
215
  {(coverArt || reactiveCover) && (
212
- <div className="flex flex-col items-center gap-2 shrink-0">
216
+ <div className={cn('flex flex-col items-center gap-2', isHorizontal ? 'shrink-0' : 'w-full')}>
213
217
  {showReactiveCover ? (
214
218
  <AudioReactiveCover
215
- size={coverSize}
219
+ size={COVER_SIZE_FOR_LAYOUT(coverSize, isHorizontal)}
216
220
  variant={effectiveVariant as 'glow' | 'orbs' | 'spotlight' | 'mesh'}
217
221
  intensity={effectiveIntensity}
218
222
  colorScheme={effectiveColorScheme}
219
223
  onClick={nextVariant}
220
224
  >
221
- <div className={cn('rounded-lg overflow-hidden', COVER_SIZES[coverSize])}>
225
+ <div className={cn('rounded-lg overflow-hidden', isHorizontal ? COVER_SIZES[coverSize] : 'w-full h-full')}>
222
226
  {renderCoverContent()}
223
227
  </div>
224
228
  </AudioReactiveCover>
@@ -226,7 +230,7 @@ function HybridSimplePlayerContent({
226
230
  <div
227
231
  className={cn(
228
232
  'rounded-lg overflow-hidden shadow-lg cursor-pointer',
229
- COVER_SIZES[coverSize]
233
+ isHorizontal ? COVER_SIZES[coverSize] : 'w-full sm:max-w-xs aspect-square mx-auto'
230
234
  )}
231
235
  onClick={nextVariant}
232
236
  role="button"
@@ -248,7 +252,7 @@ function HybridSimplePlayerContent({
248
252
 
249
253
  {/* Track Info + Player */}
250
254
  <div
251
- className={cn('flex flex-col gap-3', isHorizontal ? 'flex-1 min-w-0' : 'w-full max-w-md')}
255
+ className={cn('flex flex-col gap-3', isHorizontal ? 'flex-1 min-w-0' : 'w-full')}
252
256
  >
253
257
  {/* Track Info */}
254
258
  {(title || artist) && (
@@ -32,7 +32,8 @@ import { GlowEffect, OrbsEffect, SpotlightEffect, MeshEffect, type GlowEffectDat
32
32
 
33
33
  export interface AudioReactiveCoverProps {
34
34
  children: ReactNode;
35
- size?: 'sm' | 'md' | 'lg';
35
+ /** 'sm' | 'md' | 'lg' = fixed sizes; 'full' = stretch to container width (aspect-square) */
36
+ size?: 'sm' | 'md' | 'lg' | 'full';
36
37
  variant?: EffectVariant;
37
38
  intensity?: EffectIntensity;
38
39
  colorScheme?: EffectColorScheme;
@@ -48,6 +49,7 @@ const SIZES = {
48
49
  sm: { container: 'w-32 h-32', orbBase: 40 },
49
50
  md: { container: 'w-40 h-40', orbBase: 50 },
50
51
  lg: { container: 'w-48 h-48', orbBase: 60 },
52
+ full: { container: 'w-full sm:max-w-xs aspect-square mx-auto', orbBase: 60 },
51
53
  };
52
54
 
53
55
  // =============================================================================
@@ -8,6 +8,7 @@
8
8
  // Player components
9
9
  export { HybridAudioPlayer, type HybridAudioPlayerProps } from './HybridAudioPlayer';
10
10
  export { HybridSimplePlayer, type HybridSimplePlayerProps } from './HybridSimplePlayer';
11
+ export { HybridCompactPlayer, type HybridCompactPlayerProps } from './HybridCompactPlayer';
11
12
  export { HybridWaveform, type HybridWaveformProps } from './HybridWaveform';
12
13
 
13
14
  // ReactiveCover
@@ -2,6 +2,10 @@
2
2
  * AudioPlayer hooks - Public API
3
3
  */
4
4
 
5
+ // Audio bus — global exclusivity (one player at a time)
6
+ export { useAudioBus, useAudioBusStore } from './useAudioBus';
7
+ export type { UseAudioBusReturn } from './useAudioBus';
8
+
5
9
  // Core hybrid audio hook
6
10
  export { useHybridAudio } from './useHybridAudio';
7
11
  export type {
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useAudioBus — Global audio exclusivity via Zustand store.
5
+ *
6
+ * Ensures only one audio player plays at a time across the entire page.
7
+ * No provider needed — singleton store, works anywhere in the tree.
8
+ *
9
+ * Also integrates with @djangocfg/ui-tools AudioPlayer automatically
10
+ * (wired into useHybridAudio).
11
+ *
12
+ * @example
13
+ * const { announce } = useAudioBus('my-player-id', () => pause());
14
+ *
15
+ * // When playback starts:
16
+ * announce(); // all other players will pause
17
+ */
18
+
19
+ import { useEffect, useRef, useCallback } from 'react';
20
+ import { create } from 'zustand';
21
+
22
+ // =============================================================================
23
+ // Store
24
+ // =============================================================================
25
+
26
+ interface AudioBusStore {
27
+ /** ID of the currently active (playing) player, null if none */
28
+ activeId: string | null;
29
+ setActiveId: (id: string | null) => void;
30
+ }
31
+
32
+ export const useAudioBusStore = create<AudioBusStore>((set) => ({
33
+ activeId: null,
34
+ setActiveId: (id) => set({ activeId: id }),
35
+ }));
36
+
37
+ // =============================================================================
38
+ // Hook
39
+ // =============================================================================
40
+
41
+ export interface UseAudioBusReturn {
42
+ /** Broadcast: this player is now playing — all others should stop */
43
+ announce: () => void;
44
+ /** Release: this player stopped (clears activeId if it was ours) */
45
+ release: () => void;
46
+ }
47
+
48
+ /**
49
+ * @param playerId Stable unique ID for this player instance
50
+ * @param onStop Called when another player announces — should pause this player
51
+ */
52
+ export function useAudioBus(playerId: string, onStop: () => void): UseAudioBusReturn {
53
+ const onStopRef = useRef(onStop);
54
+ onStopRef.current = onStop;
55
+
56
+ const setActiveId = useAudioBusStore((s) => s.setActiveId);
57
+
58
+ // Subscribe to store changes — if another player becomes active, stop this one
59
+ useEffect(() => {
60
+ return useAudioBusStore.subscribe((state) => {
61
+ if (state.activeId !== null && state.activeId !== playerId) {
62
+ onStopRef.current();
63
+ }
64
+ });
65
+ }, [playerId]);
66
+
67
+ const announce = useCallback(() => {
68
+ setActiveId(playerId);
69
+ }, [playerId, setActiveId]);
70
+
71
+ const release = useCallback(() => {
72
+ useAudioBusStore.setState((s) => s.activeId === playerId ? { activeId: null } : s);
73
+ }, [playerId]);
74
+
75
+ return { announce, release };
76
+ }
@@ -11,7 +11,8 @@
11
11
  * source -> analyser (parallel path for visualization only, no output)
12
12
  */
13
13
 
14
- import { useCallback, useEffect, useRef, useState } from 'react';
14
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
15
+ import { useAudioBus } from './useAudioBus';
15
16
 
16
17
  // =============================================================================
17
18
  // TYPES
@@ -23,6 +24,8 @@ export interface UseHybridAudioOptions {
23
24
  initialVolume?: number;
24
25
  loop?: boolean;
25
26
  crossOrigin?: 'anonymous' | 'use-credentials';
27
+ /** Set to true to opt out of global audio bus (player won't stop others and won't be stopped) */
28
+ excludeFromBus?: boolean;
26
29
  onPlay?: () => void;
27
30
  onPause?: () => void;
28
31
  onEnded?: () => void;
@@ -81,6 +84,7 @@ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioRe
81
84
  initialVolume = 1,
82
85
  loop = false,
83
86
  crossOrigin = 'anonymous',
87
+ excludeFromBus = false,
84
88
  onPlay,
85
89
  onPause,
86
90
  onEnded,
@@ -89,6 +93,8 @@ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioRe
89
93
  onReady,
90
94
  } = options;
91
95
 
96
+ const playerId = useId();
97
+
92
98
  // Refs
93
99
  const audioRef = useRef<HTMLAudioElement | null>(null);
94
100
  const audioContextRef = useRef<AudioContext | null>(null);
@@ -162,6 +168,14 @@ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioRe
162
168
  }
163
169
  }, []);
164
170
 
171
+ // Audio bus — stop other players when this one plays
172
+ const { announce: busAnnounce, release: busRelease } = useAudioBus(
173
+ playerId,
174
+ useCallback(() => {
175
+ if (!excludeFromBus) audioRef.current?.pause();
176
+ }, [excludeFromBus])
177
+ );
178
+
165
179
  // Resume AudioContext on user interaction
166
180
  const resumeAudioContext = useCallback(async () => {
167
181
  const ctx = audioContextRef.current;
@@ -176,6 +190,7 @@ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioRe
176
190
  if (!audio) return;
177
191
 
178
192
  try {
193
+ if (!excludeFromBus) busAnnounce();
179
194
  initWebAudio();
180
195
  await resumeAudioContext();
181
196
  await audio.play();
@@ -183,7 +198,7 @@ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioRe
183
198
  console.error('[useHybridAudio] Play failed:', error);
184
199
  onError?.(error as Error);
185
200
  }
186
- }, [initWebAudio, resumeAudioContext, onError]);
201
+ }, [excludeFromBus, busAnnounce, initWebAudio, resumeAudioContext, onError]);
187
202
 
188
203
  const pause = useCallback(() => {
189
204
  audioRef.current?.pause();
@@ -286,6 +301,7 @@ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioRe
286
301
  return () => {
287
302
  audio.pause();
288
303
  audio.src = '';
304
+ busRelease();
289
305
  if (audioContextRef.current) {
290
306
  audioContextRef.current.close().catch(() => {});
291
307
  }
@@ -24,6 +24,7 @@
24
24
  export {
25
25
  HybridAudioPlayer,
26
26
  HybridSimplePlayer,
27
+ HybridCompactPlayer,
27
28
  HybridWaveform,
28
29
  AudioReactiveCover,
29
30
  // Effect components (for custom implementations)
@@ -36,6 +37,7 @@ export {
36
37
  export type {
37
38
  HybridAudioPlayerProps,
38
39
  HybridSimplePlayerProps,
40
+ HybridCompactPlayerProps,
39
41
  HybridWaveformProps,
40
42
  AudioReactiveCoverProps,
41
43
  GlowEffectData,
@@ -64,6 +66,9 @@ export type {
64
66
  // =============================================================================
65
67
 
66
68
  export {
69
+ // Audio bus
70
+ useAudioBus,
71
+ useAudioBusStore,
67
72
  // Core hooks
68
73
  useHybridAudio,
69
74
  useHybridAudioAnalysis,
@@ -77,6 +82,7 @@ export {
77
82
  } from './hooks';
78
83
 
79
84
  export type {
85
+ UseAudioBusReturn,
80
86
  UseHybridAudioOptions,
81
87
  HybridAudioState,
82
88
  HybridAudioControls,
@@ -14,13 +14,14 @@ import { createLazyComponent, LoadingFallback } from '../../components';
14
14
  import type {
15
15
  HybridAudioPlayerProps,
16
16
  HybridSimplePlayerProps,
17
+ HybridCompactPlayerProps,
17
18
  } from './components';
18
19
 
19
20
  // ============================================================================
20
21
  // Re-export types
21
22
  // ============================================================================
22
23
 
23
- export type { HybridAudioPlayerProps, HybridSimplePlayerProps };
24
+ export type { HybridAudioPlayerProps, HybridSimplePlayerProps, HybridCompactPlayerProps };
24
25
 
25
26
  // ============================================================================
26
27
  // Audio Loading Fallback
@@ -83,3 +84,22 @@ export const LazyHybridSimplePlayer = createLazyComponent<HybridSimplePlayerProp
83
84
  fallback: <AudioLoadingFallback />,
84
85
  }
85
86
  );
87
+
88
+ /**
89
+ * LazyHybridCompactPlayer - Lazy-loaded compact single-row audio player
90
+ *
91
+ * Use in tight spaces: play/pause + waveform + timer in one line.
92
+ */
93
+ export const LazyHybridCompactPlayer = createLazyComponent<HybridCompactPlayerProps>(
94
+ () => import('./components').then((mod) => ({ default: mod.HybridCompactPlayer })),
95
+ {
96
+ displayName: 'LazyHybridCompactPlayer',
97
+ fallback: (
98
+ <div className="flex items-center gap-2 h-8 px-1 animate-pulse">
99
+ <div className="h-8 w-8 rounded-md bg-muted flex-shrink-0" />
100
+ <div className="flex-1 h-4 rounded bg-muted" />
101
+ <div className="h-3 w-12 rounded bg-muted flex-shrink-0" />
102
+ </div>
103
+ ),
104
+ }
105
+ );
@@ -49,7 +49,32 @@ All-in-one component with dropzone and preview list.
49
49
  />
50
50
  ```
51
51
 
52
- ### Custom Composition
52
+ ### Standalone — custom upload handler (no UploadProvider)
53
+
54
+ If you handle uploads yourself (custom API hooks, multipart POST, etc.), pass `uploadFn` instead of wrapping with `UploadProvider`. Supports drag/drop, click, and Ctrl+V paste.
55
+
56
+ ```tsx
57
+ import { UploadDropzone } from '@djangocfg/ui-tools/upload';
58
+
59
+ function MyUploader() {
60
+ const { uploadAsset } = useAssets(); // your own API hook
61
+
62
+ return (
63
+ <UploadDropzone
64
+ accept={['image', 'video']}
65
+ maxSizeMB={50}
66
+ pasteEnabled
67
+ uploadFn={async (files) => {
68
+ for (const file of files) {
69
+ await uploadAsset({ file, name: file.name });
70
+ }
71
+ }}
72
+ />
73
+ );
74
+ }
75
+ ```
76
+
77
+ ### Custom Composition (with rpldy)
53
78
 
54
79
  ```tsx
55
80
  import {
@@ -116,11 +141,11 @@ Custom overlay:
116
141
 
117
142
  ### Components
118
143
 
119
- | Component | Description |
120
- |-----------|-------------|
121
- | `Uploader` | All-in-one (Provider + Dropzone + Preview) |
122
- | `UploadProvider` | Context provider wrapping @rpldy/uploady |
123
- | `UploadDropzone` | Drag-drop zone with file input |
144
+ | Component | Needs UploadProvider | Description |
145
+ |-----------|---------------------|-------------|
146
+ | `Uploader` | Yes | All-in-one (Provider + Dropzone + Preview) |
147
+ | `UploadDropzone` | **Optional** | Drag-drop zone — use `uploadFn` for standalone mode |
148
+ | `UploadProvider` | | Context provider wrapping @rpldy/uploady |
124
149
  | `UploadPreviewList` | List of upload items with progress |
125
150
  | `UploadPreviewItem` | Single item (thumbnail, status, actions) |
126
151
  | `UploadAddButton` | Button to add files |
@@ -369,3 +369,47 @@ export const PageDropCustomOverlay = () => (
369
369
  </UploadProvider>
370
370
  </div>
371
371
  );
372
+
373
+ // Standalone — uploadFn instead of UploadProvider (custom API hooks, no rpldy)
374
+ export const StandaloneWithUploadFn = () => {
375
+ const [files, setFiles] = useState<string[]>([]);
376
+
377
+ return (
378
+ <div className="max-w-2xl space-y-4">
379
+ <Card>
380
+ <CardContent className="pt-4">
381
+ <p className="text-sm text-muted-foreground flex items-center gap-2">
382
+ <ClipboardPaste className="h-4 w-4" />
383
+ No <code className="text-xs bg-muted px-1 rounded">UploadProvider</code> needed.
384
+ Pass <code className="text-xs bg-muted px-1 rounded">uploadFn</code> to handle files yourself.
385
+ Drag, click, or paste (Ctrl+V).
386
+ </p>
387
+ </CardContent>
388
+ </Card>
389
+ <UploadDropzone
390
+ accept={['image', 'document']}
391
+ maxSizeMB={10}
392
+ pasteEnabled
393
+ uploadFn={(selected) => {
394
+ setFiles((prev) => [...prev, ...selected.map((f) => f.name)]);
395
+ logger.info('Custom uploadFn received: ' + selected.map((f) => f.name).join(', '));
396
+ }}
397
+ onPasteNoMatch={() => logger.info('Paste: no uploadable content found')}
398
+ />
399
+ {files.length > 0 && (
400
+ <Card>
401
+ <CardHeader>
402
+ <CardTitle className="text-sm">Received by uploadFn</CardTitle>
403
+ </CardHeader>
404
+ <CardContent>
405
+ <ul className="text-sm space-y-1">
406
+ {files.map((name, i) => (
407
+ <li key={i} className="text-muted-foreground">{name}</li>
408
+ ))}
409
+ </ul>
410
+ </CardContent>
411
+ </Card>
412
+ )}
413
+ </div>
414
+ );
415
+ };
@@ -9,6 +9,17 @@ import { buildAcceptString, logger } from '../utils';
9
9
  import { useClipboardPaste } from '../hooks/useClipboardPaste';
10
10
  import type { UploadDropzoneProps } from '../types';
11
11
 
12
+ function useOptionalUploady(uploadFn?: (files: File[]) => void) {
13
+ try {
14
+ // eslint-disable-next-line react-hooks/rules-of-hooks
15
+ const { upload } = useUploady();
16
+ return uploadFn ?? upload;
17
+ } catch {
18
+ // Not inside UploadProvider — use uploadFn if provided, otherwise noop
19
+ return uploadFn ?? (() => {});
20
+ }
21
+ }
22
+
12
23
  export function UploadDropzone({
13
24
  accept = ['image', 'audio', 'video', 'document'],
14
25
  multiple = true,
@@ -18,11 +29,12 @@ export function UploadDropzone({
18
29
  className,
19
30
  children,
20
31
  onFilesSelected,
32
+ uploadFn,
21
33
  pasteEnabled = true,
22
34
  onPasteNoMatch,
23
35
  }: UploadDropzoneProps) {
24
36
  const t = useT();
25
- const { upload } = useUploady();
37
+ const upload = useOptionalUploady(uploadFn);
26
38
  const inputRef = useRef<HTMLInputElement>(null);
27
39
  const [isDragging, setIsDragging] = useState(false);
28
40
  const dragCounter = useRef(0);