diff --git a/apps/client/package.json b/apps/client/package.json index 19592e4..137dc74 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -14,8 +14,13 @@ "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.3", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", "@tanstack/react-query": "^5.66.3", diff --git a/apps/client/src/app/video_player/page.tsx b/apps/client/src/app/video_player/page.tsx new file mode 100644 index 0000000..cc909b2 --- /dev/null +++ b/apps/client/src/app/video_player/page.tsx @@ -0,0 +1,12 @@ +import VideoPlayer from "@/components/video_player/video-player"; + +export default function Home() { + return ( +
+

+ Pushy Player +

+ +
+ ); +} diff --git a/apps/client/src/components/ui/label.tsx b/apps/client/src/components/ui/label.tsx new file mode 100644 index 0000000..5341821 --- /dev/null +++ b/apps/client/src/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/apps/client/src/components/ui/popover.tsx b/apps/client/src/components/ui/popover.tsx new file mode 100644 index 0000000..29c7bd2 --- /dev/null +++ b/apps/client/src/components/ui/popover.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverAnchor = PopoverPrimitive.Anchor + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/apps/client/src/components/ui/select.tsx b/apps/client/src/components/ui/select.tsx new file mode 100644 index 0000000..0cbf77d --- /dev/null +++ b/apps/client/src/components/ui/select.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/apps/client/src/components/ui/slider.tsx b/apps/client/src/components/ui/slider.tsx new file mode 100644 index 0000000..ab19d57 --- /dev/null +++ b/apps/client/src/components/ui/slider.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +import { cn } from "@/lib/utils" + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } diff --git a/apps/client/src/components/ui/switch.tsx b/apps/client/src/components/ui/switch.tsx new file mode 100644 index 0000000..5f4117f --- /dev/null +++ b/apps/client/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/apps/client/src/components/video_player/video-player.tsx b/apps/client/src/components/video_player/video-player.tsx new file mode 100644 index 0000000..d02fc75 --- /dev/null +++ b/apps/client/src/components/video_player/video-player.tsx @@ -0,0 +1,768 @@ +"use client"; + +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { + Loader2, + Play, + Pause, + SkipBack, + SkipForward, + Volume2, + VolumeX, + Maximize, + Minimize, + Settings, +} from "lucide-react"; +import { Slider } from "@/components/ui/slider"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { motion, AnimatePresence } from "framer-motion"; +import { handleKeyboardControls, formatTime } from "./videoControls"; + +interface VideoPlayerProps { + src: string; + poster?: string; +} + +const VideoPlayer: React.FC = ({ src, poster }) => { + // -------------------------------------------------------------------------- + // Refs for video element and container + // -------------------------------------------------------------------------- + const videoRef = useRef(null); + const containerRef = useRef(null); + + // -------------------------------------------------------------------------- + // State variables for playback and UI controls + // -------------------------------------------------------------------------- + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolume] = useState(1); + const [isMuted, setIsMuted] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [quality, setQuality] = useState("720p"); + const [skipDuration, setSkipDuration] = useState(5); + const [showSettings, setShowSettings] = useState(false); + const [previousVolume, setPreviousVolume] = useState(1); + const [showControls, setShowControls] = useState(true); + const [lastActivity, setLastActivity] = useState(Date.now()); + const hideControlsTimeout = useRef(null); + const [isBuffering, setIsBuffering] = useState(false); + + // -------------------------------------------------------------------------- + // VIDEO INITIALIZATION & SOURCE CHANGE + // Reloads the video when the src prop changes. + // -------------------------------------------------------------------------- + useEffect(() => { + if (videoRef.current) { + videoRef.current.load(); + } + }, [src]); + + // -------------------------------------------------------------------------- + // VIDEO EVENT HANDLERS + // Handles events like metadata loading, time update, buffering, etc. + // -------------------------------------------------------------------------- + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const onLoadedMetadata = () => { + setDuration(video.duration); + }; + + const onTimeUpdate = () => { + setCurrentTime(video.currentTime); + }; + + const onLoadStart = () => { + setIsLoading(true); + }; + + const onCanPlay = () => { + setIsLoading(false); + }; + + const onPlaying = () => { + setIsLoading(false); + setIsBuffering(false); + }; + + const onWaiting = () => { + setIsBuffering(true); + }; + + const onError = () => { + setIsLoading(false); + console.error("Video error occurred"); + }; + + video.addEventListener("loadstart", onLoadStart); + video.addEventListener("loadedmetadata", onLoadedMetadata); + video.addEventListener("timeupdate", onTimeUpdate); + video.addEventListener("canplay", onCanPlay); + video.addEventListener("playing", onPlaying); + video.addEventListener("waiting", onWaiting); + video.addEventListener("error", onError); + + return () => { + video.removeEventListener("loadstart", onLoadStart); + video.removeEventListener("loadedmetadata", onLoadedMetadata); + video.removeEventListener("timeupdate", onTimeUpdate); + video.removeEventListener("canplay", onCanPlay); + video.removeEventListener("playing", onPlaying); + video.removeEventListener("waiting", onWaiting); + video.removeEventListener("error", onError); + }; + }, [src]); + + // -------------------------------------------------------------------------- + // GLOBAL EVENT HANDLERS + // (Click Outside, Keyboard Controls, Fullscreen Change) + // -------------------------------------------------------------------------- + useEffect(() => { + // Close settings popover when clicking outside of the container + const handleClickOutside = (event: MouseEvent) => { + if ( + showSettings && + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setShowSettings(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + }, [showSettings]); + + useEffect(() => { + // Handle keyboard controls for video actions + const handleKeyPress = (e: KeyboardEvent) => { + handleKeyboardControls( + e, + { isPlaying, currentTime, duration, volume, isMuted, skipDuration }, + { + togglePlay, + handleSeek, + handleVolumeChange, + toggleMute, + toggleFullscreen, + skip, + handlePlaybackRate, + } + ); + }; + + window.addEventListener("keydown", handleKeyPress); + return () => window.removeEventListener("keydown", handleKeyPress); + }, [skipDuration, volume, duration, isPlaying]); + + useEffect(() => { + // Listen for fullscreen changes + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => + document.removeEventListener("fullscreenchange", handleFullscreenChange); + }, []); + + // -------------------------------------------------------------------------- + // AUTO-HIDE CONTROLS ON INACTIVITY + // Only when video is playing and not buffering. + // -------------------------------------------------------------------------- + useEffect(() => { + if (isPlaying && !isBuffering) { + const scheduleHideControls = () => { + if (hideControlsTimeout.current) { + clearTimeout(hideControlsTimeout.current); + } + hideControlsTimeout.current = setTimeout(() => { + if ( + Date.now() - lastActivity > 3000 && + isPlaying && + !showSettings && + !isBuffering + ) { + setShowControls(false); + } + }, 3000); + }; + + scheduleHideControls(); + + // Reset timer on mouse movement + const handleMouseMove = () => { + setLastActivity(Date.now()); + setShowControls(true); + scheduleHideControls(); + }; + + const container = containerRef.current; + container?.addEventListener("mousemove", handleMouseMove); + + return () => { + if (hideControlsTimeout.current) { + clearTimeout(hideControlsTimeout.current); + } + container?.removeEventListener("mousemove", handleMouseMove); + }; + } else { + // Always show controls when video is paused or buffering + setShowControls(true); + if (hideControlsTimeout.current) { + clearTimeout(hideControlsTimeout.current); + } + } + }, [isPlaying, lastActivity, showSettings, isBuffering]); + + // Ensure controls are visible when settings are open. + useEffect(() => { + if (showSettings) { + setShowControls(true); + } + }, [showSettings]); + + // -------------------------------------------------------------------------- + // VIDEO ACTION HANDLERS + // Functions to play/pause, seek, change volume, toggle mute/fullscreen, etc. + // -------------------------------------------------------------------------- + const togglePlay = useCallback(() => { + if (videoRef.current) { + if (isPlaying) { + videoRef.current.pause(); + } else { + const playPromise = videoRef.current.play(); + if (playPromise !== undefined) { + playPromise.catch((error) => + console.error("Error attempting to play:", error) + ); + } + } + setIsPlaying((prev) => !prev); + } + }, [isPlaying]); + + const handleSeek = (newTime: number) => { + if (videoRef.current) { + const clampedTime = Math.max(0, Math.min(newTime, duration)); + videoRef.current.currentTime = clampedTime; + setCurrentTime(clampedTime); + } + }; + + const handleVolumeChange = (newVolume: number) => { + if (videoRef.current) { + const clampedVolume = Math.max(0, Math.min(newVolume, 1)); + videoRef.current.volume = clampedVolume; + setVolume(clampedVolume); + setIsMuted(clampedVolume === 0); + if (clampedVolume > 0) { + setPreviousVolume(clampedVolume); + } + } + }; + + const handlePlaybackRate = (change: number) => { + if (videoRef.current) { + const newRate = Math.max( + 0.25, + Math.min(videoRef.current.playbackRate + change, 2) + ); + videoRef.current.playbackRate = newRate; + } + }; + + const toggleMute = () => { + if (videoRef.current) { + if (isMuted) { + handleVolumeChange(previousVolume); + } else { + setPreviousVolume(volume); + handleVolumeChange(0); + } + setIsMuted((prev) => !prev); + } + }; + + const toggleFullscreen = () => { + const container = containerRef.current; + if (!container) return; + + if (!document.fullscreenElement) { + container.requestFullscreen(); + } else { + document.exitFullscreen(); + } + }; + + const skip = (amount: number) => { + if (videoRef.current) { + const newTime = Math.max( + 0, + Math.min(videoRef.current.currentTime + amount, duration) + ); + videoRef.current.currentTime = newTime; + setCurrentTime(newTime); + } + }; + + // -------------------------------------------------------------------------- + // VIDEO CLICK HANDLERS + // -------------------------------------------------------------------------- + // Clicking directly on the video element toggles play/pause. + const handleVideoClick = (e: React.MouseEvent) => { + if (e.target === videoRef.current) { + togglePlay(); + } + }; + + // When the overlay (play button) is visible, clicking it also toggles play. + const handleOverlayClick = (e: React.MouseEvent) => { + e.stopPropagation(); + togglePlay(); + }; + + // -------------------------------------------------------------------------- + // UTILITY: Check if the video is ready for interaction + // -------------------------------------------------------------------------- + const canPlayVideo = () => + videoRef.current && + videoRef.current.readyState >= 3 && // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA + !isLoading; + + // -------------------------------------------------------------------------- + // RENDER: JSX + // -------------------------------------------------------------------------- + return ( + +
setShowControls(true)} + > + {/* ---------------------------------------------------------------------- */} + {/* LOADING & BUFFERING OVERLAYS */} + {/* ---------------------------------------------------------------------- */} + {isLoading && ( +
+ +
+ )} + {isBuffering && !isLoading && ( +
+ +
+ )} + + {/* ---------------------------------------------------------------------- */} + {/* VIDEO DISPLAY AREA */} + {/* ---------------------------------------------------------------------- */} +
+
+ + {/* ---------------------------------------------------------------------- */} + {/* CONTROLS OVERLAY */} + {/* ---------------------------------------------------------------------- */} + + {showControls && ( + + {isFullscreen ? ( + // ================= FULLSCREEN CONTROLS ================= +
+ {/* Progress Slider */} +
+ handleSeek(value)} + className="w-full h-2" + disabled={!canPlayVideo()} + /> +
+
+
+ + + + + handleVolumeChange(value)} + className="w-16" + disabled={isLoading} + /> + + {formatTime(currentTime)} / {formatTime(duration)} + +
+
+ {/* Settings Popover for Fullscreen */} + + + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+ ) : ( + // ================= NORMAL CONTROLS (NON-FULLSCREEN) ================= +
+
+ handleSeek(value)} + className="w-full h-2" + disabled={!canPlayVideo()} + /> +
+
+
+ + + + + +

{isPlaying ? "Pause (Space)" : "Play (Space)"}

+
+
+ + + + + +

Skip Back {skipDuration}s (←)

+
+
+ + + + + +

Skip Forward {skipDuration}s (→)

+
+
+ + + + + +

{isMuted ? "Unmute (M)" : "Mute (M)"}

+
+
+ handleVolumeChange(value)} + className="w-24" + disabled={isLoading} + /> + + {formatTime(currentTime)} / {formatTime(duration)} + +
+
+ {/* Settings Popover for Normal Mode */} + + + + + +
+
+ + +
+
+ + +
+
+
+
+ + + + + +

