trix-ui 0.2.1 → 0.2.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.
Files changed (69) hide show
  1. package/README.md +306 -215
  2. package/dist/commands/add/__tests__/add.test.js +16 -4
  3. package/dist/commands/add/__tests__/add.test.js.map +1 -1
  4. package/dist/commands/add/analysis.js +6 -1
  5. package/dist/commands/add/analysis.js.map +1 -1
  6. package/dist/commands/add/command.js +6 -0
  7. package/dist/commands/add/command.js.map +1 -1
  8. package/dist/commands/add/types.d.ts +1 -0
  9. package/dist/commands/add/ui.js +4 -0
  10. package/dist/commands/add/ui.js.map +1 -1
  11. package/dist/commands/add-composite.d.ts +2 -0
  12. package/dist/commands/add-composite.js +202 -0
  13. package/dist/commands/add-composite.js.map +1 -0
  14. package/dist/commands/add-section.js +6 -0
  15. package/dist/commands/add-section.js.map +1 -1
  16. package/dist/commands/add-wrapper.js +6 -0
  17. package/dist/commands/add-wrapper.js.map +1 -1
  18. package/dist/commands/build.js +104 -104
  19. package/dist/commands/doctor.js +7 -2
  20. package/dist/commands/doctor.js.map +1 -1
  21. package/dist/commands/init/config.js +1 -0
  22. package/dist/commands/init/config.js.map +1 -1
  23. package/dist/commands/list.js +12 -4
  24. package/dist/commands/list.js.map +1 -1
  25. package/dist/commands/remove.js +24 -10
  26. package/dist/commands/remove.js.map +1 -1
  27. package/dist/commands/shared/add-collection.d.ts +2 -1
  28. package/dist/commands/shared/add-collection.js +8 -2
  29. package/dist/commands/shared/add-collection.js.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/lib/config.d.ts +1 -0
  33. package/dist/lib/config.js +1 -0
  34. package/dist/lib/config.js.map +1 -1
  35. package/dist/lib/lockfile.d.ts +6 -5
  36. package/dist/lib/lockfile.js +3 -0
  37. package/dist/lib/lockfile.js.map +1 -1
  38. package/dist/lib/paths.d.ts +1 -0
  39. package/dist/lib/paths.js +1 -0
  40. package/dist/lib/paths.js.map +1 -1
  41. package/dist/lib/registry.d.ts +2 -0
  42. package/dist/lib/registry.js +11 -0
  43. package/dist/lib/registry.js.map +1 -1
  44. package/package.json +4 -3
  45. package/registry/index.json +204 -242
  46. package/templates/components/ui/avatar.tsx +109 -0
  47. package/templates/components/ui/badge.tsx +182 -182
  48. package/templates/components/ui/button.tsx +48 -44
  49. package/templates/components/ui/label.tsx +24 -0
  50. package/templates/composites/feature-collection-card.tsx +113 -0
  51. package/templates/composites/music-player-card.tsx +571 -0
  52. package/templates/composites/user-profile-card.tsx +145 -0
  53. package/templates/sections/signature-hero.tsx +92 -0
  54. package/templates/wrappers/Interative-wrapper.tsx +555 -0
  55. package/templates/wrappers/progress-wrapper.tsx +248 -0
  56. package/templates/wrappers/reveal-wrap.tsx +136 -0
  57. package/LICENSE.md +0 -21
  58. package/templates/components/ui/checkbox.tsx +0 -33
  59. package/templates/components/ui/dialog.tsx +0 -92
  60. package/templates/components/ui/dropdown.tsx +0 -75
  61. package/templates/components/ui/select.tsx +0 -24
  62. package/templates/components/ui/switch.tsx +0 -27
  63. package/templates/components/ui/toast.tsx +0 -100
  64. package/templates/sections/cta.tsx +0 -22
  65. package/templates/sections/feature-grid.tsx +0 -62
  66. package/templates/sections/hero.tsx +0 -63
  67. package/templates/wrappers/border-wrapper.tsx +0 -34
  68. package/templates/wrappers/glow-wrapper.tsx +0 -31
  69. package/templates/wrappers/lift-wrapper.tsx +0 -27
