@cntyclub/ui-react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-HDGMSYQS.js +26461 -0
- package/dist/chunk-HDGMSYQS.js.map +1 -0
- package/dist/chunk-PR4QN5HX.js +39 -0
- package/dist/chunk-PR4QN5HX.js.map +1 -0
- package/dist/form.d.ts +175 -0
- package/dist/form.js +5207 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +1462 -0
- package/dist/index.js +81862 -0
- package/dist/index.js.map +1 -0
- package/dist/input-CZvh825j.d.ts +24 -0
- package/dist/qr-code-styling-3Y6LZH6V.js +1123 -0
- package/dist/qr-code-styling-3Y6LZH6V.js.map +1 -0
- package/package.json +79 -0
- package/src/components/form/checkbox-group-field.tsx +101 -0
- package/src/components/form/date-field.tsx +79 -0
- package/src/components/form/date-range-field.tsx +106 -0
- package/src/components/form/form-context.ts +10 -0
- package/src/components/form/form.tsx +54 -0
- package/src/components/form/number-field.tsx +69 -0
- package/src/components/form/select-field.tsx +76 -0
- package/src/components/form/submit-button.tsx +28 -0
- package/src/components/form/text-field.tsx +107 -0
- package/src/components/layout/dashboard-header.tsx +54 -0
- package/src/components/layout/dashboard-panel.tsx +34 -0
- package/src/components/theme-provider.tsx +403 -0
- package/src/components/ui/accordion.tsx +69 -0
- package/src/components/ui/alert-dialog.tsx +169 -0
- package/src/components/ui/alert.tsx +80 -0
- package/src/components/ui/animated-theme-toggler.tsx +265 -0
- package/src/components/ui/app-store-buttons.tsx +182 -0
- package/src/components/ui/aspect-ratio.tsx +23 -0
- package/src/components/ui/autocomplete.tsx +296 -0
- package/src/components/ui/avatar-group.tsx +95 -0
- package/src/components/ui/avatar.tsx +285 -0
- package/src/components/ui/badge-group.tsx +160 -0
- package/src/components/ui/badge.tsx +172 -0
- package/src/components/ui/breadcrumb.tsx +112 -0
- package/src/components/ui/button.tsx +77 -0
- package/src/components/ui/calendar.tsx +137 -0
- package/src/components/ui/card.tsx +244 -0
- package/src/components/ui/carousel.tsx +258 -0
- package/src/components/ui/chart.tsx +379 -0
- package/src/components/ui/checkbox-group.tsx +16 -0
- package/src/components/ui/checkbox.tsx +82 -0
- package/src/components/ui/collapsible.tsx +45 -0
- package/src/components/ui/combobox.tsx +411 -0
- package/src/components/ui/command.tsx +264 -0
- package/src/components/ui/context-menu.tsx +271 -0
- package/src/components/ui/credit-card.tsx +214 -0
- package/src/components/ui/dialog.tsx +196 -0
- package/src/components/ui/drawer.tsx +135 -0
- package/src/components/ui/empty.tsx +127 -0
- package/src/components/ui/featured-icon.tsx +149 -0
- package/src/components/ui/field.tsx +88 -0
- package/src/components/ui/fieldset.tsx +29 -0
- package/src/components/ui/form.tsx +17 -0
- package/src/components/ui/frame.tsx +82 -0
- package/src/components/ui/generic-empty.tsx +142 -0
- package/src/components/ui/group.tsx +97 -0
- package/src/components/ui/horizontal-scroll-fader.tsx +228 -0
- package/src/components/ui/input-group.tsx +102 -0
- package/src/components/ui/input-otp.tsx +96 -0
- package/src/components/ui/input.tsx +66 -0
- package/src/components/ui/item.tsx +198 -0
- package/src/components/ui/kbd.tsx +30 -0
- package/src/components/ui/label.tsx +28 -0
- package/src/components/ui/menu.tsx +312 -0
- package/src/components/ui/menubar.tsx +93 -0
- package/src/components/ui/meter.tsx +67 -0
- package/src/components/ui/multi-select.tsx +308 -0
- package/src/components/ui/navigation-menu.tsx +143 -0
- package/src/components/ui/number-field.tsx +160 -0
- package/src/components/ui/pagination-controls.tsx +74 -0
- package/src/components/ui/pagination.tsx +149 -0
- package/src/components/ui/popover.tsx +119 -0
- package/src/components/ui/preview-card.tsx +55 -0
- package/src/components/ui/progress.tsx +289 -0
- package/src/components/ui/qr-code.tsx +150 -0
- package/src/components/ui/radio-group.tsx +103 -0
- package/src/components/ui/resizable.tsx +56 -0
- package/src/components/ui/scroll-area.tsx +90 -0
- package/src/components/ui/scroller.tsx +38 -0
- package/src/components/ui/section-header.tsx +118 -0
- package/src/components/ui/select.tsx +181 -0
- package/src/components/ui/separator.tsx +23 -0
- package/src/components/ui/sheet.tsx +224 -0
- package/src/components/ui/sidebar.tsx +744 -0
- package/src/components/ui/skeleton.tsx +16 -0
- package/src/components/ui/slider.tsx +108 -0
- package/src/components/ui/smooth-scroll.tsx +143 -0
- package/src/components/ui/social-button.tsx +247 -0
- package/src/components/ui/spinner-on-demand.tsx +32 -0
- package/src/components/ui/spinner.tsx +18 -0
- package/src/components/ui/stat.tsx +187 -0
- package/src/components/ui/stepper.tsx +167 -0
- package/src/components/ui/switch.tsx +56 -0
- package/src/components/ui/table.tsx +126 -0
- package/src/components/ui/tabs.tsx +90 -0
- package/src/components/ui/tag.tsx +229 -0
- package/src/components/ui/target-countdown.tsx +46 -0
- package/src/components/ui/text-editor.tsx +313 -0
- package/src/components/ui/textarea.tsx +51 -0
- package/src/components/ui/timeline.tsx +116 -0
- package/src/components/ui/toast.tsx +268 -0
- package/src/components/ui/toggle-group.tsx +101 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/components/ui/toolbar.tsx +89 -0
- package/src/components/ui/tooltip.tsx +102 -0
- package/src/components/ui/vertical-scroll-fader.tsx +250 -0
- package/src/components/ui/video-player.tsx +275 -0
- package/src/components/upload/avatar-upload-base.tsx +131 -0
- package/src/components/upload/image-upload-base.tsx +112 -0
- package/src/form.ts +17 -0
- package/src/index.ts +125 -0
- package/src/lib/hooks/use-callback-ref.ts +15 -0
- package/src/lib/hooks/use-first-render.ts +11 -0
- package/src/lib/hooks/use-hover.ts +53 -0
- package/src/lib/hooks/use-is-tab-active.ts +17 -0
- package/src/lib/hooks/use-media-query.ts +164 -0
- package/src/lib/utils/css.ts +6 -0
- package/src/styles.css +300 -0
- package/src/types/helpers.ts +24 -0
- package/src/types/react.d.ts +7 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
|
|
4
|
+
import type * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../lib/utils/css";
|
|
7
|
+
|
|
8
|
+
const TooltipCreateHandle = TooltipPrimitive.createHandle;
|
|
9
|
+
|
|
10
|
+
const TooltipProvider = TooltipPrimitive.Provider;
|
|
11
|
+
|
|
12
|
+
const Tooltip = TooltipPrimitive.Root;
|
|
13
|
+
|
|
14
|
+
function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
|
|
15
|
+
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function TooltipTitle({
|
|
19
|
+
className,
|
|
20
|
+
...props
|
|
21
|
+
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
|
22
|
+
return (
|
|
23
|
+
<p
|
|
24
|
+
data-slot="tooltip-title"
|
|
25
|
+
className={cn("font-medium leading-snug", className)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function TooltipDescription({
|
|
32
|
+
className,
|
|
33
|
+
...props
|
|
34
|
+
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
|
35
|
+
return (
|
|
36
|
+
<p
|
|
37
|
+
data-slot="tooltip-description"
|
|
38
|
+
className={cn("text-muted-foreground leading-snug", className)}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function TooltipPopup({
|
|
45
|
+
className,
|
|
46
|
+
align = "center",
|
|
47
|
+
sideOffset = 4,
|
|
48
|
+
side = "top",
|
|
49
|
+
arrow = false,
|
|
50
|
+
children,
|
|
51
|
+
...props
|
|
52
|
+
}: TooltipPrimitive.Popup.Props & {
|
|
53
|
+
align?: TooltipPrimitive.Positioner.Props["align"];
|
|
54
|
+
side?: TooltipPrimitive.Positioner.Props["side"];
|
|
55
|
+
sideOffset?: TooltipPrimitive.Positioner.Props["sideOffset"];
|
|
56
|
+
arrow?: boolean;
|
|
57
|
+
}) {
|
|
58
|
+
return (
|
|
59
|
+
<TooltipPrimitive.Portal>
|
|
60
|
+
<TooltipPrimitive.Positioner
|
|
61
|
+
align={align}
|
|
62
|
+
className="z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] data-instant:transition-none"
|
|
63
|
+
data-slot="tooltip-positioner"
|
|
64
|
+
side={side}
|
|
65
|
+
sideOffset={arrow ? (sideOffset as number) + 5 : sideOffset}
|
|
66
|
+
>
|
|
67
|
+
<TooltipPrimitive.Popup
|
|
68
|
+
className={cn(
|
|
69
|
+
"relative flex h-(--popup-height,auto) w-(--popup-width,auto) origin-(--transform-origin) text-balance rounded-md border bg-popover not-dark:bg-clip-padding text-popover-foreground text-xs shadow-md/5 transition-[width,height,scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] before:shadow-[0_1px_--theme(--color-black/6%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:duration-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
data-slot="tooltip-popup"
|
|
73
|
+
{...props}
|
|
74
|
+
>
|
|
75
|
+
<TooltipPrimitive.Viewport
|
|
76
|
+
className="relative size-full overflow-clip px-(--viewport-inline-padding) py-1 [--viewport-inline-padding:--spacing(2)] data-instant:transition-none **:data-current:data-ending-style:opacity-0 **:data-current:data-starting-style:opacity-0 **:data-previous:data-ending-style:opacity-0 **:data-previous:data-starting-style:opacity-0 **:data-current:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:truncate **:data-current:opacity-100 **:data-previous:opacity-100 **:data-current:transition-opacity **:data-previous:transition-opacity"
|
|
77
|
+
data-slot="tooltip-viewport"
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</TooltipPrimitive.Viewport>
|
|
81
|
+
{arrow && (
|
|
82
|
+
<TooltipPrimitive.Arrow
|
|
83
|
+
data-slot="tooltip-arrow"
|
|
84
|
+
className="absolute block size-2.5 bg-popover data-[side=top]:[clip-path:polygon(0%_0%,100%_0%,50%_100%)] data-[side=bottom]:[clip-path:polygon(50%_0%,0%_100%,100%_100%)] data-[side=left]:[clip-path:polygon(0%_0%,100%_50%,0%_100%)] data-[side=right]:[clip-path:polygon(100%_0%,0%_50%,100%_100%)]"
|
|
85
|
+
/>
|
|
86
|
+
)}
|
|
87
|
+
</TooltipPrimitive.Popup>
|
|
88
|
+
</TooltipPrimitive.Positioner>
|
|
89
|
+
</TooltipPrimitive.Portal>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export {
|
|
94
|
+
TooltipCreateHandle,
|
|
95
|
+
TooltipProvider,
|
|
96
|
+
Tooltip,
|
|
97
|
+
TooltipTrigger,
|
|
98
|
+
TooltipPopup,
|
|
99
|
+
TooltipPopup as TooltipContent,
|
|
100
|
+
TooltipTitle,
|
|
101
|
+
TooltipDescription,
|
|
102
|
+
};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Transition } from "@headlessui/react";
|
|
4
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
5
|
+
import type React from "react";
|
|
6
|
+
import {
|
|
7
|
+
createContext,
|
|
8
|
+
forwardRef,
|
|
9
|
+
useContext,
|
|
10
|
+
useEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from "react";
|
|
15
|
+
import useResizeObserver from "use-resize-observer";
|
|
16
|
+
import useCallbackRef from "../../lib/hooks/use-callback-ref";
|
|
17
|
+
import { useHover } from "../../lib/hooks/use-hover";
|
|
18
|
+
import { cn } from "../../lib/utils/css";
|
|
19
|
+
import type { AsChildProps } from "../../types/helpers";
|
|
20
|
+
|
|
21
|
+
interface VerticalScrollFaderContextValue {
|
|
22
|
+
isTopOverflowing: boolean;
|
|
23
|
+
isBottomOverflowing: boolean;
|
|
24
|
+
containerRef: React.RefObject<HTMLElement | null>;
|
|
25
|
+
onScroll: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const VerticalScrollFaderContext =
|
|
29
|
+
createContext<VerticalScrollFaderContextValue>({
|
|
30
|
+
isTopOverflowing: false,
|
|
31
|
+
isBottomOverflowing: false,
|
|
32
|
+
containerRef: { current: null },
|
|
33
|
+
onScroll: () => {},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const getScrollDirection = (element: HTMLElement) =>
|
|
37
|
+
getComputedStyle(element).flexDirection === "column-reverse" ? -1 : 1;
|
|
38
|
+
|
|
39
|
+
function VerticalScrollFaderInner(
|
|
40
|
+
{ asChild, ...props }: AsChildProps<"div">,
|
|
41
|
+
ref: React.ForwardedRef<HTMLDivElement>,
|
|
42
|
+
) {
|
|
43
|
+
const containerRef = useRef<HTMLElement>(null);
|
|
44
|
+
|
|
45
|
+
const [isTopOverflowing, setIsTopOverflowing] = useState(false);
|
|
46
|
+
const [isBottomOverflowing, setIsBottomOverflowing] = useState(false);
|
|
47
|
+
|
|
48
|
+
const onScroll = useCallbackRef(() => {
|
|
49
|
+
if (!containerRef.current) return;
|
|
50
|
+
|
|
51
|
+
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
|
52
|
+
const scrollDirection = getScrollDirection(containerRef.current!);
|
|
53
|
+
setIsTopOverflowing(
|
|
54
|
+
scrollDirection > 0
|
|
55
|
+
? Math.floor(scrollTop - 1) > 0
|
|
56
|
+
: Math.round(-scrollTop + clientHeight + 1) < scrollHeight,
|
|
57
|
+
);
|
|
58
|
+
setIsBottomOverflowing(
|
|
59
|
+
scrollDirection > 0
|
|
60
|
+
? Math.round(scrollTop + clientHeight + 1) < scrollHeight
|
|
61
|
+
: Math.floor(-scrollTop - 1) > 0,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const contextValue = useMemo(
|
|
66
|
+
() => ({ isTopOverflowing, isBottomOverflowing, containerRef, onScroll }),
|
|
67
|
+
[isTopOverflowing, isBottomOverflowing, onScroll],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const Comp = asChild ? Slot : "div";
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<VerticalScrollFaderContext.Provider value={contextValue}>
|
|
74
|
+
<Slot className="relative" onScroll={onScroll}>
|
|
75
|
+
<Comp ref={ref} {...props} />
|
|
76
|
+
</Slot>
|
|
77
|
+
</VerticalScrollFaderContext.Provider>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const VerticalScrollFader = forwardRef(VerticalScrollFaderInner);
|
|
82
|
+
|
|
83
|
+
function VerticalScrollFaderContentInner(
|
|
84
|
+
{
|
|
85
|
+
asChild,
|
|
86
|
+
faderSize = "3rem",
|
|
87
|
+
showScrollbar,
|
|
88
|
+
...props
|
|
89
|
+
}: AsChildProps<"div"> & {
|
|
90
|
+
faderSize?: string;
|
|
91
|
+
showScrollbar?: boolean;
|
|
92
|
+
},
|
|
93
|
+
ref: React.ForwardedRef<HTMLDivElement>,
|
|
94
|
+
) {
|
|
95
|
+
const Comp = asChild ? Slot : "div";
|
|
96
|
+
const { isTopOverflowing, isBottomOverflowing, containerRef, onScroll } =
|
|
97
|
+
useContext(VerticalScrollFaderContext);
|
|
98
|
+
|
|
99
|
+
useResizeObserver({
|
|
100
|
+
ref: containerRef as React.RefObject<Element>,
|
|
101
|
+
onResize: onScroll,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// A single vertical mask gradient fades the top and bottom edges over their
|
|
105
|
+
// fader sizes. An edge that isn't overflowing has a 0 fader size, so it shows
|
|
106
|
+
// no fade. Inline styles (with -webkit- for Safari) keep this independent of
|
|
107
|
+
// Tailwind mask-utility emission.
|
|
108
|
+
const maskImage = `linear-gradient(to bottom, transparent 0, #000 var(--top-fader-size), #000 calc(100% - var(--bottom-fader-size)), transparent 100%)`;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Slot
|
|
112
|
+
onScroll={onScroll}
|
|
113
|
+
className={cn(
|
|
114
|
+
"overflow-auto [transition:--top-fader-size_150ms,--bottom-fader-size_150ms]",
|
|
115
|
+
!showScrollbar &&
|
|
116
|
+
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
117
|
+
)}
|
|
118
|
+
style={{
|
|
119
|
+
"--fader-size": faderSize,
|
|
120
|
+
"--top-fader-size": isTopOverflowing ? "var(--fader-size)" : "0rem",
|
|
121
|
+
"--bottom-fader-size": isBottomOverflowing
|
|
122
|
+
? "var(--fader-size)"
|
|
123
|
+
: "0rem",
|
|
124
|
+
maskImage,
|
|
125
|
+
WebkitMaskImage: maskImage,
|
|
126
|
+
}}
|
|
127
|
+
ref={containerRef}
|
|
128
|
+
>
|
|
129
|
+
<Comp ref={ref} {...props} />
|
|
130
|
+
</Slot>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const VerticalScrollFaderContent = forwardRef(VerticalScrollFaderContentInner);
|
|
135
|
+
|
|
136
|
+
function VerticalScrollFaderTopScrollerInner(
|
|
137
|
+
{ asChild, ...props }: AsChildProps<"button">,
|
|
138
|
+
ref: React.ForwardedRef<HTMLButtonElement>,
|
|
139
|
+
) {
|
|
140
|
+
const Comp = asChild ? Slot : "button";
|
|
141
|
+
const { containerRef, isTopOverflowing } = useContext(
|
|
142
|
+
VerticalScrollFaderContext,
|
|
143
|
+
);
|
|
144
|
+
const onClick = useCallbackRef(() => {
|
|
145
|
+
if (!containerRef.current) return;
|
|
146
|
+
containerRef.current.scrollBy({
|
|
147
|
+
top: -containerRef.current.clientHeight,
|
|
148
|
+
behavior: "smooth",
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const [hoverRef, hovered] = useHover<HTMLElement>({
|
|
153
|
+
mounted: isTopOverflowing,
|
|
154
|
+
});
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!hovered || !isTopOverflowing) return;
|
|
157
|
+
let stopped = false;
|
|
158
|
+
let animationFrameId: number;
|
|
159
|
+
let startTime: DOMHighResTimeStamp;
|
|
160
|
+
|
|
161
|
+
const animate = (timestamp: DOMHighResTimeStamp) => {
|
|
162
|
+
if (stopped) return;
|
|
163
|
+
if (!startTime) startTime = timestamp;
|
|
164
|
+
const elapsed = Math.min(timestamp - startTime, 2000);
|
|
165
|
+
containerRef.current?.scrollBy({
|
|
166
|
+
top: -Math.log2(elapsed + 1),
|
|
167
|
+
});
|
|
168
|
+
animationFrameId = requestAnimationFrame(animate);
|
|
169
|
+
};
|
|
170
|
+
animationFrameId = requestAnimationFrame(animate);
|
|
171
|
+
|
|
172
|
+
return () => {
|
|
173
|
+
stopped = true;
|
|
174
|
+
cancelAnimationFrame(animationFrameId);
|
|
175
|
+
};
|
|
176
|
+
}, [hovered, isTopOverflowing, containerRef.current?.scrollBy]);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<Transition show={isTopOverflowing}>
|
|
180
|
+
<Slot onClick={onClick} ref={hoverRef}>
|
|
181
|
+
<Comp {...(asChild ? {} : { type: "button" })} ref={ref} {...props} />
|
|
182
|
+
</Slot>
|
|
183
|
+
</Transition>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const VerticalScrollFaderTopScroller = forwardRef(
|
|
188
|
+
VerticalScrollFaderTopScrollerInner,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
function VerticalScrollFaderBottomScrollerInner(
|
|
192
|
+
{ asChild, ...props }: AsChildProps<"button">,
|
|
193
|
+
ref: React.ForwardedRef<HTMLButtonElement>,
|
|
194
|
+
) {
|
|
195
|
+
const Comp = asChild ? Slot : "button";
|
|
196
|
+
const { containerRef, isBottomOverflowing } = useContext(
|
|
197
|
+
VerticalScrollFaderContext,
|
|
198
|
+
);
|
|
199
|
+
const onClick = useCallbackRef(() => {
|
|
200
|
+
if (!containerRef.current) return;
|
|
201
|
+
containerRef.current.scrollBy({
|
|
202
|
+
top: containerRef.current.clientHeight,
|
|
203
|
+
behavior: "smooth",
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
const [hoverRef, hovered] = useHover({
|
|
207
|
+
mounted: isBottomOverflowing,
|
|
208
|
+
});
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (!hovered || !isBottomOverflowing) return;
|
|
211
|
+
let stopped = false;
|
|
212
|
+
let animationFrameId: number;
|
|
213
|
+
let startTime: DOMHighResTimeStamp;
|
|
214
|
+
|
|
215
|
+
const animate = (timestamp: DOMHighResTimeStamp) => {
|
|
216
|
+
if (stopped) return;
|
|
217
|
+
if (!startTime) startTime = timestamp;
|
|
218
|
+
const elapsed = Math.min(timestamp - startTime, 2000);
|
|
219
|
+
containerRef.current?.scrollBy({
|
|
220
|
+
top: Math.log2(elapsed + 1),
|
|
221
|
+
});
|
|
222
|
+
animationFrameId = requestAnimationFrame(animate);
|
|
223
|
+
};
|
|
224
|
+
animationFrameId = requestAnimationFrame(animate);
|
|
225
|
+
|
|
226
|
+
return () => {
|
|
227
|
+
stopped = true;
|
|
228
|
+
cancelAnimationFrame(animationFrameId);
|
|
229
|
+
};
|
|
230
|
+
}, [hovered, isBottomOverflowing, containerRef.current?.scrollBy]);
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<Transition show={isBottomOverflowing}>
|
|
234
|
+
<Slot onClick={onClick} ref={hoverRef}>
|
|
235
|
+
<Comp {...(asChild ? {} : { type: "button" })} ref={ref} {...props} />
|
|
236
|
+
</Slot>
|
|
237
|
+
</Transition>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const VerticalScrollFaderBottomScroller = forwardRef(
|
|
242
|
+
VerticalScrollFaderBottomScrollerInner,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
export {
|
|
246
|
+
VerticalScrollFader,
|
|
247
|
+
VerticalScrollFaderContent,
|
|
248
|
+
VerticalScrollFaderTopScroller,
|
|
249
|
+
VerticalScrollFaderBottomScroller,
|
|
250
|
+
};
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import {
|
|
5
|
+
Maximize2Icon,
|
|
6
|
+
Minimize2Icon,
|
|
7
|
+
PauseIcon,
|
|
8
|
+
PlayIcon,
|
|
9
|
+
Volume2Icon,
|
|
10
|
+
VolumeXIcon,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import * as React from "react";
|
|
13
|
+
|
|
14
|
+
import { cn } from "../../lib/utils/css";
|
|
15
|
+
|
|
16
|
+
const videoPlayerVariants = cva(
|
|
17
|
+
"group relative overflow-hidden rounded-2xl bg-black ring-1 ring-inset ring-white/10",
|
|
18
|
+
{
|
|
19
|
+
defaultVariants: { size: "md" },
|
|
20
|
+
variants: {
|
|
21
|
+
size: {
|
|
22
|
+
sm: "w-full max-w-xs",
|
|
23
|
+
md: "w-full max-w-lg",
|
|
24
|
+
lg: "w-full max-w-2xl",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
interface VideoPlayerProps
|
|
31
|
+
extends Omit<React.ComponentProps<"div">, "title">,
|
|
32
|
+
VariantProps<typeof videoPlayerVariants> {
|
|
33
|
+
src: string;
|
|
34
|
+
poster?: string;
|
|
35
|
+
title?: string;
|
|
36
|
+
autoPlay?: boolean;
|
|
37
|
+
loop?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function VideoPlayer({
|
|
41
|
+
className,
|
|
42
|
+
size,
|
|
43
|
+
src,
|
|
44
|
+
poster,
|
|
45
|
+
title,
|
|
46
|
+
autoPlay = false,
|
|
47
|
+
loop = false,
|
|
48
|
+
...props
|
|
49
|
+
}: VideoPlayerProps) {
|
|
50
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
51
|
+
const videoRef = React.useRef<HTMLVideoElement>(null);
|
|
52
|
+
const hideTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
53
|
+
|
|
54
|
+
const [playing, setPlaying] = React.useState(false);
|
|
55
|
+
const [currentTime, setCurrentTime] = React.useState(0);
|
|
56
|
+
const [duration, setDuration] = React.useState(0);
|
|
57
|
+
const [muted, setMuted] = React.useState(false);
|
|
58
|
+
const [fullscreen, setFullscreen] = React.useState(false);
|
|
59
|
+
const [controlsVisible, setControlsVisible] = React.useState(true);
|
|
60
|
+
|
|
61
|
+
const clearHideTimer = () => {
|
|
62
|
+
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const scheduleHide = React.useCallback(() => {
|
|
66
|
+
clearHideTimer();
|
|
67
|
+
hideTimerRef.current = setTimeout(() => setControlsVisible(false), 2500);
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
React.useEffect(() => () => clearHideTimer(), []);
|
|
71
|
+
|
|
72
|
+
React.useEffect(() => {
|
|
73
|
+
if (!playing) {
|
|
74
|
+
clearHideTimer();
|
|
75
|
+
setControlsVisible(true);
|
|
76
|
+
}
|
|
77
|
+
}, [playing]);
|
|
78
|
+
|
|
79
|
+
React.useEffect(() => {
|
|
80
|
+
const handler = () => setFullscreen(!!document.fullscreenElement);
|
|
81
|
+
document.addEventListener("fullscreenchange", handler);
|
|
82
|
+
return () => document.removeEventListener("fullscreenchange", handler);
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
const handleMouseMove = () => {
|
|
86
|
+
setControlsVisible(true);
|
|
87
|
+
if (playing) scheduleHide();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleMouseLeave = () => {
|
|
91
|
+
if (playing) setControlsVisible(false);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const togglePlay = () => {
|
|
95
|
+
const v = videoRef.current;
|
|
96
|
+
if (!v) return;
|
|
97
|
+
if (v.paused) {
|
|
98
|
+
v.play();
|
|
99
|
+
} else {
|
|
100
|
+
v.pause();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
105
|
+
const v = videoRef.current;
|
|
106
|
+
if (!v) return;
|
|
107
|
+
const t = Number(e.target.value);
|
|
108
|
+
v.currentTime = t;
|
|
109
|
+
setCurrentTime(t);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const toggleMute = () => {
|
|
113
|
+
const v = videoRef.current;
|
|
114
|
+
if (!v) return;
|
|
115
|
+
v.muted = !v.muted;
|
|
116
|
+
setMuted(v.muted);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const toggleFullscreen = () => {
|
|
120
|
+
const el = containerRef.current;
|
|
121
|
+
if (!el) return;
|
|
122
|
+
if (!document.fullscreenElement) {
|
|
123
|
+
el.requestFullscreen().catch(() => {});
|
|
124
|
+
} else {
|
|
125
|
+
document.exitFullscreen().catch(() => {});
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const formatTime = (s: number) => {
|
|
130
|
+
if (!isFinite(s) || s < 0) return "0:00";
|
|
131
|
+
const m = Math.floor(s / 60);
|
|
132
|
+
const sec = Math.floor(s % 60);
|
|
133
|
+
return `${m}:${sec.toString().padStart(2, "0")}`;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div
|
|
140
|
+
ref={containerRef}
|
|
141
|
+
className={cn(videoPlayerVariants({ size }), className)}
|
|
142
|
+
data-slot="video-player"
|
|
143
|
+
onMouseLeave={handleMouseLeave}
|
|
144
|
+
onMouseMove={handleMouseMove}
|
|
145
|
+
{...props}
|
|
146
|
+
>
|
|
147
|
+
{/* Video */}
|
|
148
|
+
<video
|
|
149
|
+
ref={videoRef}
|
|
150
|
+
autoPlay={autoPlay}
|
|
151
|
+
className="aspect-video w-full object-cover"
|
|
152
|
+
loop={loop}
|
|
153
|
+
playsInline
|
|
154
|
+
poster={poster}
|
|
155
|
+
src={src}
|
|
156
|
+
onEnded={() => setPlaying(false)}
|
|
157
|
+
onLoadedMetadata={() => {
|
|
158
|
+
const v = videoRef.current;
|
|
159
|
+
if (v) setDuration(v.duration);
|
|
160
|
+
}}
|
|
161
|
+
onPause={() => setPlaying(false)}
|
|
162
|
+
onPlay={() => {
|
|
163
|
+
setPlaying(true);
|
|
164
|
+
scheduleHide();
|
|
165
|
+
}}
|
|
166
|
+
onTimeUpdate={() => {
|
|
167
|
+
const v = videoRef.current;
|
|
168
|
+
if (v) setCurrentTime(v.currentTime);
|
|
169
|
+
}}
|
|
170
|
+
onClick={togglePlay}
|
|
171
|
+
/>
|
|
172
|
+
|
|
173
|
+
{/* Center play overlay — visible when paused */}
|
|
174
|
+
{!playing && (
|
|
175
|
+
<button
|
|
176
|
+
className="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/20"
|
|
177
|
+
data-slot="video-player-overlay"
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={togglePlay}
|
|
180
|
+
>
|
|
181
|
+
<span className="flex size-14 items-center justify-center rounded-full bg-white/90 text-black shadow-lg ring-1 ring-inset ring-white/20 backdrop-blur-sm transition-transform hover:scale-105">
|
|
182
|
+
<PlayIcon className="size-5 translate-x-0.5" />
|
|
183
|
+
</span>
|
|
184
|
+
</button>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Controls */}
|
|
188
|
+
<div
|
|
189
|
+
className={cn(
|
|
190
|
+
"absolute inset-x-0 bottom-0 flex flex-col gap-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent px-3 pb-3 pt-10 transition-opacity duration-200",
|
|
191
|
+
controlsVisible ? "opacity-100" : "opacity-0",
|
|
192
|
+
)}
|
|
193
|
+
data-slot="video-player-controls"
|
|
194
|
+
>
|
|
195
|
+
{/* Scrubber */}
|
|
196
|
+
<div className="relative flex items-center">
|
|
197
|
+
<div className="relative h-1 w-full overflow-hidden rounded-full bg-white/25">
|
|
198
|
+
<div
|
|
199
|
+
className="pointer-events-none h-full rounded-full bg-white"
|
|
200
|
+
style={{ width: `${progress}%` }}
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
<input
|
|
204
|
+
aria-label="Seek"
|
|
205
|
+
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
|
206
|
+
max={duration || 100}
|
|
207
|
+
min={0}
|
|
208
|
+
step={0.1}
|
|
209
|
+
type="range"
|
|
210
|
+
value={currentTime}
|
|
211
|
+
onChange={handleSeek}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{/* Button row */}
|
|
216
|
+
<div className="flex items-center justify-between gap-2">
|
|
217
|
+
<div className="flex items-center gap-0.5">
|
|
218
|
+
<button
|
|
219
|
+
aria-label={playing ? "Pause" : "Play"}
|
|
220
|
+
className="flex size-7 items-center justify-center rounded-md text-white/80 transition-colors hover:bg-white/10 hover:text-white"
|
|
221
|
+
type="button"
|
|
222
|
+
onClick={togglePlay}
|
|
223
|
+
>
|
|
224
|
+
{playing ? (
|
|
225
|
+
<PauseIcon className="size-4" />
|
|
226
|
+
) : (
|
|
227
|
+
<PlayIcon className="size-4" />
|
|
228
|
+
)}
|
|
229
|
+
</button>
|
|
230
|
+
|
|
231
|
+
<button
|
|
232
|
+
aria-label={muted ? "Unmute" : "Mute"}
|
|
233
|
+
className="flex size-7 items-center justify-center rounded-md text-white/80 transition-colors hover:bg-white/10 hover:text-white"
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={toggleMute}
|
|
236
|
+
>
|
|
237
|
+
{muted ? (
|
|
238
|
+
<VolumeXIcon className="size-4" />
|
|
239
|
+
) : (
|
|
240
|
+
<Volume2Icon className="size-4" />
|
|
241
|
+
)}
|
|
242
|
+
</button>
|
|
243
|
+
|
|
244
|
+
<span className="select-none pl-1 text-[11px] tabular-nums text-white/60">
|
|
245
|
+
{formatTime(currentTime)} / {formatTime(duration)}
|
|
246
|
+
</span>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div className="flex items-center gap-1.5">
|
|
250
|
+
{title ? (
|
|
251
|
+
<span className="max-w-28 truncate text-[11px] font-medium text-white/50">
|
|
252
|
+
{title}
|
|
253
|
+
</span>
|
|
254
|
+
) : null}
|
|
255
|
+
|
|
256
|
+
<button
|
|
257
|
+
aria-label={fullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
|
258
|
+
className="flex size-7 items-center justify-center rounded-md text-white/80 transition-colors hover:bg-white/10 hover:text-white"
|
|
259
|
+
type="button"
|
|
260
|
+
onClick={toggleFullscreen}
|
|
261
|
+
>
|
|
262
|
+
{fullscreen ? (
|
|
263
|
+
<Minimize2Icon className="size-4" />
|
|
264
|
+
) : (
|
|
265
|
+
<Maximize2Icon className="size-4" />
|
|
266
|
+
)}
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export { VideoPlayer, videoPlayerVariants };
|