diff --git a/package-lock.json b/package-lock.json index a8b781f..0ab6948 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "react-i18next": "^17.0.8", + "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", "three": "^0.184.0", "tw-animate-css": "^1.3.6", @@ -9629,6 +9630,22 @@ "seroval-plugins": "~1.5.0" } }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", diff --git a/package.json b/package.json index a866691..6e5efdc 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "react-i18next": "^17.0.8", + "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", "three": "^0.184.0", "tw-animate-css": "^1.3.6", diff --git a/src/app/components/cad/editor/index.tsx b/src/app/components/cad/editor/index.tsx index ecb8e90..2620733 100644 --- a/src/app/components/cad/editor/index.tsx +++ b/src/app/components/cad/editor/index.tsx @@ -2,14 +2,14 @@ import { useQuery } from '@customcads/react-sdk'; import { AppError } from '@/types/errors'; import { getCadType } from '@/lib/cad'; import { useEditorStore } from '@/app/hooks/stores/useEditorStore'; -import { useCadBlobUrl } from '@/app/hooks/features/cads/useCadBlobUrl'; +import { useFetchCad } from '@/app/hooks/features/cads/useFetchCad'; import { useTextures } from '@/app/hooks/features/materials/useTextures'; import Loader from '@/app/components/loading'; import EditorThreeJS from './threejs'; type Props = { cadId: string }; const EditorCad = ({ cadId }: Props) => { - const { blobUrl: cadBlobUrl, progress } = useCadBlobUrl(cadId, 'Product'); + const { blobUrl, progress } = useFetchCad(cadId, 'Product'); const { data: cad } = useQuery(({ cads }) => cads.single({ id: cadId })); const materialId = useEditorStore(cadId, (state) => state.materialId); @@ -23,7 +23,7 @@ const EditorCad = ({ cadId }: Props) => { tip: 'Reload this page or clear your browser cache.', }); - if (!cad || !cadBlobUrl || !texture) { + if (!cad || !blobUrl || !texture) { return ; } @@ -33,7 +33,7 @@ const EditorCad = ({ cadId }: Props) => { texture={texture} cad={{ id: cadId, - blobUrl: cadBlobUrl, + blobUrl: blobUrl, type: getCadType(cad.contentType as never), coords: { cam: cad.camCoordinates, diff --git a/src/app/components/cad/gallery/index.tsx b/src/app/components/cad/gallery/index.tsx index 9324bb9..b359f99 100644 --- a/src/app/components/cad/gallery/index.tsx +++ b/src/app/components/cad/gallery/index.tsx @@ -1,12 +1,12 @@ import { useQuery } from '@customcads/react-sdk'; import { getCadType } from '@/lib/cad'; -import { useCadBlobUrl } from '@/app/hooks/features/cads/useCadBlobUrl'; +import { useFetchCad } from '@/app/hooks/features/cads/useFetchCad'; import Loader from '@/app/components/loading'; import GalleryThreeJS from './threejs'; const GalleryCad = ({ cadId }: { cadId: string }) => { const { data: cad } = useQuery(({ cads }) => cads.single({ id: cadId })); - const { blobUrl, progress } = useCadBlobUrl(cadId, 'Product'); + const { blobUrl, progress } = useFetchCad(cadId, 'Product'); return (
diff --git a/src/app/components/image.tsx b/src/app/components/image.tsx new file mode 100644 index 0000000..9a92f42 --- /dev/null +++ b/src/app/components/image.tsx @@ -0,0 +1,24 @@ +import { DownloadRequest, useQuery } from '@customcads/react-sdk'; + +type Props = { + request: DownloadRequest; + enabled?: boolean; +} & React.ComponentProps<'img'>; +const PresignedImage = ({ request, enabled, ...props }: Props) => { + const { data: image, refetch } = useQuery(({ images }) => { + const { queryKey, queryFn } = images.download(request); + return { + queryKey, + queryFn, + enabled, + staleTime: 1000 * 20, + gcTime: 1000 * 60, + }; + }); + + return ( + refetch()} {...props} /> + ); +}; + +export default PresignedImage; diff --git a/src/app/components/layout/header/notifications/index.tsx b/src/app/components/layout/header/notifications/index.tsx index 32626bc..e544aee 100644 --- a/src/app/components/layout/header/notifications/index.tsx +++ b/src/app/components/layout/header/notifications/index.tsx @@ -1,13 +1,12 @@ import { Bell } from 'lucide-react'; import { useInfiniteQuery } from '@customcads/react-sdk'; -import { useNotificationRealTime } from '@/app/hooks/features/notifications/useNotificationRealTime'; +import { useLiveNotifications } from '@/app/hooks/features/notifications'; import { useAuthStore } from '@/app/hooks/stores/useAuthStore'; import { popover } from '@/app/components/ui'; import CustomIcon from '@/app/components/icon'; import Scroll from './scroll'; const ALL_PARAMS = { page: 1, limit: 10 }; -const bell = ; const NotificationsTab = () => { const { is } = useAuthStore(); @@ -15,15 +14,17 @@ const NotificationsTab = () => { ({ notifications }) => notifications.all(ALL_PARAMS), !is.guest, ); - useNotificationRealTime({ allParams: ALL_PARAMS }); + useLiveNotifications(ALL_PARAMS); if (is.guest) return; - if (!query.data) return bell; + if (!query.data) return ; const { pages } = query.data; return ( - {bell} + + +
{
{children} +
diff --git a/src/app/components/search/categories/index.tsx b/src/app/components/search/categories/index.tsx index 79050f1..c628e95 100644 --- a/src/app/components/search/categories/index.tsx +++ b/src/app/components/search/categories/index.tsx @@ -35,10 +35,9 @@ const Categories = ({ getCategory, updateCategory }: Props) => { } }; - const iconClass = 'cursor-pointer w-5 h-5 md:w-5 md:h-5'; return (
- + ({ @@ -47,12 +46,15 @@ const Categories = ({ getCategory, updateCategory }: Props) => { }))} onSelect={handleSelect} > - + {category} {category !== initial && ( - handleSelect()} className={iconClass} /> + handleSelect()} + className='cursor-pointer w-5 h-5 md:w-5 md:h-5' + /> )}
); diff --git a/src/app/components/search/pagination/index.tsx b/src/app/components/search/pagination/index.tsx index 4e8f290..8d47b40 100644 --- a/src/app/components/search/pagination/index.tsx +++ b/src/app/components/search/pagination/index.tsx @@ -18,7 +18,7 @@ const Pagination = ({ total, defaultPagination, navigate }: Props) => { return ( -
+
{ onChange={handleChange.page} />
-
+
{ return ( onChange(Number(val))}> - + {limit} diff --git a/src/app/components/search/searchbar/index.tsx b/src/app/components/search/searchbar/index.tsx index c885a0f..b557781 100644 --- a/src/app/components/search/searchbar/index.tsx +++ b/src/app/components/search/searchbar/index.tsx @@ -1,43 +1,46 @@ import { useState } from 'react'; -import { Search, X } from 'lucide-react'; -import { Input } from '@/app/components/ui'; +import { Search } from 'lucide-react'; +import { Children } from '@/types/react'; +import { inputGroup } from '@/app/components/ui'; type Props = { + preview?: (search: string) => Children['children']; placeholder: string; getSearch: () => string | undefined; updateSearch: (searchTerm: string | undefined) => void; }; - -const Searchbar = ({ placeholder, getSearch, updateSearch }: Props) => { +const Searchbar = ({ + preview, + placeholder, + getSearch, + updateSearch, +}: Props) => { const [search, setSearch] = useState(getSearch()); return ( -
- {search && ( - { - setSearch(undefined); - updateSearch(undefined); - }} - className='cursor-pointer' - /> - )} - + setSearch(target.value)} - onBlur={() => updateSearch(search)} onKeyDown={({ key }) => key === 'Enter' && updateSearch(search)} - className='bg-secondary border-2 rounded-xl min-h-11 md:min-h-14 md:min-w-75 md:px-6 text-ellipsis text-xs md:text-lg' + className='min-w-50 sm:min-w-60 md:min-w-75 lg:min-w-100 xl:min-w-120 text-ellipsis text-xs md:text-lg' autoComplete='off' /> - updateSearch(search)} - className='cursor-pointer' - /> -
+ {preview && search && search !== getSearch() && ( +
+ {preview(search)} +
+ )} + + updateSearch(search)} + className='cursor-pointer size-4 lg:size-5' + /> + + ); }; diff --git a/src/app/components/search/sortings/index.tsx b/src/app/components/search/sortings/index.tsx index 181fc68..55e11d7 100644 --- a/src/app/components/search/sortings/index.tsx +++ b/src/app/components/search/sortings/index.tsx @@ -42,11 +42,14 @@ const Sortings = ({ getSorting, updateSorting, sortings }: Props) => { } }; + if (!sortings) return; + const handleSelect = (name?: string) => { setSorting(() => name ?? initial); updateSorting({ type: name, direction }); }; - if (!sortings) return; + const format = (sorting: string) => + sorting.replace(/([a-z])([A-Z])/g, '$1 $2'); const DirectionArrow = direction === 'ascending' ? ArrowUp : ArrowDown; @@ -55,18 +58,18 @@ const Sortings = ({ getSorting, updateSorting, sortings }: Props) => { ({ - value: x, - label: x, - }))} + options={sortings.map((x) => ({ value: x, label: format(x) }))} onSelect={handleSelect} > - - {sorting} + + {format(sorting)} {sorting !== initial && ( - + )}
); diff --git a/src/app/components/ui/badge.tsx b/src/app/components/ui/badge.tsx new file mode 100644 index 0000000..0359abd --- /dev/null +++ b/src/app/components/ui/badge.tsx @@ -0,0 +1,47 @@ +import { Slot } from 'radix-ui'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + secondary: + 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', + destructive: + 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20', + outline: + 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', + ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +const Badge = ({ + className, + variant = 'default', + asChild = false, + ...props +}: React.ComponentProps<'span'> & + VariantProps & { asChild?: boolean }) => { + const Comp = asChild ? Slot.Root : 'span'; + + return ( + + ); +}; + +export { Badge, badgeVariants }; diff --git a/src/app/components/ui/button.tsx b/src/app/components/ui/button.tsx index 3c00c11..1fc3917 100644 --- a/src/app/components/ui/button.tsx +++ b/src/app/components/ui/button.tsx @@ -8,13 +8,13 @@ const buttonVariants = cva( variants: { variant: { default: - 'bg-primary text-primary-foreground hover:bg-primary/90', + 'bg-primary text-primary-foreground hover:bg-primary/90 select-none', destructive: - 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 select-none', outline: - 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-border dark:hover:bg-input/50', + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-border dark:hover:bg-input/50 select-none', secondary: - 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + 'bg-secondary text-secondary-foreground hover:bg-secondary/80 select-none', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, diff --git a/src/app/components/ui/carousel.tsx b/src/app/components/ui/carousel.tsx index d2a952c..3aa31b4 100644 --- a/src/app/components/ui/carousel.tsx +++ b/src/app/components/ui/carousel.tsx @@ -192,7 +192,7 @@ const CarouselPrevious = ({ variant={variant} size={size} className={cn( - 'absolute size-8 rounded-full', + 'absolute size-8 rounded-full cursor-pointer', orientation === 'horizontal' ? 'top-1/2 -left-12 -translate-y-1/2' : '-top-12 left-1/2 -translate-x-1/2 rotate-90', @@ -222,7 +222,7 @@ const CarouselNext = ({ variant={variant} size={size} className={cn( - 'absolute size-8 rounded-full', + 'absolute size-8 rounded-full cursor-pointer', orientation === 'horizontal' ? 'top-1/2 -right-12 -translate-y-1/2' : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90', diff --git a/src/app/components/ui/command.tsx b/src/app/components/ui/command.tsx index 78ec6eb..a2f8363 100644 --- a/src/app/components/ui/command.tsx +++ b/src/app/components/ui/command.tsx @@ -122,7 +122,7 @@ const CommandItem = ({ ) => { + return ( +
+ ); +}; + +const EmptyHeader = ({ className, ...props }: React.ComponentProps<'div'>) => { + return ( +
+ ); +}; + +const emptyMediaVariants = cva( + 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-transparent', + icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +const EmptyMedia = ({ + className, + variant = 'default', + ...props +}: React.ComponentProps<'div'> & VariantProps) => { + return ( +
+ ); +}; + +const EmptyTitle = ({ className, ...props }: React.ComponentProps<'div'>) => { + return ( +
+ ); +}; + +const EmptyDescription = ({ + className, + ...props +}: React.ComponentProps<'p'>) => { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary', + className, + )} + {...props} + /> + ); +}; + +const EmptyContent = ({ className, ...props }: React.ComponentProps<'div'>) => { + return ( +
+ ); +}; + +export { + Empty as Root, + EmptyHeader as Header, + EmptyTitle as Title, + EmptyDescription as Description, + EmptyContent as Content, + EmptyMedia as Media, +}; diff --git a/src/app/components/ui/index.ts b/src/app/components/ui/index.ts index 1c91e97..4b7fb02 100644 --- a/src/app/components/ui/index.ts +++ b/src/app/components/ui/index.ts @@ -1,5 +1,6 @@ export * as alert from './alert'; export * as alertDialog from './alert-dialog'; +export { Badge } from './badge'; export { Button } from './button'; export * as card from './card'; export * as carousel from './carousel'; @@ -8,7 +9,10 @@ export * as collapsible from './collapsible'; export * as command from './command'; export * as dialog from './dialog'; export * as dropdownMenu from './dropdown-menu'; +export * as empty from './empty'; export { Input } from './input'; +export * as inputGroup from './input-group'; +export * as item from './item'; export { Label } from './label'; export * as navMenu from './navmenu'; export * as pagination from './pagination'; @@ -25,4 +29,5 @@ export { Slider } from './slider'; export { Spinner } from './spinner'; export { Switch } from './switch'; export * as tabs from './tabs'; +export { Textarea } from './textarea'; export * as tooltip from './tooltip'; diff --git a/src/app/components/ui/input-group.tsx b/src/app/components/ui/input-group.tsx new file mode 100644 index 0000000..c38fa97 --- /dev/null +++ b/src/app/components/ui/input-group.tsx @@ -0,0 +1,158 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils/index'; +import { Button } from '@/app/components/ui/button'; +import { Input } from '@/app/components/ui/input'; +import { Textarea } from '@/app/components/ui/textarea'; + +const InputGroup = ({ className, ...props }: React.ComponentProps<'div'>) => ( +
textarea]:h-auto', + + // Variants based on alignment. + 'has-[>[data-align=inline-start]]:[&>input]:pl-2', + 'has-[>[data-align=inline-end]]:[&>input]:pr-2', + 'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3', + 'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3', + + // Focus state. + 'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50', + + // Error state. + 'has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40', + + className, + )} + {...props} + /> +); + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + 'inline-start': + 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]', + 'inline-end': + 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]', + 'block-start': + 'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3', + 'block-end': + 'order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3', + }, + }, + defaultVariants: { + align: 'inline-start', + }, + }, +); + +const InputGroupAddon = ({ + className, + align = 'inline-start', + ...props +}: React.ComponentProps<'div'> & + VariantProps) => ( +
{ + if ((e.target as HTMLElement).closest('button')) { + return; + } + e.currentTarget.parentElement?.querySelector('input')?.focus(); + }} + {...props} + /> +); + +const inputGroupButtonVariants = cva( + 'flex items-center gap-2 text-sm shadow-none', + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", + sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5', + 'icon-xs': + 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0', + 'icon-sm': 'size-8 p-0 has-[>svg]:p-0', + }, + }, + defaultVariants: { + size: 'xs', + }, + }, +); + +const InputGroupButton = ({ + className, + type = 'button', + variant = 'ghost', + size = 'xs', + ...props +}: Omit, 'size'> & + VariantProps) => ( +