diff --git a/apps/web/app/(landing)/Hero.tsx b/apps/web/app/(landing)/Hero.tsx index 7568cf3a..6807b92d 100644 --- a/apps/web/app/(landing)/Hero.tsx +++ b/apps/web/app/(landing)/Hero.tsx @@ -4,6 +4,7 @@ import { motion } from "framer-motion"; import { Twitter } from "@repo/ui/components/icons"; import EmailInput from "./EmailInput"; import LinkArrow from "./linkArrow"; +import { TwitterBorder } from "./twitterLink"; const slap = { initial: { @@ -22,18 +23,7 @@ function Hero() { return ( <>
- - -
- {" "} - Follow us on Twitter{" "} - -
-
+ -
- - Supermemory logo +
+ + Supermemory logo + +
+ + Use cases -
- - Use cases - - - Features - - - Try supermemory - -
- - Login - + + Features + + + Try supermemory
- + + Login + + +
); } -export default Navbar; +export const Navbar = () => { + const { scrollYProgress } = useScroll(); + + const [visible, setVisible] = useState(true); + + useMotionValueEvent(scrollYProgress, "change", (current) => { + // Check if current is not undefined and is a number + if (typeof current === "number") { + let direction = current! - scrollYProgress.getPrevious()!; + if (direction < 0) { + setVisible(true); + } else { + setVisible(false); + } + } + }); + + return ( + + + + + + ); +}; diff --git a/apps/web/app/(landing)/linkArrow.tsx b/apps/web/app/(landing)/linkArrow.tsx index def37e91..a696ca25 100644 --- a/apps/web/app/(landing)/linkArrow.tsx +++ b/apps/web/app/(landing)/linkArrow.tsx @@ -1,9 +1,16 @@ import React from "react"; -function LinkArrow({ classname }: { classname?: string }) { +function LinkArrow({ + classname, + stroke, +}: { + classname?: string; + stroke?: string; +}) { return ( + + +
+ {" "} + Follow us on Twitter{" "} + +
+ + + ); +} + +type Direction = "TOP" | "LEFT" | "BOTTOM" | "RIGHT"; + +export function HoverBorderGradient({ + children, + containerClassName, + className, + as: Tag = "button", + duration = 1, + clockwise = true, + ...props +}: React.PropsWithChildren< + { + as?: React.ElementType; + containerClassName?: string; + className?: string; + duration?: number; + clockwise?: boolean; + } & React.HTMLAttributes +>) { + const [hovered, setHovered] = useState(false); + const [direction, setDirection] = useState("TOP"); + + const rotateDirection = (currentDirection: Direction): Direction => { + const directions: Direction[] = ["TOP", "LEFT", "BOTTOM", "RIGHT"]; + const currentIndex = directions.indexOf(currentDirection); + const nextIndex = clockwise + ? (currentIndex - 1 + directions.length) % directions.length + : (currentIndex + 1) % directions.length; + return directions[nextIndex]; + }; + + const movingMap: Record = { + TOP: "radial-gradient(20.7% 50% at 50% 0%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)", + LEFT: "radial-gradient(16.6% 43.1% at 0% 50%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)", + BOTTOM: + "radial-gradient(20.7% 50% at 50% 100%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)", + RIGHT: + "radial-gradient(16.2% 41.199999999999996% at 100% 50%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)", + }; + + const highlight = + "radial-gradient(75% 181.15942028985506% at 50% 50%, #3275F8 0%, rgba(255, 255, 255, 0) 100%)"; + useEffect(() => { + if (!hovered) { + const interval = setInterval(() => { + setDirection((prevState) => rotateDirection(prevState)); + }, duration * 1000); + return () => clearInterval(interval); + } + }, [hovered]); + return ( + ) => { + setHovered(true); + }} + onMouseLeave={() => setHovered(false)} + className={cn( + "relative h-min w-fit transition duration-500 group/anchor flex items-center justify-center gap-4 rounded-full text-white/80 bg-white/5 text-sm", + containerClassName, + )} + {...props} + > +
+ {children} +
+ +
+ + ); +} diff --git a/apps/web/app/chat/chatWindow.tsx b/apps/web/app/chat/chatWindow.tsx new file mode 100644 index 00000000..8361bbf8 --- /dev/null +++ b/apps/web/app/chat/chatWindow.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { AnimatePresence } from "framer-motion"; +import React, { useEffect, useState } from "react"; +import QueryInput from "../home/queryinput"; +import { cn } from "@repo/ui/lib/utils"; +import { motion } from "framer-motion"; +import { useRouter } from "next/navigation"; + +function ChatWindow({ q, spaces }: { q: string; spaces: number[] }) { + const [layout, setLayout] = useState<"chat" | "initial">("initial"); + + const router = useRouter(); + + useEffect(() => { + if (q !== "") { + setTimeout(() => { + setLayout("chat"); + }, 300); + } else { + router.push("/home"); + } + }, [q]); + return ( +
+ + {layout === "initial" ? ( + +
+ +
+
+ ) : ( +
+

+ {q} +

+
+ )} +
+
+ ); +} + +export default ChatWindow; diff --git a/package.json b/package.json index 61bc50fa..ec99cea0 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@cloudflare/next-on-pages": "1", "@cloudflare/workers-types": "^4.20240512.0", "@repo/eslint-config": "*", + "@repo/shared-types": "*", "@repo/tailwind-config": "*", "@repo/typescript-config": "*", "@repo/ui": "*", @@ -50,6 +51,7 @@ "@hookform/resolvers": "^3.4.2", "@iarna/toml": "^2.2.5", "@langchain/cloudflare": "^0.0.6", + "@million/lint": "^1.0.0-rc.18", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-progress": "^1.0.3", diff --git a/packages/tailwind-config/globals.css b/packages/tailwind-config/globals.css index 24120e68..18017f73 100644 --- a/packages/tailwind-config/globals.css +++ b/packages/tailwind-config/globals.css @@ -64,3 +64,23 @@ body { rgba(255, 255, 255, 0) ); } + +/* width */ +::-webkit-scrollbar { + width: 8px; +} + +/* Track */ +::-webkit-scrollbar-track { + background: transparent; +} + +/* Handle */ +::-webkit-scrollbar-thumb { + background: #131f2c; +} + +/* Handle on hover */ +::-webkit-scrollbar-thumb:hover { + background: #22303d; +} diff --git a/packages/ui/components/cardClick.tsx b/packages/ui/components/cardClick.tsx new file mode 100644 index 00000000..8543cb4c --- /dev/null +++ b/packages/ui/components/cardClick.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { cn } from "@repo/ui/lib/utils"; +import { AnimatePresence, motion } from "framer-motion"; +import React from "react"; + +export const CardClick = ({ + tab, + handleClickIndex, + items, +}: { + tab: number; + handleClickIndex: (tab: number) => void; + items: { + title: string; + description: string; + svg: React.ReactNode; + }[]; +}) => { + return ( +
+ {items.map((item, idx) => ( +
handleClickIndex(idx)} + > + + {tab === idx && ( + + )} + + +
+ ))} +
+ ); +}; + +export const Card = ({ + title, + description, + svg, +}: { + title: string; + description: string; + svg: React.ReactNode; +}) => { + return ( +
+ {svg} +
+
+ {title} +
+
{description}
+
+
+ ); +}; diff --git a/packages/ui/components/icons.tsx b/packages/ui/components/icons.tsx new file mode 100644 index 00000000..d72c8e1f --- /dev/null +++ b/packages/ui/components/icons.tsx @@ -0,0 +1,304 @@ +import type { SVGProps } from "react"; +export const Github = (props: SVGProps) => ( + + + +); + +export const Twitter = (props: SVGProps) => ( + + + +); + +export const Medium = (props: SVGProps) => ( + + + +); + +export const Reddit = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const Notion = (props: SVGProps) => ( + + + + +); + +export const X = (props: SVGProps) => ( + + + +); + +export const Google = (props: SVGProps) => ( + + + + + + +); diff --git a/packages/ui/hooks/use-callback-ref.ts b/packages/ui/hooks/use-callback-ref.ts new file mode 100644 index 00000000..e3133618 --- /dev/null +++ b/packages/ui/hooks/use-callback-ref.ts @@ -0,0 +1,24 @@ +/** + * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx + */ + +import { useEffect, useMemo, useRef } from "react"; + +/** + * A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a + * prop or avoid re-executing effects when passed as a dependency + */ +function useCallbackRef unknown>( + callback: T | undefined, +): T { + const callbackRef = useRef(callback); + + useEffect(() => { + callbackRef.current = callback; + }); + + // https://github.com/facebook/react/issues/19240 + return useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []); +} + +export { useCallbackRef }; diff --git a/packages/ui/hooks/use-controllable-state.ts b/packages/ui/hooks/use-controllable-state.ts new file mode 100644 index 00000000..7cf20f89 --- /dev/null +++ b/packages/ui/hooks/use-controllable-state.ts @@ -0,0 +1,67 @@ +import * as React from "react"; + +import { useCallbackRef } from "@repo/ui/src/hooks/use-callback-ref.js"; + +/** + * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx + */ + +type UseControllableStateParams = { + prop?: T | undefined; + defaultProp?: T | undefined; + onChange?: (state: T) => void; +}; + +type SetStateFn = (prevState?: T) => T; + +function useControllableState({ + prop, + defaultProp, + onChange = () => {}, +}: UseControllableStateParams) { + const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({ + defaultProp, + onChange, + }); + const isControlled = prop !== undefined; + const value = isControlled ? prop : uncontrolledProp; + const handleChange = useCallbackRef(onChange); + + const setValue: React.Dispatch> = + React.useCallback( + (nextValue) => { + if (isControlled) { + const setter = nextValue as SetStateFn; + const value = + typeof nextValue === "function" ? setter(prop) : nextValue; + if (value !== prop) handleChange(value as T); + } else { + setUncontrolledProp(nextValue); + } + }, + [isControlled, prop, setUncontrolledProp, handleChange], + ); + + return [value, setValue] as const; +} + +function useUncontrolledState({ + defaultProp, + onChange, +}: Omit, "prop">) { + const uncontrolledState = React.useState(defaultProp); + const [value] = uncontrolledState; + const prevValueRef = React.useRef(value); + const handleChange = useCallbackRef(onChange); + + React.useEffect(() => { + if (prevValueRef.current !== value) { + handleChange(value as T); + prevValueRef.current = value; + } + }, [value, prevValueRef, handleChange]); + + return uncontrolledState; +} + +export { useControllableState }; diff --git a/packages/ui/icons/add.svg b/packages/ui/icons/add.svg new file mode 100644 index 00000000..1c6d87f6 --- /dev/null +++ b/packages/ui/icons/add.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/icons/arrowright.svg b/packages/ui/icons/arrowright.svg new file mode 100644 index 00000000..d794b094 --- /dev/null +++ b/packages/ui/icons/arrowright.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/icons/chat.svg b/packages/ui/icons/chat.svg new file mode 100644 index 00000000..7fe96c16 --- /dev/null +++ b/packages/ui/icons/chat.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/icons/explore.svg b/packages/ui/icons/explore.svg new file mode 100644 index 00000000..d6332612 --- /dev/null +++ b/packages/ui/icons/explore.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/icons/history.svg b/packages/ui/icons/history.svg new file mode 100644 index 00000000..7766c75d --- /dev/null +++ b/packages/ui/icons/history.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/icons/index.ts b/packages/ui/icons/index.ts new file mode 100644 index 00000000..6c528aba --- /dev/null +++ b/packages/ui/icons/index.ts @@ -0,0 +1,17 @@ +import AddIcon from "./add.svg"; +import ChatIcon from "./chat.svg"; +import HistoryIcon from "./history.svg"; +import ExploreIcon from "./explore.svg"; +import MemoriesIcon from "./memories.svg"; +import ArrowRightIcon from "./arrowRight.svg"; +import SelectIcon from "./select.svg"; + +export { + AddIcon, + ChatIcon, + HistoryIcon, + ExploreIcon, + MemoriesIcon, + ArrowRightIcon, + SelectIcon, +}; diff --git a/packages/ui/icons/memories.svg b/packages/ui/icons/memories.svg new file mode 100644 index 00000000..27784aac --- /dev/null +++ b/packages/ui/icons/memories.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/icons/select.svg b/packages/ui/icons/select.svg new file mode 100644 index 00000000..0b019c01 --- /dev/null +++ b/packages/ui/icons/select.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/ui/shadcn/badge.tsx b/packages/ui/shadcn/badge.tsx new file mode 100644 index 00000000..c6a128aa --- /dev/null +++ b/packages/ui/shadcn/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@repo/ui/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/packages/ui/shadcn/button.tsx b/packages/ui/shadcn/button.tsx new file mode 100644 index 00000000..90c4fa46 --- /dev/null +++ b/packages/ui/shadcn/button.tsx @@ -0,0 +1,56 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@repo/ui/lib/utils"; +import React from "react"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/packages/ui/shadcn/card.tsx b/packages/ui/shadcn/card.tsx new file mode 100644 index 00000000..4e0d9363 --- /dev/null +++ b/packages/ui/shadcn/card.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { cn } from "@repo/ui/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/packages/ui/shadcn/combobox.tsx b/packages/ui/shadcn/combobox.tsx new file mode 100644 index 00000000..1a2674ff --- /dev/null +++ b/packages/ui/shadcn/combobox.tsx @@ -0,0 +1,528 @@ +"use client"; + +import * as React from "react"; +import { forwardRef, useEffect } from "react"; +import { Command as CommandPrimitive, useCommandState } from "cmdk"; +import { X } from "lucide-react"; + +import { cn } from "../lib/utils"; +import { Badge } from "./badge"; +import { Command, CommandGroup, CommandItem, CommandList } from "./command"; + +export interface Option { + value: string; + label: string; + disable?: boolean; + /** fixed option that can't be removed. */ + fixed?: boolean; + /** Group the options by providing key. */ + [key: string]: string | boolean | undefined; +} +interface GroupOption { + [key: string]: Option[]; +} + +interface MultipleSelectorProps { + value?: Option[]; + defaultOptions?: Option[]; + /** manually controlled options */ + options?: Option[]; + placeholder?: string; + /** Loading component. */ + loadingIndicator?: React.ReactNode; + /** Empty component. */ + emptyIndicator?: React.ReactNode; + /** Debounce time for async search. Only work with `onSearch`. */ + delay?: number; + /** + * Only work with `onSearch` prop. Trigger search when `onFocus`. + * For example, when user click on the input, it will trigger the search to get initial options. + **/ + triggerSearchOnFocus?: boolean; + /** async search */ + onSearch?: (value: string) => Promise; + onChange?: (options: Option[]) => void; + /** Limit the maximum number of selected options. */ + maxSelected?: number; + /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ + onMaxSelected?: (maxLimit: number) => void; + /** Hide the placeholder when there are options selected. */ + hidePlaceholderWhenSelected?: boolean; + disabled?: boolean; + /** Group the options base on provided key. */ + groupBy?: string; + className?: string; + badgeClassName?: string; + /** + * First item selected is a default behavior by cmdk. That is why the default is true. + * This is a workaround solution by add a dummy item. + * + * @reference: https://github.com/pacocoursey/cmdk/issues/171 + */ + selectFirstItem?: boolean; + /** Allow user to create option when there is no option matched. */ + creatable?: boolean; + /** Props of `Command` */ + commandProps?: React.ComponentPropsWithoutRef; + /** Props of `CommandInput` */ + inputProps?: Omit< + React.ComponentPropsWithoutRef, + "value" | "placeholder" | "disabled" + >; +} + +export interface MultipleSelectorRef { + selectedValue: Option[]; + input: HTMLInputElement; +} + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +function transToGroupOption(options: Option[], groupBy?: string) { + if (options.length === 0) { + return {}; + } + if (!groupBy) { + return { + "": options, + }; + } + + const groupOption: GroupOption = {}; + options.forEach((option) => { + const key = (option[groupBy] as string) || ""; + if (!groupOption[key]) { + groupOption[key] = []; + } + groupOption[key].push(option); + }); + return groupOption; +} + +function removePickedOption(groupOption: GroupOption, picked: Option[]) { + const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption; + + for (const [key, value] of Object.entries(cloneOption)) { + cloneOption[key] = value.filter( + (val) => !picked.find((p) => p.value === val.value), + ); + } + return cloneOption; +} + +function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) { + for (const [key, value] of Object.entries(groupOption)) { + if ( + value.some((option) => targetOption.find((p) => p.value === option.value)) + ) { + return true; + } + } + return false; +} + +/** + * The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly. + * So we create one and copy the `Empty` implementation from `cmdk`. + * + * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 + **/ +const CommandEmpty = forwardRef< + HTMLDivElement, + React.ComponentProps +>(({ className, ...props }, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0); + + if (!render) return null; + + return ( +
+ ); +}); + +CommandEmpty.displayName = "CommandEmpty"; + +const MultipleSelector = React.forwardRef< + MultipleSelectorRef, + MultipleSelectorProps +>( + ( + { + value, + onChange, + placeholder, + defaultOptions: arrayDefaultOptions = [], + options: arrayOptions, + delay, + onSearch, + loadingIndicator, + emptyIndicator, + maxSelected = Number.MAX_SAFE_INTEGER, + onMaxSelected, + hidePlaceholderWhenSelected, + disabled, + groupBy, + className, + badgeClassName, + selectFirstItem = true, + creatable = false, + triggerSearchOnFocus = false, + commandProps, + inputProps, + }: MultipleSelectorProps, + ref: React.Ref, + ) => { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + const [selected, setSelected] = React.useState(value || []); + const [options, setOptions] = React.useState( + transToGroupOption(arrayDefaultOptions, groupBy), + ); + const [inputValue, setInputValue] = React.useState(""); + const debouncedSearchTerm = useDebounce(inputValue, delay || 500); + + React.useImperativeHandle( + ref, + () => ({ + selectedValue: [...selected], + input: inputRef.current as HTMLInputElement, + focus: () => inputRef.current?.focus(), + }), + [selected], + ); + + const handleUnselect = React.useCallback( + (option: Option) => { + const newOptions = selected.filter((s) => s.value !== option.value); + setSelected(newOptions); + onChange?.(newOptions); + }, + [onChange, selected], + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + const input = inputRef.current; + if (input) { + if (e.key === "Delete" || e.key === "Backspace") { + if (input.value === "" && selected.length > 0) { + handleUnselect(selected[selected.length - 1]!); + } + } + // This is not a default behavior of the field + if (e.key === "Escape") { + input.blur(); + } + } + }, + [handleUnselect, selected], + ); + + useEffect(() => { + if (value) { + setSelected(value); + } + }, [value]); + + useEffect(() => { + /** If `onSearch` is provided, do not trigger options updated. */ + if (!arrayOptions || onSearch) { + return; + } + const newOption = transToGroupOption(arrayOptions || [], groupBy); + if (JSON.stringify(newOption) !== JSON.stringify(options)) { + setOptions(newOption); + } + }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); + + useEffect(() => { + const doSearch = async () => { + setIsLoading(true); + const res = await onSearch?.(debouncedSearchTerm); + setOptions(transToGroupOption(res || [], groupBy)); + setIsLoading(false); + }; + + const exec = async () => { + if (!onSearch || !open) return; + + if (triggerSearchOnFocus) { + await doSearch(); + } + + if (debouncedSearchTerm) { + await doSearch(); + } + }; + + void exec(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); + + const CreatableItem = () => { + if (!creatable) return undefined; + if ( + isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || + selected.find((s) => s.value === inputValue) + ) { + return undefined; + } + + const Item = ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value: string) => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, { value, label: value }]; + setSelected(newOptions); + onChange?.(newOptions); + }} + > + {`Create "${inputValue}"`} + + ); + + // For normal creatable + if (!onSearch && inputValue.length > 0) { + return Item; + } + + // For async search creatable. avoid showing creatable item before loading at first. + if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { + return Item; + } + + return undefined; + }; + + const EmptyItem = React.useCallback(() => { + if (!emptyIndicator) return undefined; + + // For async search that showing emptyIndicator + if (onSearch && !creatable && Object.keys(options).length === 0) { + return ( + + {emptyIndicator} + + ); + } + + return {emptyIndicator}; + }, [creatable, emptyIndicator, onSearch, options]); + + const selectables = React.useMemo( + () => removePickedOption(options, selected), + [options, selected], + ); + + /** Avoid Creatable Selector freezing or lagging when paste a long string. */ + const commandFilter = React.useCallback(() => { + if (commandProps?.filter) { + return commandProps.filter; + } + + if (creatable) { + return (value: string, search: string) => { + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; + }; + } + // Using default filter in `cmdk`. We don't have to provide it. + return undefined; + }, [creatable, commandProps?.filter]); + + return ( + { + handleKeyDown(e); + commandProps?.onKeyDown?.(e); + }} + className={cn( + "h-auto overflow-visible bg-transparent z-10", + commandProps?.className, + )} + shouldFilter={ + commandProps?.shouldFilter !== undefined + ? commandProps.shouldFilter + : !onSearch + } // When onSearch is provided, we don't want to filter the options. You can still override it. + filter={commandFilter()} + > +
{ + if (disabled) return; + inputRef.current?.focus(); + }} + > +
+ {selected.map((option) => { + return ( + + {option.label} + + + ); + })} + {/* Avoid having the "Search" Icon */} + { + setInputValue(value); + inputProps?.onValueChange?.(value); + }} + onBlur={(event) => { + setOpen(false); + inputProps?.onBlur?.(event); + }} + onFocus={(event) => { + setOpen(true); + triggerSearchOnFocus && onSearch?.(debouncedSearchTerm); + inputProps?.onFocus?.(event); + }} + placeholder={ + hidePlaceholderWhenSelected && selected.length !== 0 + ? "" + : placeholder + } + className={cn( + "flex-1 bg-transparent outline-none placeholder:text-muted-foreground", + { + "w-full": hidePlaceholderWhenSelected, + "px-3 py-2": selected.length === 0, + "ml-1": selected.length !== 0, + }, + inputProps?.className, + )} + /> +
+
+
+ {open && ( + + {isLoading ? ( + <>{loadingIndicator} + ) : ( + <> + {EmptyItem()} + {CreatableItem()} + {!selectFirstItem && ( + + )} + {Object.entries(selectables).map(([key, dropdowns]) => ( + + <> + {dropdowns.map((option) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={() => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, option]; + setSelected(newOptions); + onChange?.(newOptions); + }} + className={cn( + "cursor-pointer", + option.disable && + "cursor-default text-muted-foreground", + )} + > + {option.label} + + ); + })} + + + ))} + + )} + + )} +
+
+ ); + }, +); + +MultipleSelector.displayName = "MultipleSelector"; +export { MultipleSelector }; diff --git a/packages/ui/shadcn/command.tsx b/packages/ui/shadcn/command.tsx new file mode 100644 index 00000000..f81763f2 --- /dev/null +++ b/packages/ui/shadcn/command.tsx @@ -0,0 +1,155 @@ +"use client"; + +import * as React from "react"; +import { type DialogProps } from "@radix-ui/react-dialog"; +import { Command as CommandPrimitive } from "cmdk"; +import { Search } from "lucide-react"; + +import { cn } from "@repo/ui/lib/utils"; +import { Dialog, DialogContent } from "@repo/ui/shadcn/dialog"; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/packages/ui/shadcn/dialog.tsx b/packages/ui/shadcn/dialog.tsx new file mode 100644 index 00000000..f73becfe --- /dev/null +++ b/packages/ui/shadcn/dialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@repo/ui/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/packages/ui/shadcn/divider.tsx b/packages/ui/shadcn/divider.tsx new file mode 100644 index 00000000..34439866 --- /dev/null +++ b/packages/ui/shadcn/divider.tsx @@ -0,0 +1,7 @@ +import { cn } from "@repo/ui/lib/utils"; + +function Divider({ className }: { className?: string }) { + return
; +} + +export default Divider; diff --git a/packages/ui/shadcn/form.tsx b/packages/ui/shadcn/form.tsx new file mode 100644 index 00000000..9de2d23e --- /dev/null +++ b/packages/ui/shadcn/form.tsx @@ -0,0 +1,177 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "@repo/ui/lib/utils"; +import { Label } from "@repo/ui/shadcn/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +