mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: sound effects functionality with freesound's api
This commit is contained in:
@@ -4,7 +4,7 @@ import { TabBar } from "./tabbar";
|
||||
import { MediaView } from "./views/media";
|
||||
import { useMediaPanelStore, Tab } from "./store";
|
||||
import { TextView } from "./views/text";
|
||||
import { AudioView } from "./views/audio";
|
||||
import { SoundsView } from "./views/sounds";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SettingsView } from "./views/settings";
|
||||
|
||||
@@ -13,7 +13,7 @@ export function MediaPanel() {
|
||||
|
||||
const viewMap: Record<Tab, React.ReactNode> = {
|
||||
media: <MediaView />,
|
||||
audio: <AudioView />,
|
||||
sounds: <SoundsView />,
|
||||
text: <TextView />,
|
||||
stickers: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
@@ -15,7 +15,7 @@ import { create } from "zustand";
|
||||
|
||||
export type Tab =
|
||||
| "media"
|
||||
| "audio"
|
||||
| "sounds"
|
||||
| "text"
|
||||
| "stickers"
|
||||
| "effects"
|
||||
@@ -30,9 +30,9 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
||||
icon: VideoIcon,
|
||||
label: "Media",
|
||||
},
|
||||
audio: {
|
||||
sounds: {
|
||||
icon: MusicIcon,
|
||||
label: "Audio",
|
||||
label: "Sounds",
|
||||
},
|
||||
text: {
|
||||
icon: TypeIcon,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState } from "react";
|
||||
|
||||
export function AudioView() {
|
||||
const [search, setSearch] = useState("");
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2 p-4">
|
||||
<Input
|
||||
placeholder="Search songs and artists"
|
||||
className="bg-panel-accent"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PlayIcon, PauseIcon, HeartIcon, PlusIcon } from "lucide-react";
|
||||
import { useSoundsStore } from "@/stores/sounds-store";
|
||||
import { useSoundSearch } from "@/hooks/use-sound-search";
|
||||
import type { SoundEffect, SavedSound } from "@/types/sounds";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
PropertyGroup,
|
||||
PropertyItem,
|
||||
PropertyItemValue,
|
||||
} from "@/components/editor/properties-panel/property-item";
|
||||
|
||||
export function SoundsView() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs defaultValue="sound-effects" className="flex flex-col h-full">
|
||||
<div className="px-3 pt-4 pb-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
|
||||
<TabsTrigger value="songs">Songs</TabsTrigger>
|
||||
<TabsTrigger value="saved">Saved</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<TabsContent
|
||||
value="sound-effects"
|
||||
className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<SoundEffectsView />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="saved"
|
||||
className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<SavedSoundsView />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="songs"
|
||||
className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<SongsView />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SoundEffectsView() {
|
||||
const {
|
||||
topSoundEffects,
|
||||
isLoading,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
scrollPosition,
|
||||
setScrollPosition,
|
||||
loadSavedSounds,
|
||||
isSoundSaved,
|
||||
toggleSavedSound,
|
||||
} = useSoundsStore();
|
||||
const {
|
||||
results: searchResults,
|
||||
isLoading: isSearching,
|
||||
loadMore,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
} = useSoundSearch(searchQuery);
|
||||
|
||||
// Audio playback state
|
||||
const [playingId, setPlayingId] = useState<number | null>(null);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Scroll position persistence
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load saved sounds and restore scroll position when component mounts
|
||||
useEffect(() => {
|
||||
loadSavedSounds();
|
||||
|
||||
if (scrollAreaRef.current && scrollPosition > 0) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
scrollAreaRef.current?.scrollTo({ top: scrollPosition });
|
||||
}, 100); // Small delay to ensure content is rendered
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, []); // Only run on mount
|
||||
|
||||
// Track scroll position changes and handle infinite scroll
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
setScrollPosition(scrollTop);
|
||||
|
||||
// Trigger loadMore when scrolled to within 200px of bottom
|
||||
const nearBottom = scrollTop + clientHeight >= scrollHeight - 200;
|
||||
if (nearBottom && hasNextPage && !isLoadingMore && !isSearching) {
|
||||
loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
// Use your existing design, just swap the data source
|
||||
const displayedSounds = useMemo(() => {
|
||||
const sounds = searchQuery ? searchResults : topSoundEffects;
|
||||
return sounds;
|
||||
}, [searchQuery, searchResults, topSoundEffects]);
|
||||
|
||||
const playSound = (sound: SoundEffect) => {
|
||||
if (playingId === sound.id) {
|
||||
audioElement?.pause();
|
||||
setPlayingId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop previous sound
|
||||
audioElement?.pause();
|
||||
|
||||
if (sound.previewUrl) {
|
||||
const audio = new Audio(sound.previewUrl);
|
||||
audio.addEventListener("ended", () => {
|
||||
setPlayingId(null);
|
||||
});
|
||||
audio.addEventListener("error", (e) => {
|
||||
setPlayingId(null);
|
||||
});
|
||||
audio.play().catch((error) => {
|
||||
setPlayingId(null);
|
||||
});
|
||||
|
||||
setAudioElement(audio);
|
||||
setPlayingId(sound.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 mt-1 h-full">
|
||||
<Input
|
||||
placeholder="Search sound effects"
|
||||
className="bg-panel-accent"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
showClearIcon
|
||||
onClear={() => setSearchQuery("")}
|
||||
/>
|
||||
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<ScrollArea
|
||||
className="flex-1 h-full"
|
||||
ref={scrollAreaRef}
|
||||
onScrollCapture={handleScroll}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading && !searchQuery && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Loading sounds...
|
||||
</div>
|
||||
)}
|
||||
{isSearching && searchQuery && (
|
||||
<div className="text-muted-foreground text-sm">Searching...</div>
|
||||
)}
|
||||
{displayedSounds.map((sound) => (
|
||||
<AudioItem
|
||||
key={sound.id}
|
||||
sound={sound}
|
||||
isPlaying={playingId === sound.id}
|
||||
onPlay={() => playSound(sound)}
|
||||
isSaved={isSoundSaved(sound.id)}
|
||||
onToggleSaved={() => toggleSavedSound(sound)}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && !isSearching && displayedSounds.length === 0 && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{searchQuery ? "No sounds found" : "No sounds available"}
|
||||
</div>
|
||||
)}
|
||||
{isLoadingMore && (
|
||||
<div className="text-muted-foreground text-sm text-center py-4">
|
||||
Loading more sounds...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedSoundsView() {
|
||||
const {
|
||||
savedSounds,
|
||||
isLoadingSavedSounds,
|
||||
savedSoundsError,
|
||||
loadSavedSounds,
|
||||
isSoundSaved,
|
||||
toggleSavedSound,
|
||||
clearSavedSounds,
|
||||
} = useSoundsStore();
|
||||
|
||||
// Audio playback state
|
||||
const [playingId, setPlayingId] = useState<number | null>(null);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Clear confirmation dialog state
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
|
||||
// Load saved sounds when tab becomes active
|
||||
useEffect(() => {
|
||||
loadSavedSounds();
|
||||
}, [loadSavedSounds]);
|
||||
|
||||
const playSound = (sound: SavedSound) => {
|
||||
if (playingId === sound.id) {
|
||||
audioElement?.pause();
|
||||
setPlayingId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop previous sound
|
||||
audioElement?.pause();
|
||||
|
||||
if (sound.previewUrl) {
|
||||
const audio = new Audio(sound.previewUrl);
|
||||
audio.addEventListener("ended", () => {
|
||||
setPlayingId(null);
|
||||
});
|
||||
audio.addEventListener("error", (e) => {
|
||||
setPlayingId(null);
|
||||
});
|
||||
audio.play().catch((error) => {
|
||||
setPlayingId(null);
|
||||
});
|
||||
|
||||
setAudioElement(audio);
|
||||
setPlayingId(sound.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Convert SavedSound to SoundEffect for compatibility with AudioItem
|
||||
const convertToSoundEffect = (savedSound: SavedSound): SoundEffect => ({
|
||||
id: savedSound.id,
|
||||
name: savedSound.name,
|
||||
description: "",
|
||||
url: "",
|
||||
previewUrl: savedSound.previewUrl,
|
||||
downloadUrl: savedSound.downloadUrl,
|
||||
duration: savedSound.duration,
|
||||
filesize: 0,
|
||||
type: "audio",
|
||||
channels: 0,
|
||||
bitrate: 0,
|
||||
bitdepth: 0,
|
||||
samplerate: 0,
|
||||
username: savedSound.username,
|
||||
tags: savedSound.tags,
|
||||
license: savedSound.license,
|
||||
created: savedSound.savedAt,
|
||||
downloads: 0,
|
||||
rating: 0,
|
||||
ratingCount: 0,
|
||||
});
|
||||
|
||||
if (isLoadingSavedSounds) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Loading saved sounds...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (savedSoundsError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-destructive text-sm">
|
||||
Error: {savedSoundsError}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (savedSounds.length === 0) {
|
||||
return (
|
||||
<div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
|
||||
<HeartIcon
|
||||
className="w-10 h-10 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium">No saved sounds</p>
|
||||
<p className="text-sm text-muted-foreground text-balance">
|
||||
Click the heart icon on any sound to save it here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 mt-1 h-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{savedSounds.length} saved{" "}
|
||||
{savedSounds.length === 1 ? "sound" : "sounds"}
|
||||
</p>
|
||||
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto text-muted-foreground hover:text-destructive !opacity-100"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear all saved sounds?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently remove all {savedSounds.length} saved
|
||||
sounds from your collection. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="text" onClick={() => setShowClearDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
await clearSavedSounds();
|
||||
setShowClearDialog(false);
|
||||
}}
|
||||
>
|
||||
Clear all sounds
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
<div className="flex flex-col gap-4">
|
||||
{savedSounds.map((sound) => (
|
||||
<AudioItem
|
||||
key={sound.id}
|
||||
sound={convertToSoundEffect(sound)}
|
||||
isPlaying={playingId === sound.id}
|
||||
onPlay={() => playSound(sound)}
|
||||
isSaved={isSoundSaved(sound.id)}
|
||||
onToggleSaved={() =>
|
||||
toggleSavedSound(convertToSoundEffect(sound))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SongsView() {
|
||||
return <div>Songs</div>;
|
||||
}
|
||||
|
||||
interface AudioItemProps {
|
||||
sound: SoundEffect;
|
||||
isPlaying: boolean;
|
||||
onPlay: () => void;
|
||||
isSaved: boolean;
|
||||
onToggleSaved: () => void;
|
||||
}
|
||||
|
||||
function AudioItem({
|
||||
sound,
|
||||
isPlaying,
|
||||
onPlay,
|
||||
isSaved,
|
||||
onToggleSaved,
|
||||
}: AudioItemProps) {
|
||||
const { addSoundToTimeline } = useSoundsStore();
|
||||
|
||||
const handleClick = () => {
|
||||
onPlay();
|
||||
};
|
||||
|
||||
const handleSaveClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onToggleSaved();
|
||||
};
|
||||
|
||||
const handleAddToTimeline = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await addSoundToTimeline(sound);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex items-center gap-3 opacity-100 hover:opacity-75 transition-opacity cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="relative w-12 h-12 bg-accent rounded-md flex items-center justify-center overflow-hidden shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-transparent" />
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<PlayIcon className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<p className="font-medium truncate text-sm">{sound.name}</p>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{sound.username}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pr-2">
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground !opacity-100 w-auto"
|
||||
onClick={handleAddToTimeline}
|
||||
title="Add to timeline"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className={`hover:text-foreground !opacity-100 w-auto ${
|
||||
isSaved
|
||||
? "text-red-500 hover:text-red-600"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
onClick={handleSaveClick}
|
||||
title={isSaved ? "Remove from saved" : "Save sound"}
|
||||
>
|
||||
<HeartIcon className={`w-4 h-4 ${isSaved ? "fill-current" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea } from "../../ui/scroll-area";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "../../ui/button";
|
||||
import {
|
||||
Scissors,
|
||||
@@ -746,12 +746,7 @@ export function Timeline() {
|
||||
containerRef={tracksContainerRef}
|
||||
isActive={selectionBox?.isActive || false}
|
||||
/>
|
||||
<ScrollArea
|
||||
className="w-full h-full"
|
||||
ref={tracksScrollRef}
|
||||
type="scroll"
|
||||
showHorizontalScrollbar
|
||||
>
|
||||
<ScrollArea className="w-full h-full" ref={tracksScrollRef}>
|
||||
<div
|
||||
className="relative flex-1"
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useSoundsStore } from "@/stores/sounds-store";
|
||||
|
||||
export function useGlobalPrefetcher() {
|
||||
const {
|
||||
hasLoaded,
|
||||
setTopSoundEffects,
|
||||
setLoading,
|
||||
setError,
|
||||
setHasLoaded,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
} = useSoundsStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoaded) return;
|
||||
|
||||
let ignore = false;
|
||||
|
||||
const prefetchTopSounds = async () => {
|
||||
try {
|
||||
if (!ignore) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
"/api/sounds/search?page_size=50&sort=downloads"
|
||||
);
|
||||
|
||||
if (!ignore) {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTopSoundEffects(data.results);
|
||||
setHasLoaded(true);
|
||||
|
||||
// Set pagination state for top sounds
|
||||
setCurrentPage(1);
|
||||
setHasNextPage(!!data.next);
|
||||
setTotalCount(data.count);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!ignore) {
|
||||
console.error("Failed to prefetch top sounds:", error);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Failed to load sounds"
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(prefetchTopSounds, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
ignore = true;
|
||||
};
|
||||
}, [
|
||||
hasLoaded,
|
||||
setTopSoundEffects,
|
||||
setLoading,
|
||||
setError,
|
||||
setHasLoaded,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { Eye, EyeOff, X } from "lucide-react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
@@ -7,39 +7,91 @@ import { Button } from "./button";
|
||||
interface InputProps extends React.ComponentProps<"input"> {
|
||||
showPassword?: boolean;
|
||||
onShowPasswordChange?: (show: boolean) => void;
|
||||
showClearIcon?: boolean;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{ className, type, showPassword, onShowPasswordChange, value, ...props },
|
||||
{
|
||||
className,
|
||||
type,
|
||||
showPassword,
|
||||
onShowPasswordChange,
|
||||
showClearIcon,
|
||||
onClear,
|
||||
value,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
|
||||
const isPassword = type === "password";
|
||||
const showPasswordToggle = isPassword && onShowPasswordChange;
|
||||
const showClear =
|
||||
showClearIcon &&
|
||||
onClear &&
|
||||
value &&
|
||||
String(value).length > 0 &&
|
||||
isFocused;
|
||||
const inputType = isPassword && showPassword ? "text" : type;
|
||||
|
||||
const hasIcons = showPasswordToggle || showClear;
|
||||
const iconCount = Number(showPasswordToggle) + Number(showClear);
|
||||
const paddingRight =
|
||||
iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : "";
|
||||
|
||||
return (
|
||||
<div className={showPassword ? "relative w-full" : ""}>
|
||||
<div className={hasIcons ? "relative w-full" : ""}>
|
||||
<input
|
||||
type={inputType}
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
showPasswordToggle && "pr-10",
|
||||
paddingRight,
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
value={value}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{showClear && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="icon"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onClear?.();
|
||||
}}
|
||||
className="absolute right-0 top-0 h-full px-3 text-muted-foreground !opacity-100"
|
||||
aria-label="Clear input"
|
||||
>
|
||||
<X className="!size-[0.85]" />
|
||||
</Button>
|
||||
)}
|
||||
{showPasswordToggle && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => onShowPasswordChange?.(!showPassword)}
|
||||
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground"
|
||||
className={cn(
|
||||
"absolute top-0 h-full px-3 text-muted-foreground hover:text-foreground",
|
||||
showClear ? "right-10" : "right-0"
|
||||
)}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
|
||||
Reference in New Issue
Block a user