Enter Fullscreen (F)

+
+
+
+
+
+ )} +
+ )} +
+
+
+ ); +}; + +export default VideoPlayer; diff --git a/apps/client/src/components/video_player/videoControls.ts b/apps/client/src/components/video_player/videoControls.ts new file mode 100644 index 0000000..bff7022 --- /dev/null +++ b/apps/client/src/components/video_player/videoControls.ts @@ -0,0 +1,105 @@ +export interface VideoControlsState { + isPlaying: boolean + currentTime: number + duration: number + volume: number + isMuted: boolean + skipDuration: number +} + +export interface VideoControlsHandlers { + togglePlay: () => void + handleSeek: (time: number) => void + handleVolumeChange: (volume: number) => void + toggleMute: () => void + toggleFullscreen: () => void + skip: (amount: number) => void + handlePlaybackRate: (change: number) => void +} + +export const handleKeyboardControls = ( + e: KeyboardEvent, + videoState: VideoControlsState, + handlers: VideoControlsHandlers +) => { + // Ignore key events if user is typing in an input field + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return + } + + // Ensure skipDuration is a valid number + const skipAmount = typeof videoState.skipDuration === 'number' && !isNaN(videoState.skipDuration) + ? videoState.skipDuration + : 5; // Default to 5 seconds if invalid + + switch (e.code) { + case "Space": + e.preventDefault() + handlers.togglePlay() + break + case "ArrowLeft": + e.preventDefault() + handlers.skip(-skipAmount) // Use the validated skipAmount + break + case "ArrowRight": + e.preventDefault() + handlers.skip(skipAmount) // Use the validated skipAmount + break + case "ArrowUp": + e.preventDefault() + handlers.handleVolumeChange(Math.min(videoState.volume + 0.1, 1)) + break + case "ArrowDown": + e.preventDefault() + handlers.handleVolumeChange(Math.max(videoState.volume - 0.1, 0)) + break + case "KeyM": + e.preventDefault() + handlers.toggleMute() + break + case "KeyF": + e.preventDefault() + handlers.toggleFullscreen() + break + case "Home": + e.preventDefault() + handlers.handleSeek(0) + break + case "End": + e.preventDefault() + handlers.handleSeek(videoState.duration) + break + case "Period": + if (e.shiftKey) { + e.preventDefault() + handlers.handlePlaybackRate(0.25) + } + break + case "Comma": + if (e.shiftKey) { + e.preventDefault() + handlers.handlePlaybackRate(-0.25) + } + break + case "Digit0": + case "Digit1": + case "Digit2": + case "Digit3": + case "Digit4": + case "Digit5": + case "Digit6": + case "Digit7": + case "Digit8": + case "Digit9": + e.preventDefault() + const percent = parseInt(e.code.slice(-1)) * 10 + handlers.handleSeek((videoState.duration * percent) / 100) + break + } +} + +export const formatTime = (time: number): string => { + const minutes = Math.floor(time / 60) + const seconds = Math.floor(time % 60) + return `${minutes}:${seconds.toString().padStart(2, "0")}` +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c00425..b0175b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,12 +39,27 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.6 version: 2.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-label': + specifier: ^2.1.2 + version: 2.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-popover': + specifier: ^1.1.6 + version: 1.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-select': + specifier: ^2.1.6 + version: 2.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-separator': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slider': + specifier: ^1.2.3 + version: 1.2.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-slot': specifier: ^1.1.2 version: 1.1.2(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-switch': + specifier: ^1.1.3 + version: 1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-tabs': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -1192,6 +1207,9 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@radix-ui/number@1.1.0': + resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} + '@radix-ui/primitive@1.1.1': resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} @@ -1344,6 +1362,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.1.2': + resolution: {integrity: sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-menu@2.1.6': resolution: {integrity: sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==} peerDependencies: @@ -1357,6 +1388,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.6': + resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.2': resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==} peerDependencies: @@ -1422,6 +1466,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-select@2.1.6': + resolution: {integrity: sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-separator@1.1.2': resolution: {integrity: sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==} peerDependencies: @@ -1435,6 +1492,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.2.3': + resolution: {integrity: sha512-nNrLAWLjGESnhqBqcCNW4w2nn7LxudyMzeB6VgdyAnFLC6kfQgnAjSL2v6UkQTnDctJBlxrmxfplWS4iYjdUTw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.1.2': resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} peerDependencies: @@ -1444,6 +1514,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.1.3': + resolution: {integrity: sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tabs@1.1.3': resolution: {integrity: sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==} peerDependencies: @@ -1506,6 +1589,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.0': + resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.0': resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} peerDependencies: @@ -6344,6 +6436,8 @@ snapshots: '@popperjs/core@2.11.8': {} + '@radix-ui/number@1.1.0': {} + '@radix-ui/primitive@1.1.1': {} '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': @@ -6487,6 +6581,15 @@ snapshots: optionalDependencies: '@types/react': 19.0.8 + '@radix-ui/react-label@2.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.8 + '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-menu@2.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -6513,6 +6616,29 @@ snapshots: '@types/react': 19.0.8 '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-popover@1.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-id': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.1.2(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.8)(react@19.0.0) + aria-hidden: 1.2.4 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-remove-scroll: 2.6.3(@types/react@19.0.8)(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.8 + '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-popper@1.2.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -6577,6 +6703,35 @@ snapshots: '@types/react': 19.0.8 '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-select@2.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-direction': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-id': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.1.2(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-visually-hidden': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + aria-hidden: 1.2.4 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-remove-scroll: 2.6.3(@types/react@19.0.8)(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.8 + '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-separator@1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -6586,6 +6741,25 @@ snapshots: '@types/react': 19.0.8 '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-slider@1.2.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-direction': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-use-size': 1.1.0(@types/react@19.0.8)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.8 + '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-slot@1.1.2(@types/react@19.0.8)(react@19.0.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0) @@ -6593,6 +6767,21 @@ snapshots: optionalDependencies: '@types/react': 19.0.8 + '@radix-ui/react-switch@1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.0(@types/react@19.0.8)(react@19.0.0) + '@radix-ui/react-use-size': 1.1.0(@types/react@19.0.8)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.8 + '@types/react-dom': 19.0.3(@types/react@19.0.8) + '@radix-ui/react-tabs@1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -6655,6 +6844,12 @@ snapshots: optionalDependencies: '@types/react': 19.0.8 + '@radix-ui/react-use-previous@1.1.0(@types/react@19.0.8)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.8 + '@radix-ui/react-use-rect@1.1.0(@types/react@19.0.8)(react@19.0.0)': dependencies: '@radix-ui/rect': 1.1.0