diff options
| author | Hayden Bleasel <hello@haydenbleasel.com> | 2025-07-24 14:41:34 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-07-25 00:41:34 +0300 |
| commit | 5931ddb77aac23ffe1b82d9d7c03afde3e671109 (patch) | |
| tree | 3e2ead9cc6e13c01132a78c21b64f2be7b00e215 /apps/web/src/components/ui | |
| parent | 7b840e9b4e198212eb34861c21469b2215157914 (diff) | |
| download | opencut-5931ddb77aac23ffe1b82d9d7c03afde3e671109.tar.gz | |
feat: add ultracite (#452)
* Init ultracite
* Update scripts and biome.jsonc
* Update biome.jsonc
* Update biome.jsonc
* Update biome.jsonc
* Run format
Diffstat (limited to 'apps/web/src/components/ui')
| -rw-r--r-- | apps/web/src/components/ui/aspect-ratio.tsx | 8 | ||||
| -rw-r--r-- | apps/web/src/components/ui/audio-player.tsx | 254 | ||||
| -rw-r--r-- | apps/web/src/components/ui/button.tsx | 3 | ||||
| -rw-r--r-- | apps/web/src/components/ui/card.tsx | 5 | ||||
| -rw-r--r-- | apps/web/src/components/ui/carousel.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/ui/chart.tsx | 4 | ||||
| -rw-r--r-- | apps/web/src/components/ui/collapsible.tsx | 12 | ||||
| -rw-r--r-- | apps/web/src/components/ui/font-picker.tsx | 80 | ||||
| -rw-r--r-- | apps/web/src/components/ui/form.tsx | 4 | ||||
| -rw-r--r-- | apps/web/src/components/ui/image-timeline-treatment.tsx | 204 | ||||
| -rw-r--r-- | apps/web/src/components/ui/phone-input.tsx | 334 | ||||
| -rw-r--r-- | apps/web/src/components/ui/separator.tsx | 14 | ||||
| -rw-r--r-- | apps/web/src/components/ui/sonner.tsx | 16 | ||||
| -rw-r--r-- | apps/web/src/components/ui/sponsor-button.tsx | 1 | ||||
| -rw-r--r-- | apps/web/src/components/ui/toaster.tsx | 24 |
15 files changed, 477 insertions, 488 deletions
diff --git a/apps/web/src/components/ui/aspect-ratio.tsx b/apps/web/src/components/ui/aspect-ratio.tsx index 6f90bbb8..082de9df 100644 --- a/apps/web/src/components/ui/aspect-ratio.tsx +++ b/apps/web/src/components/ui/aspect-ratio.tsx @@ -1,7 +1,7 @@ -"use client" +"use client"; -import { AspectRatio as AspectRatioPrimitive } from "radix-ui" +import { AspectRatio as AspectRatioPrimitive } from "radix-ui"; -const AspectRatio = AspectRatioPrimitive.Root +const AspectRatio = AspectRatioPrimitive.Root; -export { AspectRatio } +export { AspectRatio }; diff --git a/apps/web/src/components/ui/audio-player.tsx b/apps/web/src/components/ui/audio-player.tsx index 774dd95c..37bf8ca9 100644 --- a/apps/web/src/components/ui/audio-player.tsx +++ b/apps/web/src/components/ui/audio-player.tsx @@ -1,127 +1,127 @@ -"use client";
-
-import { useRef, useEffect } from "react";
-import { usePlaybackStore } from "@/stores/playback-store";
-
-interface AudioPlayerProps {
- src: string;
- className?: string;
- clipStartTime: number;
- trimStart: number;
- trimEnd: number;
- clipDuration: number;
- trackMuted?: boolean;
-}
-
-export function AudioPlayer({
- src,
- className = "",
- clipStartTime,
- trimStart,
- trimEnd,
- clipDuration,
- trackMuted = false,
-}: AudioPlayerProps) {
- const audioRef = useRef<HTMLAudioElement>(null);
- const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
-
- // Calculate if we're within this clip's timeline range
- const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
- const isInClipRange =
- currentTime >= clipStartTime && currentTime < clipEndTime;
-
- // Sync playback events
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio || !isInClipRange) return;
-
- const handleSeekEvent = (e: CustomEvent) => {
- // Always update audio time, even if outside clip range
- const timelineTime = e.detail.time;
- const audioTime = Math.max(
- trimStart,
- Math.min(
- clipDuration - trimEnd,
- timelineTime - clipStartTime + trimStart
- )
- );
- audio.currentTime = audioTime;
- };
-
- const handleUpdateEvent = (e: CustomEvent) => {
- // Always update audio time, even if outside clip range
- const timelineTime = e.detail.time;
- const targetTime = Math.max(
- trimStart,
- Math.min(
- clipDuration - trimEnd,
- timelineTime - clipStartTime + trimStart
- )
- );
-
- if (Math.abs(audio.currentTime - targetTime) > 0.5) {
- audio.currentTime = targetTime;
- }
- };
-
- const handleSpeed = (e: CustomEvent) => {
- audio.playbackRate = e.detail.speed;
- };
-
- window.addEventListener("playback-seek", handleSeekEvent as EventListener);
- window.addEventListener(
- "playback-update",
- handleUpdateEvent as EventListener
- );
- window.addEventListener("playback-speed", handleSpeed as EventListener);
-
- return () => {
- window.removeEventListener(
- "playback-seek",
- handleSeekEvent as EventListener
- );
- window.removeEventListener(
- "playback-update",
- handleUpdateEvent as EventListener
- );
- window.removeEventListener(
- "playback-speed",
- handleSpeed as EventListener
- );
- };
- }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
-
- // Sync playback state
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio) return;
-
- if (isPlaying && isInClipRange && !trackMuted) {
- audio.play().catch(() => {});
- } else {
- audio.pause();
- }
- }, [isPlaying, isInClipRange, trackMuted]);
-
- // Sync volume and speed
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio) return;
-
- audio.volume = volume;
- audio.muted = muted || trackMuted;
- audio.playbackRate = speed;
- }, [volume, speed, muted, trackMuted]);
-
- return (
- <audio
- ref={audioRef}
- src={src}
- className={className}
- preload="auto"
- controls={false}
- style={{ display: "none" }} // Audio elements don't need visual representation
- onContextMenu={(e) => e.preventDefault()}
- />
- );
-}
+"use client"; + +import { useRef, useEffect } from "react"; +import { usePlaybackStore } from "@/stores/playback-store"; + +interface AudioPlayerProps { + src: string; + className?: string; + clipStartTime: number; + trimStart: number; + trimEnd: number; + clipDuration: number; + trackMuted?: boolean; +} + +export function AudioPlayer({ + src, + className = "", + clipStartTime, + trimStart, + trimEnd, + clipDuration, + trackMuted = false, +}: AudioPlayerProps) { + const audioRef = useRef<HTMLAudioElement>(null); + const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore(); + + // Calculate if we're within this clip's timeline range + const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); + const isInClipRange = + currentTime >= clipStartTime && currentTime < clipEndTime; + + // Sync playback events + useEffect(() => { + const audio = audioRef.current; + if (!audio || !isInClipRange) return; + + const handleSeekEvent = (e: CustomEvent) => { + // Always update audio time, even if outside clip range + const timelineTime = e.detail.time; + const audioTime = Math.max( + trimStart, + Math.min( + clipDuration - trimEnd, + timelineTime - clipStartTime + trimStart + ) + ); + audio.currentTime = audioTime; + }; + + const handleUpdateEvent = (e: CustomEvent) => { + // Always update audio time, even if outside clip range + const timelineTime = e.detail.time; + const targetTime = Math.max( + trimStart, + Math.min( + clipDuration - trimEnd, + timelineTime - clipStartTime + trimStart + ) + ); + + if (Math.abs(audio.currentTime - targetTime) > 0.5) { + audio.currentTime = targetTime; + } + }; + + const handleSpeed = (e: CustomEvent) => { + audio.playbackRate = e.detail.speed; + }; + + window.addEventListener("playback-seek", handleSeekEvent as EventListener); + window.addEventListener( + "playback-update", + handleUpdateEvent as EventListener + ); + window.addEventListener("playback-speed", handleSpeed as EventListener); + + return () => { + window.removeEventListener( + "playback-seek", + handleSeekEvent as EventListener + ); + window.removeEventListener( + "playback-update", + handleUpdateEvent as EventListener + ); + window.removeEventListener( + "playback-speed", + handleSpeed as EventListener + ); + }; + }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); + + // Sync playback state + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying && isInClipRange && !trackMuted) { + audio.play().catch(() => {}); + } else { + audio.pause(); + } + }, [isPlaying, isInClipRange, trackMuted]); + + // Sync volume and speed + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + audio.volume = volume; + audio.muted = muted || trackMuted; + audio.playbackRate = speed; + }, [volume, speed, muted, trackMuted]); + + return ( + <audio + ref={audioRef} + src={src} + className={className} + preload="auto" + controls={false} + style={{ display: "none" }} // Audio elements don't need visual representation + onContextMenu={(e) => e.preventDefault()} + /> + ); +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index b8b9ab2f..5902af65 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -9,8 +9,7 @@ const buttonVariants = cva( { variants: { variant: { - default: - "bg-foreground text-background shadow hover:bg-foreground/90", + default: "bg-foreground text-background shadow hover:bg-foreground/90", primary: "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx index c5ca9d9f..6d0b37cc 100644 --- a/apps/web/src/components/ui/card.tsx +++ b/apps/web/src/components/ui/card.tsx @@ -8,10 +8,7 @@ const Card = React.forwardRef< >(({ className, ...props }, ref) => ( <div ref={ref} - className={cn( - "rounded-xl border bg-card text-card-foreground", - className - )} + className={cn("rounded-xl border bg-card text-card-foreground", className)} {...props} /> )); diff --git a/apps/web/src/components/ui/carousel.tsx b/apps/web/src/components/ui/carousel.tsx index 3c0efdc6..a9c98b67 100644 --- a/apps/web/src/components/ui/carousel.tsx +++ b/apps/web/src/components/ui/carousel.tsx @@ -124,7 +124,7 @@ const Carousel = React.forwardRef< <CarouselContext.Provider value={{ carouselRef, - api: api, + api, opts, orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), diff --git a/apps/web/src/components/ui/chart.tsx b/apps/web/src/components/ui/chart.tsx index a2c19116..82d77752 100644 --- a/apps/web/src/components/ui/chart.tsx +++ b/apps/web/src/components/ui/chart.tsx @@ -188,7 +188,7 @@ const ChartTooltipContent = React.forwardRef< className )} > - {!nestLabel ? tooltipLabel : null} + {nestLabel ? null : tooltipLabel} <div className="grid gap-1.5"> {payload.map((item, index) => { const key = `${nameKey || item.name || item.dataKey || "value"}`; @@ -328,7 +328,7 @@ function getPayloadConfigFromPayload( key: string ) { if (typeof payload !== "object" || payload === null) { - return undefined; + return; } const payloadPayload = diff --git a/apps/web/src/components/ui/collapsible.tsx b/apps/web/src/components/ui/collapsible.tsx index 6642d604..acb56414 100644 --- a/apps/web/src/components/ui/collapsible.tsx +++ b/apps/web/src/components/ui/collapsible.tsx @@ -1,11 +1,11 @@ -"use client" +"use client"; -import { Collapsible as CollapsiblePrimitive } from "radix-ui" +import { Collapsible as CollapsiblePrimitive } from "radix-ui"; -const Collapsible = CollapsiblePrimitive.Root +const Collapsible = CollapsiblePrimitive.Root; -const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; -const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; -export { Collapsible, CollapsibleTrigger, CollapsibleContent } +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 9a152fd4..479a5f0c 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -1,40 +1,40 @@ -import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
-
-interface FontPickerProps {
- defaultValue?: FontFamily;
- onValueChange?: (value: FontFamily) => void;
- className?: string;
-}
-
-export function FontPicker({
- defaultValue,
- onValueChange,
- className,
-}: FontPickerProps) {
- return (
- <Select defaultValue={defaultValue} onValueChange={onValueChange}>
- <SelectTrigger className={`w-full text-xs ${className || ""}`}>
- <SelectValue placeholder="Select a font" />
- </SelectTrigger>
- <SelectContent>
- {FONT_OPTIONS.map((font) => (
- <SelectItem
- key={font.value}
- value={font.value}
- className="text-xs"
- style={{ fontFamily: font.value }}
- >
- {font.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- );
-}
+import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants"; + +interface FontPickerProps { + defaultValue?: FontFamily; + onValueChange?: (value: FontFamily) => void; + className?: string; +} + +export function FontPicker({ + defaultValue, + onValueChange, + className, +}: FontPickerProps) { + return ( + <Select defaultValue={defaultValue} onValueChange={onValueChange}> + <SelectTrigger className={`w-full text-xs ${className || ""}`}> + <SelectValue placeholder="Select a font" /> + </SelectTrigger> + <SelectContent> + {FONT_OPTIONS.map((font) => ( + <SelectItem + key={font.value} + value={font.value} + className="text-xs" + style={{ fontFamily: font.value }} + > + {font.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ); +} diff --git a/apps/web/src/components/ui/form.tsx b/apps/web/src/components/ui/form.tsx index cb4f53a9..cab151e1 100644 --- a/apps/web/src/components/ui/form.tsx +++ b/apps/web/src/components/ui/form.tsx @@ -115,9 +115,7 @@ const FormControl = React.forwardRef< ref={ref} id={formItemId} aria-describedby={ - !error - ? `${formDescriptionId}` - : `${formDescriptionId} ${formMessageId}` + error ? `${formDescriptionId} ${formMessageId}` : `${formDescriptionId}` } aria-invalid={!!error} {...props} diff --git a/apps/web/src/components/ui/image-timeline-treatment.tsx b/apps/web/src/components/ui/image-timeline-treatment.tsx index 31847df5..7a64be18 100644 --- a/apps/web/src/components/ui/image-timeline-treatment.tsx +++ b/apps/web/src/components/ui/image-timeline-treatment.tsx @@ -1,102 +1,102 @@ -"use client";
-
-import { useState } from "react";
-import { cn } from "@/lib/utils";
-import type { BackgroundType } from "@/types/editor";
-
-interface ImageTimelineTreatmentProps {
- src: string;
- alt: string;
- targetAspectRatio?: number; // Default to 16:9 for video
- className?: string;
- backgroundType?: BackgroundType;
- backgroundColor?: string;
-}
-
-export function ImageTimelineTreatment({
- src,
- alt,
- targetAspectRatio = 16 / 9,
- className,
- backgroundType = "blur",
- backgroundColor = "#000000",
-}: ImageTimelineTreatmentProps) {
- const [imageLoaded, setImageLoaded] = useState(false);
- const [imageDimensions, setImageDimensions] = useState<{
- width: number;
- height: number;
- } | null>(null);
-
- const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
- const img = e.currentTarget;
- setImageDimensions({
- width: img.naturalWidth,
- height: img.naturalHeight,
- });
- setImageLoaded(true);
- };
-
- const imageAspectRatio = imageDimensions
- ? imageDimensions.width / imageDimensions.height
- : 1;
-
- const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio;
-
- return (
- <div
- className={cn("relative overflow-hidden", className)}
- style={{ aspectRatio: targetAspectRatio }}
- >
- {/* Background Layer */}
- {needsAspectRatioTreatment && imageLoaded && (
- <>
- {backgroundType === "blur" && (
- <div className="absolute inset-0">
- <img
- src={src}
- alt=""
- className="w-full h-full object-cover filter blur-xl scale-110 opacity-60"
- aria-hidden="true"
- />
- <div className="absolute inset-0 bg-black/20" />
- </div>
- )}
-
- {backgroundType === "mirror" && (
- <div className="absolute inset-0">
- <img
- src={src}
- alt=""
- className="w-full h-full object-cover opacity-30"
- aria-hidden="true"
- />
- </div>
- )}
-
- {backgroundType === "color" && (
- <div className="absolute inset-0" style={{ backgroundColor }} />
- )}
- </>
- )}
-
- {/* Main Image Layer */}
- <div className="absolute inset-0">
- <img
- src={src}
- alt={alt}
- className="w-full h-full object-cover"
- onLoad={handleImageLoad}
- />
- </div>
-
- {/* Loading state */}
- {!imageLoaded && (
- <div className="absolute inset-0 flex items-center justify-center bg-muted/30">
- <div className="animate-pulse text-xs text-muted-foreground">
- Loading...
- </div>
- </div>
- )}
- </div>
- );
-}
+"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import type { BackgroundType } from "@/types/editor"; + +interface ImageTimelineTreatmentProps { + src: string; + alt: string; + targetAspectRatio?: number; // Default to 16:9 for video + className?: string; + backgroundType?: BackgroundType; + backgroundColor?: string; +} + +export function ImageTimelineTreatment({ + src, + alt, + targetAspectRatio = 16 / 9, + className, + backgroundType = "blur", + backgroundColor = "#000000", +}: ImageTimelineTreatmentProps) { + const [imageLoaded, setImageLoaded] = useState(false); + const [imageDimensions, setImageDimensions] = useState<{ + width: number; + height: number; + } | null>(null); + + const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => { + const img = e.currentTarget; + setImageDimensions({ + width: img.naturalWidth, + height: img.naturalHeight, + }); + setImageLoaded(true); + }; + + const imageAspectRatio = imageDimensions + ? imageDimensions.width / imageDimensions.height + : 1; + + const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio; + + return ( + <div + className={cn("relative overflow-hidden", className)} + style={{ aspectRatio: targetAspectRatio }} + > + {/* Background Layer */} + {needsAspectRatioTreatment && imageLoaded && ( + <> + {backgroundType === "blur" && ( + <div className="absolute inset-0"> + <img + src={src} + alt="" + className="w-full h-full object-cover filter blur-xl scale-110 opacity-60" + aria-hidden="true" + /> + <div className="absolute inset-0 bg-black/20" /> + </div> + )} + + {backgroundType === "mirror" && ( + <div className="absolute inset-0"> + <img + src={src} + alt="" + className="w-full h-full object-cover opacity-30" + aria-hidden="true" + /> + </div> + )} + + {backgroundType === "color" && ( + <div className="absolute inset-0" style={{ backgroundColor }} /> + )} + </> + )} + + {/* Main Image Layer */} + <div className="absolute inset-0"> + <img + src={src} + alt={alt} + className="w-full h-full object-cover" + onLoad={handleImageLoad} + /> + </div> + + {/* Loading state */} + {!imageLoaded && ( + <div className="absolute inset-0 flex items-center justify-center bg-muted/30"> + <div className="animate-pulse text-xs text-muted-foreground"> + Loading... + </div> + </div> + )} + </div> + ); +} diff --git a/apps/web/src/components/ui/phone-input.tsx b/apps/web/src/components/ui/phone-input.tsx index 95ad9131..ca85df8f 100644 --- a/apps/web/src/components/ui/phone-input.tsx +++ b/apps/web/src/components/ui/phone-input.tsx @@ -1,167 +1,167 @@ -import * as React from "react";
-import { CheckIcon, ChevronsUpDown } from "lucide-react";
-import * as RPNInput from "react-phone-number-input";
-import flags from "react-phone-number-input/flags";
-
-import { Button } from "./button";
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "./command";
-import { Input } from "./input";
-import { Popover, PopoverContent, PopoverTrigger } from "./popover";
-import { ScrollArea } from "./scroll-area";
-import { cn } from "../../lib/utils";
-
-type PhoneInputProps = Omit<
- React.ComponentProps<"input">,
- "onChange" | "value" | "ref"
-> &
- Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
- onChange?: (value: RPNInput.Value) => void;
- };
-
-const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
- React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
- ({ className, onChange, defaultCountry = "US", ...props }, ref) => {
- return (
- <RPNInput.default
- ref={ref}
- className={cn("flex", className)}
- flagComponent={FlagComponent}
- countrySelectComponent={CountrySelect}
- inputComponent={InputComponent}
- defaultCountry={defaultCountry}
- international
- addInternationalOption
- smartCaret={false}
- /**
- * Handles the onChange event.
- *
- * react-phone-number-input might trigger the onChange event as undefined
- * when a valid phone number is not entered. To prevent this,
- * the value is coerced to an empty string.
- *
- * @param {E164Number | undefined} value - The entered value
- */
- onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
- {...props}
- />
- );
- }
- );
-PhoneInput.displayName = "PhoneInput";
-
-const InputComponent = React.forwardRef<
- HTMLInputElement,
- React.ComponentProps<"input">
->(({ className, ...props }, ref) => (
- <Input
- className={cn("rounded-e-lg rounded-s-none", className)}
- {...props}
- ref={ref}
- />
-));
-InputComponent.displayName = "InputComponent";
-
-type CountryEntry = { label: string; value: RPNInput.Country | undefined };
-
-type CountrySelectProps = {
- disabled?: boolean;
- value: RPNInput.Country;
- options: CountryEntry[];
- onChange: (country: RPNInput.Country) => void;
-};
-
-const CountrySelect = ({
- disabled,
- value: selectedCountry,
- options: countryList,
- onChange,
-}: CountrySelectProps) => {
- return (
- <Popover>
- <PopoverTrigger asChild>
- <Button
- type="button"
- variant="outline"
- className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10"
- disabled={disabled}
- >
- <FlagComponent
- country={selectedCountry}
- countryName={selectedCountry}
- />
- <ChevronsUpDown
- className={cn(
- "-mr-2 size-4 opacity-50",
- disabled ? "hidden" : "opacity-100"
- )}
- />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[300px] p-0">
- <Command>
- <CommandInput placeholder="Search country..." />
- <CommandList>
- <ScrollArea className="h-72">
- <CommandEmpty>No country found.</CommandEmpty>
- <CommandGroup>
- {countryList.map(({ value, label }) =>
- value ? (
- <CountrySelectOption
- key={value}
- country={value}
- countryName={label}
- selectedCountry={selectedCountry}
- onChange={onChange}
- />
- ) : null
- )}
- </CommandGroup>
- </ScrollArea>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- );
-};
-
-interface CountrySelectOptionProps extends RPNInput.FlagProps {
- selectedCountry: RPNInput.Country;
- onChange: (country: RPNInput.Country) => void;
-}
-
-const CountrySelectOption = ({
- country,
- countryName,
- selectedCountry,
- onChange,
-}: CountrySelectOptionProps) => {
- return (
- <CommandItem className="gap-2" onSelect={() => onChange(country)}>
- <FlagComponent country={country} countryName={countryName} />
- <span className="flex-1 text-sm">{countryName}</span>
- <span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
- <CheckIcon
- className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
- />
- </CommandItem>
- );
-};
-
-const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
- const Flag = flags[country];
-
- return (
- <span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg]:size-full">
- {Flag && <Flag title={countryName} />}
- </span>
- );
-};
-
-export { PhoneInput };
+import * as React from "react"; +import { CheckIcon, ChevronsUpDown } from "lucide-react"; +import * as RPNInput from "react-phone-number-input"; +import flags from "react-phone-number-input/flags"; + +import { Button } from "./button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "./command"; +import { Input } from "./input"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; +import { ScrollArea } from "./scroll-area"; +import { cn } from "../../lib/utils"; + +type PhoneInputProps = Omit< + React.ComponentProps<"input">, + "onChange" | "value" | "ref" +> & + Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & { + onChange?: (value: RPNInput.Value) => void; + }; + +const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = + React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>( + ({ className, onChange, defaultCountry = "US", ...props }, ref) => { + return ( + <RPNInput.default + ref={ref} + className={cn("flex", className)} + flagComponent={FlagComponent} + countrySelectComponent={CountrySelect} + inputComponent={InputComponent} + defaultCountry={defaultCountry} + international + addInternationalOption + smartCaret={false} + /** + * Handles the onChange event. + * + * react-phone-number-input might trigger the onChange event as undefined + * when a valid phone number is not entered. To prevent this, + * the value is coerced to an empty string. + * + * @param {E164Number | undefined} value - The entered value + */ + onChange={(value) => onChange?.(value || ("" as RPNInput.Value))} + {...props} + /> + ); + } + ); +PhoneInput.displayName = "PhoneInput"; + +const InputComponent = React.forwardRef< + HTMLInputElement, + React.ComponentProps<"input"> +>(({ className, ...props }, ref) => ( + <Input + className={cn("rounded-e-lg rounded-s-none", className)} + {...props} + ref={ref} + /> +)); +InputComponent.displayName = "InputComponent"; + +type CountryEntry = { label: string; value: RPNInput.Country | undefined }; + +type CountrySelectProps = { + disabled?: boolean; + value: RPNInput.Country; + options: CountryEntry[]; + onChange: (country: RPNInput.Country) => void; +}; + +const CountrySelect = ({ + disabled, + value: selectedCountry, + options: countryList, + onChange, +}: CountrySelectProps) => { + return ( + <Popover> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10" + disabled={disabled} + > + <FlagComponent + country={selectedCountry} + countryName={selectedCountry} + /> + <ChevronsUpDown + className={cn( + "-mr-2 size-4 opacity-50", + disabled ? "hidden" : "opacity-100" + )} + /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[300px] p-0"> + <Command> + <CommandInput placeholder="Search country..." /> + <CommandList> + <ScrollArea className="h-72"> + <CommandEmpty>No country found.</CommandEmpty> + <CommandGroup> + {countryList.map(({ value, label }) => + value ? ( + <CountrySelectOption + key={value} + country={value} + countryName={label} + selectedCountry={selectedCountry} + onChange={onChange} + /> + ) : null + )} + </CommandGroup> + </ScrollArea> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}; + +interface CountrySelectOptionProps extends RPNInput.FlagProps { + selectedCountry: RPNInput.Country; + onChange: (country: RPNInput.Country) => void; +} + +const CountrySelectOption = ({ + country, + countryName, + selectedCountry, + onChange, +}: CountrySelectOptionProps) => { + return ( + <CommandItem className="gap-2" onSelect={() => onChange(country)}> + <FlagComponent country={country} countryName={countryName} /> + <span className="flex-1 text-sm">{countryName}</span> + <span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span> + <CheckIcon + className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`} + /> + </CommandItem> + ); +}; + +const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => { + const Flag = flags[country]; + + return ( + <span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg]:size-full"> + {Flag && <Flag title={countryName} />} + </span> + ); +}; + +export { PhoneInput }; diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx index 12d81c4a..5b6774dc 100644 --- a/apps/web/src/components/ui/separator.tsx +++ b/apps/web/src/components/ui/separator.tsx @@ -1,9 +1,9 @@ -"use client" +"use client"; -import * as React from "react" -import * as SeparatorPrimitive from "@radix-ui/react-separator" +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Separator = React.forwardRef< React.ElementRef<typeof SeparatorPrimitive.Root>, @@ -25,7 +25,7 @@ const Separator = React.forwardRef< {...props} /> ) -) -Separator.displayName = SeparatorPrimitive.Root.displayName +); +Separator.displayName = SeparatorPrimitive.Root.displayName; -export { Separator } +export { Separator }; diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index 452f4d9f..549cf841 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -1,12 +1,12 @@ -"use client" +"use client"; -import { useTheme } from "next-themes" -import { Toaster as Sonner } from "sonner" +import { useTheme } from "next-themes"; +import { Toaster as Sonner } from "sonner"; -type ToasterProps = React.ComponentProps<typeof Sonner> +type ToasterProps = React.ComponentProps<typeof Sonner>; const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() + const { theme = "system" } = useTheme(); return ( <Sonner @@ -25,7 +25,7 @@ const Toaster = ({ ...props }: ToasterProps) => { }} {...props} /> - ) -} + ); +}; -export { Toaster } +export { Toaster }; diff --git a/apps/web/src/components/ui/sponsor-button.tsx b/apps/web/src/components/ui/sponsor-button.tsx index 79c708a2..a060e428 100644 --- a/apps/web/src/components/ui/sponsor-button.tsx +++ b/apps/web/src/components/ui/sponsor-button.tsx @@ -14,7 +14,6 @@ export function SponsorButton({ companyName, className = "", }: SponsorButtonProps) { - return ( <motion.a href={href} diff --git a/apps/web/src/components/ui/toaster.tsx b/apps/web/src/components/ui/toaster.tsx index 3eb8a720..baf623f0 100644 --- a/apps/web/src/components/ui/toaster.tsx +++ b/apps/web/src/components/ui/toaster.tsx @@ -15,20 +15,16 @@ export function Toaster() { return ( <ToastProvider> - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - <Toast key={id} {...props}> - <div className="grid gap-1"> - {title && <ToastTitle>{title}</ToastTitle>} - {description && ( - <ToastDescription>{description}</ToastDescription> - )} - </div> - {action} - <ToastClose /> - </Toast> - ); - })} + {toasts.map(({ id, title, description, action, ...props }) => ( + <Toast key={id} {...props}> + <div className="grid gap-1"> + {title && <ToastTitle>{title}</ToastTitle>} + {description && <ToastDescription>{description}</ToastDescription>} + </div> + {action} + <ToastClose /> + </Toast> + ))} <ToastViewport /> </ToastProvider> ); |