@@ -0,0 +1,571 @@
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils";
3
+ import {
4
+ Shuffle,
5
+ SkipBack,
6
+ Play,
7
+ Pause,
8
+ SkipForward,
9
+ Repeat,
10
+ } from "lucide-react";
11
+
12
+ export type MusicTrack = {
13
+ src: string;
14
+ title: string;
15
+ subtitle?: string;
16
+ imageSrc?: string;
17
+ };
18
+
19
+ export type MusicPlayerCardProps = {
20
+ imageSrc: string;
21
+ title: string;
22
+ subtitle?: string;
23
+
24
+ /** Audio source for single-track playback */
25
+ audioSrc?: string;
26
+
27
+ /** Optional playlist */
28
+ tracks?: MusicTrack[];
29
+
30
+ /** Optional File object for local playback */
31
+ audioFile?: File;
32
+
33
+ /** Initial index when using tracks (default 0) */
34
+ initialTrackIndex?: number;
35
+
36
+ /** Auto play on load (default false) */
37
+ autoPlay?: boolean;
38
+
39
+ /** Show file input to pick a local audio file */
40
+ showFileInput?: boolean;
41
+
42
+ /** 0 to 100 */
43
+ progress?: number;
44
+ currentTime?: string;
45
+ totalTime?: string;
46
+
47
+ onShuffle?: () => void;
48
+ onPrev?: () => void;
49
+ onPlay?: () => void;
50
+ onNext?: () => void;
51
+ onRepeat?: () => void;
52
+
53
+ onShuffleChange?: (enabled: boolean) => void;
54
+ onRepeatChange?: (enabled: boolean) => void;
55
+ onPlayStateChange?: (playing: boolean) => void;
56
+ onTrackChange?: (track: MusicTrack, index: number) => void;
57
+ onFileSelect?: (file: File) => void;
58
+
59
+ /** Toggle states */
60
+ isPlaying?: boolean;
61
+ isShuffleOn?: boolean;
62
+ isRepeatOn?: boolean;
63
+
64
+ /** Styling overrides */
65
+ className?: string;
66
+ imageWrapClassName?: string;
67
+ titleClassName?: string;
68
+ subtitleClassName?: string;
69
+
70
+ progressTrackClassName?: string;
71
+ progressFillClassName?: string;
72
+
73
+ controlsClassName?: string;
74
+ sideButtonClassName?: string;
75
+ midButtonClassName?: string;
76
+
77
+ accentClassName?: string; // default rose
78
+ } & React.HTMLAttributes<HTMLDivElement>;
79
+
80
+ const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
81
+
82
+ function formatTime(value: number): string {
83
+ if (!Number.isFinite(value) || value <= 0) return "0:00";
84
+ const minutes = Math.floor(value / 60);
85
+ const seconds = Math.floor(value % 60);
86
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
87
+ }
88
+
89
+ function useObjectUrl(file?: File): string | null {
90
+ const [url, setUrl] = React.useState<string | null>(null);
91
+
92
+ React.useEffect(() => {
93
+ if (!file) {
94
+ setUrl(null);
95
+ return undefined;
96
+ }
97
+
98
+ const nextUrl = URL.createObjectURL(file);
99
+ setUrl(nextUrl);
100
+
101
+ return () => {
102
+ URL.revokeObjectURL(nextUrl);
103
+ };
104
+ }, [file]);
105
+
106
+ return url;
107
+ }
108
+
109
+ const MusicPlayerCardBase = React.forwardRef<HTMLDivElement, MusicPlayerCardProps>(
110
+ (
111
+ {
112
+ imageSrc,
113
+ title,
114
+ subtitle = "Unknown Artist",
115
+
116
+ audioSrc,
117
+ tracks,
118
+ audioFile,
119
+ initialTrackIndex = 0,
120
+ autoPlay = false,
121
+ showFileInput = false,
122
+
123
+ progress,
124
+ currentTime,
125
+ totalTime,
126
+
127
+ onShuffle,
128
+ onPrev,
129
+ onPlay,
130
+ onNext,
131
+ onRepeat,
132
+
133
+ onShuffleChange,
134
+ onRepeatChange,
135
+ onPlayStateChange,
136
+ onTrackChange,
137
+ onFileSelect,
138
+
139
+ isPlaying,
140
+ isShuffleOn,
141
+ isRepeatOn,
142
+
143
+ className,
144
+ imageWrapClassName,
145
+ titleClassName,
146
+ subtitleClassName,
147
+
148
+ progressTrackClassName,
149
+ progressFillClassName,
150
+
151
+ controlsClassName,
152
+ sideButtonClassName,
153
+ midButtonClassName,
154
+
155
+ accentClassName = "bg-rose-500",
156
+
157
+ ...props
158
+ },
159
+ ref
160
+ ) => {
161
+ const [internalPlaying, setInternalPlaying] = React.useState(false);
162
+ const [internalShuffle, setInternalShuffle] = React.useState(false);
163
+ const [internalRepeat, setInternalRepeat] = React.useState(false);
164
+ const [trackIndex, setTrackIndex] = React.useState(
165
+ clamp(initialTrackIndex, 0, Math.max((tracks?.length ?? 1) - 1, 0))
166
+ );
167
+ const [uploadedFile, setUploadedFile] = React.useState<File | null>(null);
168
+ const [currentTimeSec, setCurrentTimeSec] = React.useState(0);
169
+ const [durationSec, setDurationSec] = React.useState(0);
170
+ const audioRef = React.useRef<HTMLAudioElement | null>(null);
171
+ const shuffleHistoryRef = React.useRef<number[]>([]);
172
+
173
+ const isShuffle = isShuffleOn ?? internalShuffle;
174
+ const isRepeat = isRepeatOn ?? internalRepeat;
175
+ const playing = isPlaying ?? internalPlaying;
176
+
177
+ const playlist = tracks?.length ? tracks : [];
178
+ const fallbackTrack: MusicTrack = {
179
+ src: audioSrc ?? "",
180
+ title,
181
+ subtitle,
182
+ imageSrc,
183
+ };
184
+
185
+ const activeTrack = playlist.length
186
+ ? playlist[clamp(trackIndex, 0, playlist.length - 1)]
187
+ : fallbackTrack;
188
+
189
+ const propFileUrl = useObjectUrl(audioFile);
190
+ const uploadedFileUrl = useObjectUrl(uploadedFile ?? undefined);
191
+ const resolvedSrc = propFileUrl ?? uploadedFileUrl ?? activeTrack.src;
192
+ const isPlayable = Boolean(resolvedSrc);
193
+
194
+ const derivedProgress =
195
+ durationSec > 0 ? clamp((currentTimeSec / durationSec) * 100, 0, 100) : 0;
196
+ const pct = clamp(progress ?? derivedProgress, 0, 100);
197
+ const resolvedCurrentTime = currentTime ?? formatTime(currentTimeSec);
198
+ const resolvedTotalTime = totalTime ?? formatTime(durationSec);
199
+
200
+ const requestPlay = React.useCallback(
201
+ async (nextPlaying: boolean): Promise<void> => {
202
+ const audio = audioRef.current;
203
+ if (!audio || !resolvedSrc) {
204
+ return;
205
+ }
206
+
207
+ try {
208
+ if (nextPlaying) {
209
+ await audio.play();
210
+ } else {
211
+ audio.pause();
212
+ }
213
+ } catch {
214
+ if (isPlaying === undefined) {
215
+ setInternalPlaying(false);
216
+ }
217
+ onPlayStateChange?.(false);
218
+ }
219
+ },
220
+ [isPlaying, onPlayStateChange, resolvedSrc]
221
+ );
222
+
223
+ React.useEffect(() => {
224
+ const audio = audioRef.current;
225
+ if (!audio) return;
226
+
227
+ if (!resolvedSrc) {
228
+ audio.removeAttribute("src");
229
+ audio.load();
230
+ setCurrentTimeSec(0);
231
+ setDurationSec(0);
232
+ return;
233
+ }
234
+
235
+ audio.src = resolvedSrc;
236
+ audio.load();
237
+ setCurrentTimeSec(0);
238
+ setDurationSec(0);
239
+
240
+ if (autoPlay || playing) {
241
+ requestPlay(true);
242
+ }
243
+ }, [autoPlay, playing, requestPlay, resolvedSrc]);
244
+
245
+ React.useEffect(() => {
246
+ if (!resolvedSrc) return;
247
+ requestPlay(playing);
248
+ }, [playing, requestPlay, resolvedSrc]);
249
+
250
+ React.useEffect(() => {
251
+ if (!playlist.length) return;
252
+ const clampedIndex = clamp(trackIndex, 0, playlist.length - 1);
253
+ if (clampedIndex !== trackIndex) {
254
+ setTrackIndex(clampedIndex);
255
+ }
256
+ }, [playlist.length, trackIndex]);
257
+
258
+ React.useEffect(() => {
259
+ if (!playlist.length) return;
260
+ const nextTrack = playlist[clamp(trackIndex, 0, playlist.length - 1)];
261
+ onTrackChange?.(nextTrack, clamp(trackIndex, 0, playlist.length - 1));
262
+ }, [onTrackChange, playlist, trackIndex]);
263
+
264
+ const handleShuffleToggle = (): void => {
265
+ const next = !isShuffle;
266
+ if (isShuffleOn === undefined) {
267
+ setInternalShuffle(next);
268
+ }
269
+ if (!next) {
270
+ shuffleHistoryRef.current = [];
271
+ }
272
+ onShuffle?.();
273
+ onShuffleChange?.(next);
274
+ };
275
+
276
+ const handleRepeatToggle = (): void => {
277
+ const next = !isRepeat;
278
+ if (isRepeatOn === undefined) {
279
+ setInternalRepeat(next);
280
+ }
281
+ onRepeat?.();
282
+ onRepeatChange?.(next);
283
+ };
284
+
285
+ const handlePlayToggle = (): void => {
286
+ const next = !playing;
287
+ if (isPlaying === undefined) {
288
+ setInternalPlaying(next);
289
+ }
290
+ onPlay?.();
291
+ onPlayStateChange?.(next);
292
+ };
293
+
294
+ const moveToTrack = (index: number): void => {
295
+ if (!playlist.length) return;
296
+ const nextIndex = clamp(index, 0, playlist.length - 1);
297
+ setTrackIndex(nextIndex);
298
+ shuffleHistoryRef.current.push(trackIndex);
299
+ };
300
+
301
+ const pickRandomTrack = (): number => {
302
+ if (playlist.length <= 1) return trackIndex;
303
+ let nextIndex = trackIndex;
304
+ while (nextIndex === trackIndex) {
305
+ nextIndex = Math.floor(Math.random() * playlist.length);
306
+ }
307
+ return nextIndex;
308
+ };
309
+
310
+ const handleNext = (): void => {
311
+ if (!playlist.length) {
312
+ const audio = audioRef.current;
313
+ if (audio) {
314
+ audio.currentTime = 0;
315
+ if (playing) {
316
+ requestPlay(true);
317
+ }
318
+ }
319
+ onNext?.();
320
+ return;
321
+ }
322
+
323
+ if (isShuffle) {
324
+ moveToTrack(pickRandomTrack());
325
+ } else if (trackIndex < playlist.length - 1) {
326
+ moveToTrack(trackIndex + 1);
327
+ } else if (isRepeat) {
328
+ moveToTrack(0);
329
+ } else if (isPlaying === undefined) {
330
+ setInternalPlaying(false);
331
+ }
332
+
333
+ onNext?.();
334
+ };
335
+
336
+ const handlePrev = (): void => {
337
+ const audio = audioRef.current;
338
+ if (audio && audio.currentTime > 3) {
339
+ audio.currentTime = 0;
340
+ onPrev?.();
341
+ return;
342
+ }
343
+
344
+ if (!playlist.length) {
345
+ if (audio) {
346
+ audio.currentTime = 0;
347
+ if (playing) {
348
+ requestPlay(true);
349
+ }
350
+ }
351
+ onPrev?.();
352
+ return;
353
+ }
354
+
355
+ if (isShuffle && shuffleHistoryRef.current.length > 0) {
356
+ const previous = shuffleHistoryRef.current.pop();
357
+ if (previous !== undefined) {
358
+ setTrackIndex(previous);
359
+ }
360
+ } else if (trackIndex > 0) {
361
+ setTrackIndex(trackIndex - 1);
362
+ } else {
363
+ setTrackIndex(0);
364
+ }
365
+
366
+ onPrev?.();
367
+ };
368
+
369
+ const handleEnded = (): void => {
370
+ if (playlist.length === 0 && !resolvedSrc) {
371
+ return;
372
+ }
373
+
374
+ if (isRepeat) {
375
+ const audio = audioRef.current;
376
+ if (audio) {
377
+ audio.currentTime = 0;
378
+ requestPlay(true);
379
+ }
380
+ return;
381
+ }
382
+
383
+ if (playlist.length > 0) {
384
+ if (isShuffle) {
385
+ moveToTrack(pickRandomTrack());
386
+ return;
387
+ }
388
+
389
+ if (trackIndex < playlist.length - 1) {
390
+ moveToTrack(trackIndex + 1);
391
+ return;
392
+ }
393
+ }
394
+
395
+ if (isPlaying === undefined) {
396
+ setInternalPlaying(false);
397
+ }
398
+ onPlayStateChange?.(false);
399
+ };
400
+
401
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
402
+ const file = event.target.files?.[0];
403
+ if (!file) return;
404
+ setUploadedFile(file);
405
+ onFileSelect?.(file);
406
+ if (isPlaying === undefined) {
407
+ setInternalPlaying(true);
408
+ }
409
+ onPlayStateChange?.(true);
410
+ };
411
+
412
+ return (
413
+ <div
414
+ ref={ref}
415
+ className={cn(
416
+ "bg-white p-5 rounded-3xl border border-stone-100 flex flex-col h-full",
417
+ "shadow-lg transition-all duration-500 ease-out",
418
+ "hover:-translate-y-1 hover:shadow-2xl",
419
+ className
420
+ )}
421
+ {...props}
422
+ >
423
+ <audio
424
+ ref={audioRef}
425
+ preload="metadata"
426
+ onTimeUpdate={(event) => {
427
+ const audio = event.currentTarget;
428
+ setCurrentTimeSec(audio.currentTime);
429
+ }}
430
+ onLoadedMetadata={(event) => {
431
+ const audio = event.currentTarget;
432
+ setDurationSec(audio.duration || 0);
433
+ }}
434
+ onEnded={handleEnded}
435
+ />
436
+ {/* Album Art */}
437
+ <div
438
+ className={cn(
439
+ "relative aspect-square rounded-2xl overflow-hidden mb-5 shadow-inner",
440
+ "ring-1 ring-black/5",
441
+ imageWrapClassName
442
+ )}
443
+ >
444
+ <img
445
+ src={activeTrack.imageSrc ?? imageSrc}
446
+ alt="Album Art"
447
+ loading="lazy"
448
+ decoding="async"
449
+ className="w-full h-full object-cover"
450
+ />
451
+ <div className="absolute inset-0 bg-black/10" />
452
+
453
+ {/* Premium Glow */}
454
+ <div className="pointer-events-none absolute inset-0 opacity-0 hover:opacity-100 transition-opacity duration-700 bg-[radial-gradient(circle_at_30%_20%,rgba(255,255,255,0.18),transparent_55%)]" />
455
+ </div>
456
+
457
+ {showFileInput ? (
458
+ <label className="mb-4 inline-flex cursor-pointer items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-stone-400">
459
+ <span>Load audio</span>
460
+ <input
461
+ type="file"
462
+ accept="audio/*"
463
+ className="sr-only"
464
+ onChange={handleFileChange}
465
+ />
466
+ </label>
467
+ ) : null}
468
+
469
+ {/* Text */}
470
+ <div className="mb-4">
471
+ <h3 className={cn("text-lg font-bold text-stone-800 truncate", titleClassName)}>
472
+ {activeTrack.title ?? title}
473
+ </h3>
474
+ <p
475
+ className={cn(
476
+ "text-stone-400 text-xs font-medium uppercase tracking-wide truncate",
477
+ subtitleClassName
478
+ )}
479
+ >
480
+ {activeTrack.subtitle ?? subtitle}
481
+ </p>
482
+ </div>
483
+
484
+ {/* Progress Bar */}
485
+ <div
486
+ className={cn(
487
+ "w-full bg-stone-100 h-1.5 rounded-full mb-2 overflow-hidden",
488
+ progressTrackClassName
489
+ )}
490
+ >
491
+ <div
492
+ className={cn(
493
+ "h-full rounded-full transition-all duration-500 ease-out",
494
+ accentClassName,
495
+ progressFillClassName
496
+ )}
497
+ style={{ width: `${pct}%` }}
498
+ />
499
+ </div>
500
+
501
+ <div className="flex justify-between text-[10px] text-stone-400 font-mono mb-4">
502
+ <span>{resolvedCurrentTime}</span>
503
+ <span>{resolvedTotalTime}</span>
504
+ </div>
505
+
506
+ {/* Controls */}
507
+ <div className={cn("flex justify-between items-center mt-auto px-2", controlsClassName)}>
508
+ <button
509
+ type="button"
510
+ onClick={handleShuffleToggle}
511
+ disabled={!playlist.length}
512
+ className={cn(
513
+ "text-stone-400 transition-colors hover:text-stone-800",
514
+ isShuffle && "text-stone-800",
515
+ sideButtonClassName
516
+ )}
517
+ >
518
+ <Shuffle className="h-5 w-5" />
519
+ </button>
520
+
521
+ <button
522
+ type="button"
523
+ onClick={handlePrev}
524
+ disabled={!isPlayable}
525
+ className={cn("text-stone-800 transition-colors hover:text-rose-500", midButtonClassName)}
526
+ >
527
+ <SkipBack className="h-8 w-8" />
528
+ </button>
529
+
530
+ <button
531
+ type="button"
532
+ onClick={handlePlayToggle}
533
+ disabled={!isPlayable}
534
+ className={cn(
535
+ "w-12 h-12 text-white rounded-full flex items-center justify-center shadow-lg",
536
+ "transition-all duration-300 ease-out hover:scale-105 active:scale-95",
537
+ accentClassName
538
+ )}
539
+ >
540
+ {playing ? <Pause className="h-6 w-6" /> : <Play className="h-6 w-6" />}
541
+ </button>
542
+
543
+ <button
544
+ type="button"
545
+ onClick={handleNext}
546
+ disabled={!isPlayable}
547
+ className={cn("text-stone-800 transition-colors hover:text-rose-500", midButtonClassName)}
548
+ >
549
+ <SkipForward className="h-8 w-8" />
550
+ </button>
551
+
552
+ <button
553
+ type="button"
554
+ onClick={handleRepeatToggle}
555
+ disabled={!isPlayable}
556
+ className={cn(
557
+ "text-stone-400 transition-colors hover:text-stone-800",
558
+ isRepeat && "text-stone-800",
559
+ sideButtonClassName
560
+ )}
561
+ >
562
+ <Repeat className="h-5 w-5" />
563
+ </button>
564
+ </div>
565
+ </div>
566
+ );
567
+ }
568
+ );
569
+
570
+ MusicPlayerCardBase.displayName = "MusicPlayerCard";
571
+ export const MusicPlayerCard = React.memo(MusicPlayerCardBase);
@@ -0,0 +1,145 @@
1
+ import * as React from "react";
2
+ import { cn } from "@/lib/utils";
3
+ import { Card } from "@/components/ui/card";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import { ArrowRight } from "lucide-react";
7
+
8
+ type UserProfileCardTag = {
9
+ label: string;
10
+ };
11
+
12
+ export type UserProfileCardProps = {
13
+ name: string;
14
+ role: string;
15
+ avatarSrc: string;
16
+ avatarAlt?: string;
17
+
18
+ /** Example: [{ label: "Figma" }, { label: "Design Ops" }] */
19
+ tags?: UserProfileCardTag[];
20
+
21
+ /** You can pass "1.2k" directly OR number like 1200 */
22
+ followers?: string | number;
23
+
24
+ /** Online green dot */
25
+ online?: boolean;
26
+
27
+ /** Right action (arrow button) */
28
+ onActionClick?: () => void;
29
+ actionAriaLabel?: string;
30
+
31
+ className?: string;
32
+ };
33
+
34
+ function formatFollowers(value: string | number | undefined) {
35
+ if (value === undefined || value === null) return "";
36
+ if (typeof value === "string") return value;
37
+
38
+ // number formatting like 1200 -> 1.2k
39
+ const n = value;
40
+ if (n < 1000) return `${n}`;
41
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
42
+ return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
43
+ }
44
+
45
+ export function UserProfileCard({
46
+ name,
47
+ role,
48
+ avatarSrc,
49
+ avatarAlt = name,
50
+ tags = [],
51
+ followers = "1.2k",
52
+ online = true,
53
+ onActionClick,
54
+ actionAriaLabel = "Open profile",
55
+ className,
56
+ }: UserProfileCardProps) {
57
+ return (
58
+ <Card
59
+ className={cn(
60
+ "w-[280px] rounded-[28px] bg-white shadow-[0_18px_60px_-35px_rgba(15,23,42,0.35)]",
61
+ "border border-neutral-100",
62
+ className
63
+ )}
64
+ >
65
+ <div className="px-6 pt-6 pb-5">
66
+ {/* Avatar */}
67
+ <div className="flex justify-center">
68
+ <div className="relative">
69
+ {/* Gradient ring */}
70
+ <div className="grid place-items-center rounded-full p-[3px] bg-gradient-to-br from-violet-500 to-indigo-500">
71
+ <div className="rounded-full bg-white p-[3px]">
72
+ <img
73
+ src={avatarSrc}
74
+ alt={avatarAlt}
75
+ className="h-[74px] w-[74px] rounded-full object-cover"
76
+ draggable={false}
77
+ />
78
+ </div>
79
+ </div>
80
+
81
+ {/* Online dot */}
82
+ {online && (
83
+ <span className="absolute bottom-[7px] right-[7px] h-3.5 w-3.5 rounded-full bg-emerald-500 ring-4 ring-white" />
84
+ )}
85
+ </div>
86
+ </div>
87
+
88
+ {/* Name + Role */}
89
+ <div className="mt-4 text-center">
90
+ <div className="text-[18px] font-semibold leading-tight text-slate-900">
91
+ {name}
92
+ </div>
93
+ <div className="mt-1 text-[11px] font-semibold tracking-[0.18em] text-indigo-500">
94
+ {role.toUpperCase()}
95
+ </div>
96
+ </div>
97
+
98
+ {/* Tags */}
99
+ {tags.length > 0 && (
100
+ <div className="mt-4 flex justify-center gap-2">
101
+ {tags.map((t, idx) => (
102
+ <Badge
103
+ key={`${t.label}-${idx}`}
104
+ variant="secondary"
105
+ className={cn(
106
+ "rounded-full px-3 py-1 text-[10px] font-semibold tracking-widest",
107
+ "bg-neutral-100 text-neutral-700 shadow-none border border-neutral-200"
108
+ )}
109
+ >
110
+ {t.label.toUpperCase()}
111
+ </Badge>
112
+ ))}
113
+ </div>
114
+ )}
115
+
116
+ {/* Bottom section */}
117
+ <div className="mt-6 flex items-end justify-between">
118
+ <div>
119
+ <div className="text-[10px] font-semibold tracking-[0.18em] text-neutral-300">
120
+ FOLLOWERS
121
+ </div>
122
+ <div className="mt-1 text-[16px] font-semibold text-slate-900">
123
+ {formatFollowers(followers)}
124
+ </div>
125
+ </div>
126
+
127
+ <Button
128
+ type="button"
129
+ variant="ghost"
130
+ size="icon"
131
+ onClick={onActionClick}
132
+ aria-label={actionAriaLabel}
133
+ className={cn(
134
+ "h-10 w-10 rounded-full",
135
+ "text-violet-600 hover:text-violet-700",
136
+ "hover:bg-violet-50 active:bg-violet-100"
137
+ )}
138
+ >
139
+ <ArrowRight className="h-5 w-5" />
140
+ </Button>
141
+ </div>
142
+ </div>
143
+ </Card>
144
+ );
145
+ }