diff --git a/apps/web/package.json b/apps/web/package.json index d6e2629..afb8a5d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,9 @@ "test": "vitest run" }, "dependencies": { + "@bprogress/react": "catalog:", + "@dicebear/collection": "catalog:", + "@dicebear/core": "catalog:", "@orpc/client": "catalog:", "@orpc/contract": "catalog:", "@orpc/json-schema": "catalog:", @@ -19,6 +22,7 @@ "@orpc/server": "catalog:", "@orpc/tanstack-query": "catalog:", "@orpc/zod": "catalog:", + "@tabler/icons-react": "catalog:", "@tailwindcss/typography": "catalog:", "@tailwindcss/vite": "catalog:", "@tanstack/react-devtools": "catalog:", @@ -36,10 +40,16 @@ "@trid/shared": "workspace:*", "@trid/ui": "workspace:*", "better-auth": "catalog:", + "date-fns": "catalog:", "lucide-react": "catalog:", - "nitro": "npm:nitro-nightly@latest", + "motion": "catalog:", + "next-themes": "catalog:", + "nitro": "catalog:", "react": "catalog:", "react-dom": "catalog:", + "react-icons": "catalog:", + "react-intersection-observer": "catalog:", + "sonner": "catalog:", "tailwindcss": "catalog:", "zod": "catalog:" }, diff --git a/apps/web/src/components/avatar-user.tsx b/apps/web/src/components/avatar-user.tsx new file mode 100644 index 0000000..2fa611b --- /dev/null +++ b/apps/web/src/components/avatar-user.tsx @@ -0,0 +1,35 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@trid/ui/components/avatar' +import { cn } from '@trid/ui/lib/utils' + +import { GeneratedAvatar } from '#/components/generated-avatar' + +export const AvatarUser = ({ + image, + name, + className, +}: { + image?: string | null + name: string + className?: string +}) => { + if (!image) { + return ( + + ) + } + + return ( + + + + {name.charAt(0)} + + + ) +} diff --git a/apps/web/src/components/error-components.tsx b/apps/web/src/components/error-components.tsx new file mode 100644 index 0000000..7e4d0b2 --- /dev/null +++ b/apps/web/src/components/error-components.tsx @@ -0,0 +1,118 @@ +import { useQueryErrorResetBoundary } from '@tanstack/react-query' +import { Link, useRouter } from '@tanstack/react-router' + +import { useEffect } from 'react' + +import { + AlertTriangleIcon, + ArrowUpRightFromSquareIcon, + HomeIcon, + RefreshCcwIcon, +} from 'lucide-react' + +import { Button } from '@trid/ui/components/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@trid/ui/components/dialog' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@trid/ui/components/empty' + +export const DefaultErrorComponent = ({ error }: { error: Error }) => { + const isDev = process.env.NODE_ENV !== 'production' + + const router = useRouter() + const queryClientErrorBoundary = useQueryErrorResetBoundary() + + const handleRetry = () => { + void router.invalidate() + } + + useEffect(() => { + queryClientErrorBoundary.reset() + }, [queryClientErrorBoundary]) + + return ( +
+ + + + + + Oops! Something went wrong + + It looks like something unexpected happened. +
+ Please try again later. +
+
+ + + + + + + {isDev && ( + + + + + + + + Learn More + Error details + + +
+

Error Message:

+

{error.message}

+

Stack Trace:

+
+									{error.stack}
+								
+
+ + + + + + +
+
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/generated-avatar.tsx b/apps/web/src/components/generated-avatar.tsx new file mode 100644 index 0000000..cdd83f7 --- /dev/null +++ b/apps/web/src/components/generated-avatar.tsx @@ -0,0 +1,40 @@ +import { useMemo } from 'react' + +import { + botttsNeutral, + initials, + notionistsNeutral, +} from '@dicebear/collection' +import { createAvatar } from '@dicebear/core' + +import { Avatar, AvatarFallback, AvatarImage } from '@trid/ui/components/avatar' +import { cn } from '@trid/ui/lib/utils' + +interface Props { + seed: string + className?: string + style: 'botttsNeutral' | 'initials' | 'notionistsNeutral' +} + +export const GeneratedAvatar = ({ seed, style, className }: Props) => { + const avatar = useMemo(() => { + const avatarVariants = { + botttsNeutral: () => createAvatar(botttsNeutral, { seed }), + initials: () => createAvatar(initials, { seed }), + notionistsNeutral: () => createAvatar(notionistsNeutral, { seed }), + } + + return avatarVariants[style]() + }, [seed, style]) + + const avatarUri = useMemo(() => avatar.toDataUri(), [avatar]) + + return ( + + + + {Array.from(seed)[0] ?? '?'} + + + ) +} diff --git a/apps/web/src/components/loader.tsx b/apps/web/src/components/loader.tsx new file mode 100644 index 0000000..d049975 --- /dev/null +++ b/apps/web/src/components/loader.tsx @@ -0,0 +1,28 @@ +import { motion } from 'motion/react' + +import { cn } from '@trid/ui/lib/utils' + +function InlineLoader({ className }: { className?: string }) { + return ( + + {[0, 0.15, 0.3].map((delay, i) => ( + + ))} + + ) +} + +export const Loader = { + Inline: InlineLoader, +} diff --git a/apps/web/src/components/marv-icon.tsx b/apps/web/src/components/marv-icon.tsx new file mode 100644 index 0000000..92b852a --- /dev/null +++ b/apps/web/src/components/marv-icon.tsx @@ -0,0 +1,26 @@ +import type { SVGProps } from 'react' + +type MarvIconProps = SVGProps + +export const MarvIcon = ({ ...props }: MarvIconProps) => { + return ( + + + + + ) +} diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx new file mode 100644 index 0000000..552cb43 --- /dev/null +++ b/apps/web/src/components/navbar.tsx @@ -0,0 +1,32 @@ +import { PanelLeftCloseIcon, PanelLeftIcon } from 'lucide-react' + +import { Button } from '@trid/ui/components/button' +import { useSidebar } from '@trid/ui/components/sidebar' + +export const Navbar = () => { + const { state, isMobile, toggleSidebar } = useSidebar() + + return ( +
+
+ +
+
+ ) +} diff --git a/apps/web/src/components/not-found-components.tsx b/apps/web/src/components/not-found-components.tsx new file mode 100644 index 0000000..693fef9 --- /dev/null +++ b/apps/web/src/components/not-found-components.tsx @@ -0,0 +1,40 @@ +import { Link } from '@tanstack/react-router' + +import { HomeIcon } from 'lucide-react' + +import { Button } from '@trid/ui/components/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from '@trid/ui/components/empty' + +export const DefaultNotFoundComponent = () => { + return ( +
+ + + + 404 + + + The page you're looking for might have been
+ moved or doesn't exist. +
+
+ +
+ +
+
+
+
+ ) +} diff --git a/apps/web/src/components/pending-components.tsx b/apps/web/src/components/pending-components.tsx new file mode 100644 index 0000000..ab64ef8 --- /dev/null +++ b/apps/web/src/components/pending-components.tsx @@ -0,0 +1,9 @@ +import { Loader } from '#/components/loader' + +export const DefaultPendingComponent = () => { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/components/sidebar-content.tsx b/apps/web/src/components/sidebar-content.tsx new file mode 100644 index 0000000..d769ad0 --- /dev/null +++ b/apps/web/src/components/sidebar-content.tsx @@ -0,0 +1,64 @@ +import { + Link, + linkOptions, + useMatch, + rootRouteId, +} from '@tanstack/react-router' + +import { HomeIcon } from 'lucide-react' + +import { + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from '@trid/ui/components/sidebar' +import { cn } from '@trid/ui/lib/utils' + +type MainSidebarContentProps = React.ComponentProps + +const navLinks = linkOptions([ + { + to: '/', + icon: HomeIcon, + label: 'Home', + }, +]) + +export const MainSidebarContent = ({ + className, + ...props +}: MainSidebarContentProps) => { + const match = useMatch({ from: rootRouteId }) + + return ( + + + + + {navLinks.map((link) => ( + + + + + {link.label} + + + + ))} + + + + + ) +} diff --git a/apps/web/src/components/sidebar-footer.tsx b/apps/web/src/components/sidebar-footer.tsx new file mode 100644 index 0000000..db62426 --- /dev/null +++ b/apps/web/src/components/sidebar-footer.tsx @@ -0,0 +1,283 @@ +import { + getRouteApi, + Link, + linkOptions, + useMatchRoute, + useRouter, +} from '@tanstack/react-router' + +import type { ComponentProps, FC } from 'react' + +import { AvatarUser } from './avatar-user' +import { IconSettings } from '@tabler/icons-react' +import { + ChevronsUpDownIcon, + LogInIcon, + LogOutIcon, + UserIcon, +} from 'lucide-react' +import { toast } from 'sonner' + +import type { auth } from '@trid/auth' + +import { Button } from '@trid/ui/components/button' +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '@trid/ui/components/drawer' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@trid/ui/components/dropdown-menu' +import { Separator } from '@trid/ui/components/separator' +import { + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@trid/ui/components/sidebar' +import { cn } from '@trid/ui/lib/utils' + +import { authClient } from '#/libs/auth-client' + +type MainSidebarFooterProps = ComponentProps + +const routeApi = getRouteApi('/_app') + +export const MainSidebarFooter: FC = ({ + className, + ...props +}) => { + const { auth } = routeApi.useRouteContext() + const router = useRouter() + + const { isMobile } = useSidebar() + + const handleSignOut = async () => { + await authClient.signOut({ + fetchOptions: { + onSuccess: () => { + void router.invalidate() + }, + onError: (ctx) => { + toast.error('Sign out failed', { + description: ctx.error.message, + }) + }, + }, + }) + } + return ( + + + + + {auth ? ( + + ) : ( + + + + )} + + + + + ) +} + +const getUserDropdownLinks = () => { + return linkOptions([ + { + to: '.', + icon: UserIcon, + label: 'Profile', + }, + { + to: '.', + icon: IconSettings, + label: 'Settings', + }, + ]) +} + +interface SidebarFooterUserButtonProps { + user: typeof auth.$Infer.Session.user + onSignOut: () => void + isMobile: boolean +} + +const SidebarFooterUserButton: FC = ({ + user, + onSignOut, + isMobile, +}) => { + const userDropdownLinks = getUserDropdownLinks() + + const matchRoute = useMatchRoute() + if (isMobile) { + return ( + + + + +
+ {user.name} + @{user.username} +
+ +
+
+ + +
+ +
+ + {user.name} + + + @{user.username} + +
+
+
+ +
+ {userDropdownLinks.map((link) => { + const isActive = matchRoute({ to: link.to }) + + return ( + + + + ) + })} +
+ + + + + + +
+
+
+ ) + } + + return ( + + + + +
+ {user.name} + + @{user.username} + +
+ +
+
+ + +
+ +
+ {user.name} + + @{user.username} + +
+
+
+ + + {userDropdownLinks.map((item) => { + const isActive = matchRoute({ to: item.to }) + + return ( + + + + ) + })} + + + + + Sign out + +
+
+ ) +} diff --git a/apps/web/src/components/sidebar-header.tsx b/apps/web/src/components/sidebar-header.tsx new file mode 100644 index 0000000..13913d1 --- /dev/null +++ b/apps/web/src/components/sidebar-header.tsx @@ -0,0 +1,38 @@ +import { Link } from '@tanstack/react-router' + +import type { ComponentProps } from 'react' + +import { env } from '@trid/env/client' + +import { + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from '@trid/ui/components/sidebar' + +import { MarvIcon } from '#/components/marv-icon' + +type MainSidebarHeaderProps = ComponentProps + +export const MainSidebarHeader = ({ ...props }: MainSidebarHeaderProps) => { + return ( + + + + + + + + {env.VITE_APP_NAME} + + + + + + + ) +} diff --git a/apps/web/src/components/sidebar-wrapper.tsx b/apps/web/src/components/sidebar-wrapper.tsx new file mode 100644 index 0000000..36fa308 --- /dev/null +++ b/apps/web/src/components/sidebar-wrapper.tsx @@ -0,0 +1,33 @@ +import type { ComponentProps, FC, ReactNode } from 'react' + +import { + SidebarInset, + SidebarProvider, + Sidebar, +} from '@trid/ui/components/sidebar' + +import { MainSidebarContent } from '#/components/sidebar-content' +import { MainSidebarFooter } from '#/components/sidebar-footer' +import { MainSidebarHeader } from '#/components/sidebar-header' + +type SidebarMainProps = ComponentProps + +export const SidebarWrapper = ({ children }: { children: ReactNode }) => { + return ( + + + + {children} + + ) +} + +const SidebarMain: FC = ({ ...props }) => { + return ( + + + + + + ) +} diff --git a/apps/web/src/components/sign-in-social-buttons.tsx b/apps/web/src/components/sign-in-social-buttons.tsx new file mode 100644 index 0000000..c135e7c --- /dev/null +++ b/apps/web/src/components/sign-in-social-buttons.tsx @@ -0,0 +1,65 @@ +import { useSearch } from '@tanstack/react-router' + +import { FaGithub } from 'react-icons/fa6' +import { FcGoogle } from 'react-icons/fc' +import type { IconType } from 'react-icons/lib' +import { toast } from 'sonner' + +import { Button } from '@trid/ui/components/button' + +import { authClient } from '#/libs/auth-client' + +type SocialButton = { + provider: 'github' | 'google' + icon: IconType + label: string +} + +const socialButtons: Array = [ + { + provider: 'github', + icon: FaGithub, + label: 'GitHub', + }, + { + provider: 'google', + icon: FcGoogle, + label: 'Google', + }, +] as const + +export const SignInSocialButtons = () => { + const { callbackURL } = useSearch({ from: '/_auth', strict: true }) + + const handleSignIn = async (provider: SocialButton['provider']) => { + await authClient.signIn.social({ + provider, + callbackURL, + fetchOptions: { + onError: (ctx) => { + toast.error('Failed to sign in.', { + description: ctx.error.message, + }) + }, + }, + }) + } + + return ( +
+ {socialButtons.map((button) => ( + + ))} +
+ ) +} diff --git a/apps/web/src/components/tanstack-router-progress-provider.tsx b/apps/web/src/components/tanstack-router-progress-provider.tsx new file mode 100644 index 0000000..40a2408 --- /dev/null +++ b/apps/web/src/components/tanstack-router-progress-provider.tsx @@ -0,0 +1,41 @@ +import { ProgressProvider } from '@bprogress/react' +import type { RouterProgressProviderProps } from '@bprogress/react' + +import { TanStackRouterProgress } from '#/components/tanstack-router-progress' + +export type TanStackRouterProgressProviderProps = RouterProgressProviderProps + +export const TanStackRouterProgressProvider = ({ + children, + color, + delay, + disableSameURL, + disableStyle, + height, + nonce, + options, + spinnerPosition, + startPosition, + stopDelay, + style, +}: TanStackRouterProgressProviderProps) => { + return ( + + + {children} + + ) +} diff --git a/apps/web/src/components/tanstack-router-progress.tsx b/apps/web/src/components/tanstack-router-progress.tsx new file mode 100644 index 0000000..0b25f16 --- /dev/null +++ b/apps/web/src/components/tanstack-router-progress.tsx @@ -0,0 +1,48 @@ +import type { ListenerFn, RouterEvents } from '@tanstack/react-router' +import { useRouter } from '@tanstack/react-router' + +import { useEffect } from 'react' + +import type { RouterProgressProps } from '@bprogress/react' +import { useProgress, withMemo } from '@bprogress/react' + +export type TanStackRouterProgressProps = RouterProgressProps + +const TanStackRouterProgressComponent = ({ + delay = 0, + disableSameURL = true, + startPosition = 0, + stopDelay = 0, +}: RouterProgressProps) => { + const { start, stop } = useProgress() + const router = useRouter() + + useEffect(() => { + const handleRouteStart: ListenerFn = ( + event + ) => { + // If the URL is the same, we don't want to start the progress bar + if (!event.hrefChanged && disableSameURL) { + return + } + + start(startPosition, delay) + } + + const handleRouteDone = () => stop(stopDelay) + + const unsubscribeStart = router.subscribe('onBeforeLoad', handleRouteStart) + const unsubscribeStop = router.subscribe('onResolved', handleRouteDone) + + return () => { + unsubscribeStart() + unsubscribeStop() + } + }, [router, start, stop, disableSameURL, startPosition, delay, stopDelay]) + + return null +} + +export const TanStackRouterProgress = withMemo(TanStackRouterProgressComponent) + +TanStackRouterProgress.displayName = 'TanStackRouterProgress' diff --git a/apps/web/src/components/theme-provider.tsx b/apps/web/src/components/theme-provider.tsx new file mode 100644 index 0000000..6ee8150 --- /dev/null +++ b/apps/web/src/components/theme-provider.tsx @@ -0,0 +1,118 @@ +import { ScriptOnce } from '@tanstack/react-router' + +import { + createContext, + use, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' + +type Theme = 'dark' | 'light' | 'system' +const MEDIA = '(prefers-color-scheme: dark)' + +type ThemeProviderProps = { + children: React.ReactNode + defaultTheme?: Theme + storageKey?: string +} + +type ThemeProviderState = { + theme: Theme + setTheme: (theme: Theme) => void +} + +const initialState: ThemeProviderState = { + theme: 'system', + setTheme: () => null, +} + +const ThemeProviderContext = createContext(initialState) + +// references: +// https://ui.shadcn.com/docs/dark-mode/vite +// https://github.com/pacocoursey/next-themes/blob/main/next-themes/src/index.tsx +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'theme', + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => + (typeof window !== 'undefined' + ? (localStorage.getItem(storageKey) as Theme) + : null) ?? defaultTheme + ) + + const handleMediaQuery = useCallback( + (e: MediaQueryListEvent | MediaQueryList) => { + if (theme !== 'system') return + const root = window.document.documentElement + const targetTheme = e.matches ? 'dark' : 'light' + if (!root.classList.contains(targetTheme)) { + root.classList.remove('light', 'dark') + root.classList.add(targetTheme) + } + }, + [theme] + ) + + // Listen for system preference changes + useEffect(() => { + const media = window.matchMedia(MEDIA) + + media.addEventListener('change', handleMediaQuery) + handleMediaQuery(media) + + return () => media.removeEventListener('change', handleMediaQuery) + }, [handleMediaQuery]) + + useEffect(() => { + const root = window.document.documentElement + + let targetTheme: string + + if (theme === 'system') { + localStorage.removeItem(storageKey) + targetTheme = window.matchMedia(MEDIA).matches ? 'dark' : 'light' + } else { + localStorage.setItem(storageKey, theme) + targetTheme = theme + } + + // Only update if the target theme is not already applied + if (!root.classList.contains(targetTheme)) { + root.classList.remove('light', 'dark') + root.classList.add(targetTheme) + } + }, [theme, storageKey]) + + const value = useMemo( + () => ({ + theme, + setTheme, + }), + [theme] + ) + + return ( + + + {/* Apply theme early to avoid FOUC */} + {`document.documentElement.classList.toggle( + 'dark', + localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) + )`} + + {children} + + ) +} + +export const useTheme = () => { + const context = use(ThemeProviderContext) + + return context +} diff --git a/apps/web/src/features/threads/components/thread-card.tsx b/apps/web/src/features/threads/components/thread-card.tsx new file mode 100644 index 0000000..841d513 --- /dev/null +++ b/apps/web/src/features/threads/components/thread-card.tsx @@ -0,0 +1,184 @@ +import { Link } from '@tanstack/react-router' + +import { + IconArrowBigDown, + IconArrowBigDownFilled, + IconArrowBigUp, + IconArrowBigUpFilled, + IconBookmark, + IconDotsVertical, + IconMessageCirclePlus, +} from '@tabler/icons-react' +import { formatDistanceToNowStrict } from 'date-fns' + +import type { RouterOutputs } from '@trid/api/routers' + +import type { auth as authServer } from '@trid/auth' + +import { Button } from '@trid/ui/components/button' +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from '@trid/ui/components/card' +import { Separator } from '@trid/ui/components/separator' + +import type { VoteDirection } from '@trid/shared' + +import { AvatarUser } from '#/components/avatar-user' +import type { useToggleThreadVoteMutation } from '#/features/votes/hooks/use-votes' + +interface ThreadCardProps { + thread: RouterOutputs['threads']['list']['items'][number] + toggleVote: ReturnType['mutate'] + auth: typeof authServer.$Infer.Session | null +} + +export const ThreadCard = ({ thread, toggleVote, auth }: ThreadCardProps) => { + const relativeTime = (date: Date) => formatDistanceToNowStrict(date) + + const handleVote = (direction: VoteDirection, slug: string) => + toggleVote({ direction, slug }) + + return ( + + +
+ + +
+
+ + {thread.author.name} + + + + + + {relativeTime(thread.createdAt)} + +
+ +

+ @{thread.author.username} +

+
+
+ + {auth ? ( + + ) : ( + + )} +
+ + + + {thread.title} + + + + +
+
+
+ {auth ? ( + + ) : ( + + )} + + {thread.votesScoresCount} + + {auth ? ( + + ) : ( + + )} +
+ + +
+ + {auth ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/apps/web/src/features/threads/components/thread-list-card.tsx b/apps/web/src/features/threads/components/thread-list-card.tsx new file mode 100644 index 0000000..f8484e2 --- /dev/null +++ b/apps/web/src/features/threads/components/thread-list-card.tsx @@ -0,0 +1,281 @@ +import { useNavigate } from '@tanstack/react-router' + +import { useEffect } from 'react' + +import { + IconClock, + IconCompass, + IconFlame, + IconMessageCircle, + IconRefresh, +} from '@tabler/icons-react' +import type { Icon } from '@tabler/icons-react' +import { useInView } from 'react-intersection-observer' + +import type { auth as authServer } from '@trid/auth' + +import { Button } from '@trid/ui/components/button' +import { ButtonGroup } from '@trid/ui/components/button-group' +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@trid/ui/components/empty' +import { Skeleton } from '@trid/ui/components/skeleton' +import { Spinner } from '@trid/ui/components/spinner' + +import type { ThreadFeed } from '@trid/shared' + +import { ThreadCard } from '#/features/threads/components/thread-card' +import { useListThreadsInfiniteQuery } from '#/features/threads/hooks/use-threads' +import { useToggleThreadVoteMutation } from '#/features/votes/hooks/use-votes' + +const threadListSort: Array<{ label: string; icon: Icon; value: ThreadFeed }> = + [ + { + label: 'Discover', + value: 'discover', + icon: IconCompass, + }, + { + label: 'Popular', + value: 'popular', + icon: IconFlame, + }, + { + label: 'Latest', + value: 'latest', + icon: IconClock, + }, + ] as const + +export const ThreadsListCard = ({ + feed, + auth, +}: { + feed: ThreadFeed + auth: typeof authServer.$Infer.Session | null +}) => { + const navigate = useNavigate() + const { mutate } = useToggleThreadVoteMutation() + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetchNextPageError, + isFetchingNextPage, + } = useListThreadsInfiniteQuery({ feed }) + const { ref: loadMoreRef, inView } = useInView({ + rootMargin: '320px 0px', + threshold: 0, + }) + + const threads = data.pages + .flatMap((page) => page.items) + .filter((threadItem) => !threadItem.deletedAt) + const hasReachedEnd = + !hasNextPage && !isFetchingNextPage && !isFetchNextPageError + + const handleFeedChange = (value: ThreadFeed) => { + void navigate({ + to: '.', + search: (prev) => ({ ...prev, feed: value }), + replace: true, + viewTransition: true, + }) + } + + const handleRetry = () => { + void fetchNextPage() + } + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage && !isFetchNextPageError) { + void fetchNextPage() + } + }, [ + fetchNextPage, + hasNextPage, + inView, + isFetchNextPageError, + isFetchingNextPage, + ]) + + return ( +
+ + {threadListSort.map((sortItem) => ( + + ))} + + + {threads.length === 0 ? ( + + ) : ( +
+ {threads.map((threadItem) => ( + + ))} + + {isFetchingNextPage && } + + {isFetchNextPageError && ( + + )} + + + )} +
+ ) +} + +export const ThreadListSkeleton = ({ count = 3 }: { count?: number }) => { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+
+ +
+ + +
+ +
+ +
+ + +
+ +
+
+ + +
+ +
+
+ ))} +
+ ) +} + +const ThreadListEmptyState = ({ feed }: { feed: ThreadFeed }) => { + const copyByFeed: Record = + { + discover: { + title: 'No threads in discovery yet', + description: + 'Fresh discussions will appear here once people start posting.', + }, + popular: { + title: 'No popular threads yet', + description: + 'Threads need a few votes before this feed has something to rank.', + }, + latest: { + title: 'No recent threads yet', + description: + 'The newest posts will land here as soon as they are published.', + }, + } + + const copy = copyByFeed[feed] + + return ( + + + + + + {copy.title} + {copy.description} + + + ) +} + +const ThreadListPaginationError = ({ + message, + onRetry, +}: { + message: string + onRetry: () => void +}) => { + return ( +
+

+ Couldn't load more threads. {message} +

+ +
+ ) +} + +export const ThreadListPendingState = () => { + return ( +
+
+ + + +
+ +
+ ) +} + +export const ThreadListRoutePendingState = () => { + return ( +
+ +
+ ) +} + +export const ThreadListLoadingMoreState = () => { + return ( +
+ + Loading more threads +
+ ) +} diff --git a/apps/web/src/features/threads/hooks/use-threads.ts b/apps/web/src/features/threads/hooks/use-threads.ts new file mode 100644 index 0000000..d81c263 --- /dev/null +++ b/apps/web/src/features/threads/hooks/use-threads.ts @@ -0,0 +1,13 @@ +import { useSuspenseInfiniteQuery } from '@tanstack/react-query' + +import { listThreadsInfiniteQueryOptions } from '#/features/threads/utils/thread-query-options' +import type { ListThreadsInfiniteQueryOptions } from '#/features/threads/utils/thread-query-options' + +export const useListThreadsInfiniteQuery = ({ + feed, + limit, +}: ListThreadsInfiniteQueryOptions) => { + return useSuspenseInfiniteQuery( + listThreadsInfiniteQueryOptions({ feed, limit }) + ) +} diff --git a/apps/web/src/features/threads/utils/thread-query-options.ts b/apps/web/src/features/threads/utils/thread-query-options.ts new file mode 100644 index 0000000..12cc559 --- /dev/null +++ b/apps/web/src/features/threads/utils/thread-query-options.ts @@ -0,0 +1,24 @@ +import type { RouterInputs } from '@trid/api/routers' + +import { threadORPC } from '#/libs/orpc' + +type ListThreadsInput = RouterInputs['threads']['list'] + +export type ListThreadsInfiniteQueryOptions = { + feed?: ListThreadsInput['feed'] + limit?: number +} + +export const listThreadsInfiniteQueryOptions = ({ + feed, + limit, +}: ListThreadsInfiniteQueryOptions) => + threadORPC.list.infiniteOptions({ + input: (pageParam: string | null) => ({ + limit, + feed, + cursor: typeof pageParam === 'string' ? pageParam : undefined, + }), + initialPageParam: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) diff --git a/apps/web/src/features/votes/hooks/use-votes.ts b/apps/web/src/features/votes/hooks/use-votes.ts new file mode 100644 index 0000000..6e6f545 --- /dev/null +++ b/apps/web/src/features/votes/hooks/use-votes.ts @@ -0,0 +1,50 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { toast } from 'sonner' + +import { + applyVoteThreadResult, + applyOptimisticVoteThread, + snapshotVoteThreadQueries, + restoreVoteThreadSnapshot, + cancelVoteThreadQueries, + invalidateVoteThreadQueries, +} from '#/features/votes/utils/votes-optimistic-cache' +import { voteORPC } from '#/libs/orpc' + +export const useToggleThreadVoteMutation = () => { + const queryClient = useQueryClient() + + return useMutation( + voteORPC.thread.mutationOptions({ + onMutate: async ({ direction, slug }) => { + await cancelVoteThreadQueries(queryClient, slug) + + const snapshot = snapshotVoteThreadQueries({ queryClient, slug }) + applyOptimisticVoteThread(queryClient, slug, direction) + return { snapshot } + }, + + onError: (error, _input, context) => { + toast.error('Failed to vote', { + description: error.message, + }) + + if (context?.snapshot) { + restoreVoteThreadSnapshot(queryClient, context.snapshot) + } + }, + + onSuccess: (result, variables) => { + applyVoteThreadResult(queryClient, variables.slug, result) + + toast.success('Thread voted', { + description: 'Your vote has been cast.', + }) + }, + onSettled: (_result, _error, variables) => { + invalidateVoteThreadQueries(queryClient, variables.slug) + }, + }) + ) +} diff --git a/apps/web/src/features/votes/utils/votes-optimistic-cache.ts b/apps/web/src/features/votes/utils/votes-optimistic-cache.ts new file mode 100644 index 0000000..44a9f16 --- /dev/null +++ b/apps/web/src/features/votes/utils/votes-optimistic-cache.ts @@ -0,0 +1,211 @@ +import type { InfiniteData, QueryClient, QueryKey } from '@tanstack/react-query' + +import type { RouterOutputs } from '@trid/api/routers' + +import type { VoteDirection } from '@trid/shared' + +import { threadORPC } from '#/libs/orpc' + +type VoteState = { + userVote: VoteDirection | null + votesScoresCount: number +} + +type ListThreadItemOutput = RouterOutputs['threads']['list']['items'][number] +type ListThreadsOutput = RouterOutputs['threads']['list'] +type TopThreadsOutput = RouterOutputs['threads']['top'] +type TopThreadItemOutput = RouterOutputs['threads']['top']['items'][number] +type DetailThreadOutput = RouterOutputs['threads']['get'] + +type ListThreadsInfiniteData = InfiniteData +type TopThreadsInfiniteData = InfiniteData + +export type VoteThreadSnapshot = { + lists: Array<[QueryKey, ListThreadsInfiniteData | undefined]> + tops: Array<[QueryKey, TopThreadsInfiniteData | undefined]> + detail: [QueryKey, DetailThreadOutput | undefined] +} + +export const getOptimisticVoteState = ( + current: VoteState, + direction: VoteDirection +): VoteState => { + const isSameVote = current.userVote === direction + const wasOpposite = + current.userVote !== null && current.userVote !== direction + + const scoreDelta = isSameVote + ? direction === 'UPVOTE' + ? -1 + : 1 + : wasOpposite + ? direction === 'UPVOTE' + ? 2 + : -2 + : direction === 'UPVOTE' + ? 1 + : -1 + + return { + userVote: isSameVote ? null : direction, + votesScoresCount: current.votesScoresCount + scoreDelta, + } +} + +const threadsKeys = { + list: () => threadORPC.list.key({ type: 'infinite' }), + top: () => threadORPC.top.key({ type: 'infinite' }), + detail: (slug: string) => + threadORPC.get.key({ type: 'query', input: { slug } }), +} + +export const snapshotVoteThreadQueries = ({ + queryClient, + slug, +}: { + queryClient: QueryClient + slug: string +}): VoteThreadSnapshot => { + const listKey = threadsKeys.list() + const topKey = threadsKeys.top() + const detailKey = threadsKeys.detail(slug) + + return { + lists: queryClient.getQueriesData({ + queryKey: listKey, + }), + + tops: queryClient.getQueriesData({ + queryKey: topKey, + }), + + detail: [ + detailKey, + queryClient.getQueryData(detailKey), + ], + } +} + +export const restoreVoteThreadSnapshot = ( + queryClient: QueryClient, + snapshot: VoteThreadSnapshot +) => { + for (const [key, data] of snapshot.lists) { + queryClient.setQueryData(key, data) + } + + for (const [key, data] of snapshot.tops) { + queryClient.setQueryData(key, data) + } + + const [key, data] = snapshot.detail + queryClient.setQueryData(key, data) +} + +export const updateVoteThreadsInListCaches = ( + queryClient: QueryClient, + slug: string, + updater: (thread: ListThreadItemOutput) => ListThreadItemOutput +) => { + queryClient.setQueriesData( + { queryKey: threadsKeys.list() }, + (old) => { + if (!old) return old + + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((thread) => + thread.slug === slug ? updater(thread) : thread + ), + })), + } + } + ) +} + +export const updateVoteThreadsInTopCaches = ( + queryClient: QueryClient, + slug: string, + updater: (thread: TopThreadItemOutput) => TopThreadItemOutput +) => { + queryClient.setQueriesData( + { queryKey: threadsKeys.top() }, + (old) => { + if (!old) return old + + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((thread) => + thread.slug === slug ? updater(thread) : thread + ), + })), + } + } + ) +} + +export const updateVoteThreadsInDetailCache = ( + queryClient: QueryClient, + slug: string, + updater: (thread: DetailThreadOutput) => DetailThreadOutput +) => { + queryClient.setQueryData( + threadsKeys.detail(slug), + (old) => (old ? updater(old) : old) + ) +} + +export const applyOptimisticVoteThread = ( + queryClient: QueryClient, + slug: string, + direction: VoteDirection +) => { + const update = (thread: TThread): TThread => ({ + ...thread, + ...getOptimisticVoteState(thread, direction), + }) + + updateVoteThreadsInListCaches(queryClient, slug, update) + updateVoteThreadsInTopCaches(queryClient, slug, update) + updateVoteThreadsInDetailCache(queryClient, slug, update) +} + +export const applyVoteThreadResult = ( + queryClient: QueryClient, + slug: string, + result: RouterOutputs['votes']['thread'] +) => { + const update = (thread: TThread): TThread => ({ + ...thread, + userVote: result.userVote, + votesScoresCount: result.votesScoresCount, + }) + + updateVoteThreadsInListCaches(queryClient, slug, update) + updateVoteThreadsInTopCaches(queryClient, slug, update) + updateVoteThreadsInDetailCache(queryClient, slug, update) +} + +export const invalidateVoteThreadQueries = ( + queryClient: QueryClient, + slug: string +) => { + void queryClient.invalidateQueries({ queryKey: threadsKeys.list() }) + void queryClient.invalidateQueries({ queryKey: threadsKeys.top() }) + void queryClient.invalidateQueries({ queryKey: threadsKeys.detail(slug) }) +} + +export const cancelVoteThreadQueries = async ( + queryClient: QueryClient, + slug: string +) => { + await Promise.all([ + queryClient.cancelQueries({ queryKey: threadsKeys.list() }), + queryClient.cancelQueries({ queryKey: threadsKeys.top() }), + queryClient.cancelQueries({ queryKey: threadsKeys.detail(slug) }), + ]) +} diff --git a/apps/web/src/integrations/tanstack-query/devtools.tsx b/apps/web/src/integrations/tanstack-query/devtools.tsx deleted file mode 100644 index 94c68c9..0000000 --- a/apps/web/src/integrations/tanstack-query/devtools.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' - -export default { - name: 'Tanstack Query', - render: , -} diff --git a/apps/web/src/integrations/tanstack-query/root-provider.tsx b/apps/web/src/integrations/tanstack-query/root-provider.tsx deleted file mode 100644 index a4ff9d7..0000000 --- a/apps/web/src/integrations/tanstack-query/root-provider.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { QueryClient } from '@tanstack/react-query' - -export function getContext() { - const queryClient = new QueryClient() - - return { - queryClient, - } -} -export default function TanstackQueryProvider() {} diff --git a/apps/web/src/libs/auth-client.ts b/apps/web/src/libs/auth-client.ts new file mode 100644 index 0000000..e4ee5e5 --- /dev/null +++ b/apps/web/src/libs/auth-client.ts @@ -0,0 +1,21 @@ +import { + adminClient, + inferAdditionalFields, + multiSessionClient, + usernameClient, +} from 'better-auth/client/plugins' +import { createAuthClient } from 'better-auth/react' + +import type { auth } from '@trid/auth' + +import { env } from '@trid/env/client' + +export const authClient = createAuthClient({ + baseURL: env.VITE_APP_URL, + plugins: [ + usernameClient(), + multiSessionClient(), + adminClient(), + inferAdditionalFields(), + ], +}) diff --git a/apps/web/src/libs/orpc.ts b/apps/web/src/libs/orpc.ts index 41a585f..7d86a43 100644 --- a/apps/web/src/libs/orpc.ts +++ b/apps/web/src/libs/orpc.ts @@ -10,6 +10,8 @@ import { createORPCContext } from '@trid/api' import { orpcRouters } from '@trid/api/routers' import type { ORPCRouterClient } from '@trid/api/routers' +import { env } from '@trid/env/client' + const getORPCClient = createIsomorphicFn() .server(() => createRouterClient(orpcRouters, { @@ -18,7 +20,7 @@ const getORPCClient = createIsomorphicFn() ) .client((): ORPCRouterClient => { const link = new RPCLink({ - url: `${window.location.origin}/api/rpc`, + url: `${env.VITE_APP_URL}/api/rpc`, fetch(url, options) { return fetch(url, { ...options, @@ -32,3 +34,13 @@ const getORPCClient = createIsomorphicFn() const client: ORPCRouterClient = getORPCClient() export const orpc = createTanstackQueryUtils(client) + +export const threadORPC = createTanstackQueryUtils(client.threads, { + path: ['threads'], +}) +export const replyORPC = createTanstackQueryUtils(client.replies, { + path: ['replies'], +}) +export const voteORPC = createTanstackQueryUtils(client.votes, { + path: ['votes'], +}) diff --git a/apps/web/src/libs/query-client.ts b/apps/web/src/libs/query-client.ts index 9103428..d07eaf4 100644 --- a/apps/web/src/libs/query-client.ts +++ b/apps/web/src/libs/query-client.ts @@ -4,7 +4,8 @@ const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { - staleTime: 60 * 1000, // 1 minute + staleTime: 30 * 1000, // 30 seconds + gcTime: 5 * 60 * 1000, // 5 minutes retry: 3, }, }, diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 2f3376e..dc97576 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,14 +9,36 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as IndexRouteImport } from './routes/index' +import { Route as AuthRouteImport } from './routes/_auth' +import { Route as AppRouteImport } from './routes/_app' +import { Route as AppIndexRouteImport } from './routes/_app/index' +import { Route as AuthSignInRouteImport } from './routes/_auth/sign-in' +import { Route as AuthOnBoardingRouteImport } from './routes/_auth/on-boarding' import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$' -const IndexRoute = IndexRouteImport.update({ +const AuthRoute = AuthRouteImport.update({ + id: '/_auth', + getParentRoute: () => rootRouteImport, +} as any) +const AppRoute = AppRouteImport.update({ + id: '/_app', + getParentRoute: () => rootRouteImport, +} as any) +const AppIndexRoute = AppIndexRouteImport.update({ id: '/', path: '/', - getParentRoute: () => rootRouteImport, + getParentRoute: () => AppRoute, +} as any) +const AuthSignInRoute = AuthSignInRouteImport.update({ + id: '/sign-in', + path: '/sign-in', + getParentRoute: () => AuthRoute, +} as any) +const AuthOnBoardingRoute = AuthOnBoardingRouteImport.update({ + id: '/on-boarding', + path: '/on-boarding', + getParentRoute: () => AuthRoute, } as any) const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({ id: '/api/rpc/$', @@ -30,44 +52,89 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ } as any) export interface FileRoutesByFullPath { - '/': typeof IndexRoute + '/': typeof AppIndexRoute + '/on-boarding': typeof AuthOnBoardingRoute + '/sign-in': typeof AuthSignInRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute } export interface FileRoutesByTo { - '/': typeof IndexRoute + '/': typeof AppIndexRoute + '/on-boarding': typeof AuthOnBoardingRoute + '/sign-in': typeof AuthSignInRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport - '/': typeof IndexRoute + '/_app': typeof AppRouteWithChildren + '/_auth': typeof AuthRouteWithChildren + '/_auth/on-boarding': typeof AuthOnBoardingRoute + '/_auth/sign-in': typeof AuthSignInRoute + '/_app/': typeof AppIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/api/auth/$' | '/api/rpc/$' + fullPaths: '/' | '/on-boarding' | '/sign-in' | '/api/auth/$' | '/api/rpc/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/api/auth/$' | '/api/rpc/$' - id: '__root__' | '/' | '/api/auth/$' | '/api/rpc/$' + to: '/' | '/on-boarding' | '/sign-in' | '/api/auth/$' | '/api/rpc/$' + id: + | '__root__' + | '/_app' + | '/_auth' + | '/_auth/on-boarding' + | '/_auth/sign-in' + | '/_app/' + | '/api/auth/$' + | '/api/rpc/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute + AppRoute: typeof AppRouteWithChildren + AuthRoute: typeof AuthRouteWithChildren ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiRpcSplatRoute: typeof ApiRpcSplatRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/': { - id: '/' - path: '/' + '/_auth': { + id: '/_auth' + path: '' + fullPath: '/' + preLoaderRoute: typeof AuthRouteImport + parentRoute: typeof rootRouteImport + } + '/_app': { + id: '/_app' + path: '' fullPath: '/' - preLoaderRoute: typeof IndexRouteImport + preLoaderRoute: typeof AppRouteImport parentRoute: typeof rootRouteImport } + '/_app/': { + id: '/_app/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof AppIndexRouteImport + parentRoute: typeof AppRoute + } + '/_auth/sign-in': { + id: '/_auth/sign-in' + path: '/sign-in' + fullPath: '/sign-in' + preLoaderRoute: typeof AuthSignInRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/on-boarding': { + id: '/_auth/on-boarding' + path: '/on-boarding' + fullPath: '/on-boarding' + preLoaderRoute: typeof AuthOnBoardingRouteImport + parentRoute: typeof AuthRoute + } '/api/rpc/$': { id: '/api/rpc/$' path: '/api/rpc/$' @@ -85,8 +152,31 @@ declare module '@tanstack/react-router' { } } +interface AppRouteChildren { + AppIndexRoute: typeof AppIndexRoute +} + +const AppRouteChildren: AppRouteChildren = { + AppIndexRoute: AppIndexRoute, +} + +const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) + +interface AuthRouteChildren { + AuthOnBoardingRoute: typeof AuthOnBoardingRoute + AuthSignInRoute: typeof AuthSignInRoute +} + +const AuthRouteChildren: AuthRouteChildren = { + AuthOnBoardingRoute: AuthOnBoardingRoute, + AuthSignInRoute: AuthSignInRoute, +} + +const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) + const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, + AppRoute: AppRouteWithChildren, + AuthRoute: AuthRouteWithChildren, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiRpcSplatRoute: ApiRpcSplatRoute, } diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 3c37a87..7eb6bac 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -3,6 +3,9 @@ import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query import { routeTree } from './routeTree.gen' +import { DefaultErrorComponent } from '#/components/error-components' +import { DefaultNotFoundComponent } from '#/components/not-found-components' +import { DefaultPendingComponent } from '#/components/pending-components' import { orpc } from '#/libs/orpc' import { getQueryClient } from '#/libs/query-client' @@ -15,6 +18,9 @@ export function getRouter() { scrollRestoration: true, defaultPreload: 'intent', defaultPreloadStaleTime: 0, + defaultPendingComponent: DefaultPendingComponent, + defaultNotFoundComponent: DefaultNotFoundComponent, + defaultErrorComponent: DefaultErrorComponent, }) setupRouterSsrQueryIntegration({ router, queryClient }) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 71e82d2..1742318 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -8,6 +8,11 @@ import { } from '@tanstack/react-router' import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' +import { Toaster } from '@trid/ui/components/sonner' +import { TooltipProvider } from '@trid/ui/components/tooltip' + +import { TanStackRouterProgressProvider } from '#/components/tanstack-router-progress-provider' +import { ThemeProvider } from '#/components/theme-provider' import { getAuthFn } from '#/functions/get-auth' import type { orpc } from '#/libs/orpc' import appCss from '#/styles.css?url' @@ -23,6 +28,7 @@ export const Route = createRootRouteWithContext()({ return { auth } }, + shellComponent: RootDocument, head: () => ({ meta: [ { @@ -43,17 +49,30 @@ export const Route = createRootRouteWithContext()({ }, ], }), - shellComponent: RootDocument, }) function RootDocument({ children }: { children: React.ReactNode }) { return ( - + - {children} + + + {children} + + + + { + if (auth && !auth.user.onBoardingCompleted) { + throw redirect({ + to: '/on-boarding', + replace: true, + viewTransition: true, + }) + } + }, + component: RouteComponent, +}) + +function RouteComponent() { + return ( + +
+ + +
+
+ ) +} diff --git a/apps/web/src/routes/_app/index.tsx b/apps/web/src/routes/_app/index.tsx new file mode 100644 index 0000000..6df2063 --- /dev/null +++ b/apps/web/src/routes/_app/index.tsx @@ -0,0 +1,87 @@ +import { createFileRoute, useRouter } from '@tanstack/react-router' + +import { IconAlertTriangle, IconRefresh } from '@tabler/icons-react' +import { z } from 'zod' + +import { Button } from '@trid/ui/components/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@trid/ui/components/empty' + +import { THREAD_FEEDS } from '@trid/shared' + +import { + ThreadListRoutePendingState, + ThreadsListCard, +} from '#/features/threads/components/thread-list-card' +import { listThreadsInfiniteQueryOptions } from '#/features/threads/utils/thread-query-options' + +const querySchema = z.object({ + feed: z.enum(THREAD_FEEDS).optional(), +}) + +export const Route = createFileRoute('/_app/')({ + validateSearch: querySchema, + beforeLoad: async ({ context, search }) => { + const { queryClient } = context + const { feed = 'discover' } = search + + const threads = await queryClient.ensureInfiniteQueryData( + listThreadsInfiniteQueryOptions({ feed }) + ) + + return { threads } + }, + pendingComponent: ThreadListRoutePendingState, + errorComponent: ThreadListRouteErrorState, + component: Home, +}) + +function Home() { + const { auth } = Route.useRouteContext() + const { feed = 'discover' } = Route.useSearch() + + return ( +
+ +
+ ) +} + +function ThreadListRouteErrorState({ error }: { error: Error }) { + const router = useRouter() + + const handleRetry = () => { + void router.invalidate() + } + + return ( +
+ + + + + + Threads did not load + + {error.message || 'Refresh the feed and try again.'} + + + + + + +
+ ) +} diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx new file mode 100644 index 0000000..a54dda6 --- /dev/null +++ b/apps/web/src/routes/_auth.tsx @@ -0,0 +1,42 @@ +import { createFileRoute, Link, Outlet } from '@tanstack/react-router' + +import { ArrowLeftIcon } from 'lucide-react' +import { z } from 'zod' + +import { Button } from '@trid/ui/components/button' + +function sanitizeCallbackURL(url: string): string { + if (!url.startsWith('/') || url.startsWith('//')) { + return '/' + } + return url +} + +const searchSchema = z.object({ + callbackURL: z.string().optional().default('/').transform(sanitizeCallbackURL), +}) + +export const Route = createFileRoute('/_auth')({ + validateSearch: searchSchema, + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+ + + +
+ ) +} diff --git a/apps/web/src/routes/_auth/on-boarding.tsx b/apps/web/src/routes/_auth/on-boarding.tsx new file mode 100644 index 0000000..9db11e6 --- /dev/null +++ b/apps/web/src/routes/_auth/on-boarding.tsx @@ -0,0 +1,173 @@ +import { useForm } from '@tanstack/react-form' +import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router' + +import { toast } from 'sonner' +import { z } from 'zod' + +import { env } from '@trid/env/client' + +import { Button } from '@trid/ui/components/button' +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@trid/ui/components/card' +import { + Field, + FieldError, + FieldGroup, + FieldLabel, +} from '@trid/ui/components/field' +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from '@trid/ui/components/input-group' +import { Spinner } from '@trid/ui/components/spinner' + +import { MarvIcon } from '#/components/marv-icon' +import { authClient } from '#/libs/auth-client' + +export const Route = createFileRoute('/_auth/on-boarding')({ + component: RouteComponent, + beforeLoad: ({ context: { auth }, search }) => { + if (auth?.user.onBoardingCompleted) { + throw redirect({ + to: search.callbackURL, + replace: true, + viewTransition: true, + }) + } + }, +}) + +const onBoardingFormSchema = z.object({ + username: z + .string() + .min(3, 'Username must be at least 3 characters long') + .max(30, 'Username must be at most 30 characters long') + .regex( + /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){1,28}$/i, + 'Username must be at least 3 characters long, can only contain letters, numbers, and hyphens, and must start and end with a letter or number' + ), +}) + +function RouteComponent() { + const { callbackURL } = Route.useSearch() + const navigate = useNavigate() + + const form = useForm({ + defaultValues: { + username: '', + }, + validators: { + onChange: onBoardingFormSchema, + onSubmit: onBoardingFormSchema, + }, + onSubmit: async ({ value }) => { + await authClient.updateUser({ + username: value.username, + onBoardingCompleted: true, + + fetchOptions: { + onSuccess: () => { + void navigate({ + to: callbackURL, + replace: true, + viewTransition: true, + }) + }, + + onError: ({ error }) => { + toast.error('Failed to update user', { + description: error.message, + }) + }, + }, + }) + }, + }) + + return ( + + + + + + Welcome to {env.VITE_APP_NAME} + + + Let's get to know you better + + + + +
{ + e.preventDefault() + e.stopPropagation() + void form.handleSubmit() + }} + > + + { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid + + return ( + + + Choose your username + + + + + {env.VITE_APP_URL}/ + + + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + placeholder="username" + className="pl-0.5!" + autoComplete="username" + /> + + + {isInvalid && ( + + )} + + ) + }} + /> + + + [state.canSubmit, state.isSubmitting]} + children={([canSubmit, isSubmitting]) => ( + + )} + /> + +
+
+ ) +} diff --git a/apps/web/src/routes/_auth/sign-in.tsx b/apps/web/src/routes/_auth/sign-in.tsx new file mode 100644 index 0000000..306e5a2 --- /dev/null +++ b/apps/web/src/routes/_auth/sign-in.tsx @@ -0,0 +1,44 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@trid/ui/components/card' + +import { MarvIcon } from '#/components/marv-icon' +import { SignInSocialButtons } from '#/components/sign-in-social-buttons' + +export const Route = createFileRoute('/_auth/sign-in')({ + component: RouteComponent, + beforeLoad: ({ context: { auth }, search }) => { + if (auth) { + throw redirect({ + to: search.callbackURL, + replace: true, + viewTransition: true, + }) + } + }, +}) + +function RouteComponent() { + return ( + + + + + Welcome back + + Sign in to your account + + + + + + + + ) +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx deleted file mode 100644 index 9d4d51c..0000000 --- a/apps/web/src/routes/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { createFileRoute } from '@tanstack/react-router' - -import { orpc } from '#/libs/orpc' - -export const Route = createFileRoute('/')({ component: Home }) - -function Home() { - const { auth } = Route.useRouteContext() - const { data } = useQuery(orpc.me.queryOptions()) - return ( -
-

Welcome to TanStack Start

-

- Edit src/routes/index.tsx to get started. -

- - {auth ? ( -
-					{JSON.stringify(data, null, 2)}
-				
- ) : ( - User Not Login - )} -
- ) -} diff --git a/bun.lock b/bun.lock index 106f341..d4df65b 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "marvticle-monorepo", + "name": "trid", "dependencies": { "@trid/env": "workspace:*", "dotenv": "catalog:", @@ -25,6 +25,9 @@ "apps/web": { "name": "web", "dependencies": { + "@bprogress/react": "catalog:", + "@dicebear/collection": "catalog:", + "@dicebear/core": "catalog:", "@orpc/client": "catalog:", "@orpc/contract": "catalog:", "@orpc/json-schema": "catalog:", @@ -32,6 +35,7 @@ "@orpc/server": "catalog:", "@orpc/tanstack-query": "catalog:", "@orpc/zod": "catalog:", + "@tabler/icons-react": "catalog:", "@tailwindcss/typography": "catalog:", "@tailwindcss/vite": "catalog:", "@tanstack/react-devtools": "catalog:", @@ -49,10 +53,16 @@ "@trid/shared": "workspace:*", "@trid/ui": "workspace:*", "better-auth": "catalog:", + "date-fns": "catalog:", "lucide-react": "catalog:", - "nitro": "npm:nitro-nightly@latest", + "motion": "catalog:", + "next-themes": "catalog:", + "nitro": "catalog:", "react": "catalog:", "react-dom": "catalog:", + "react-icons": "catalog:", + "react-intersection-observer": "catalog:", + "sonner": "catalog:", "tailwindcss": "catalog:", "zod": "catalog:", }, @@ -181,10 +191,15 @@ "@tabler/icons-react": "catalog:", "class-variance-authority": "catalog:", "clsx": "catalog:", + "next-themes": "catalog:", "radix-ui": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", "shadcn": "catalog:", + "sonner": "catalog:", "tailwind-merge": "catalog:", "tw-animate-css": "catalog:", + "vaul": "^1.1.2", "zod": "catalog:", }, "devDependencies": { @@ -201,6 +216,9 @@ }, "catalog": { "@better-auth/infra": "^0.2.8", + "@bprogress/react": "^1.2.7", + "@dicebear/collection": "^9.4.2", + "@dicebear/core": "^9.4.2", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/outfit": "^5.2.8", "@orpc/client": "^1.14.3", @@ -223,12 +241,12 @@ "@tanstack/eslint-plugin-router": "^1.162.0", "@tanstack/react-devtools": "latest", "@tanstack/react-form": "latest", - "@tanstack/react-query": "latest", - "@tanstack/react-query-devtools": "latest", - "@tanstack/react-router": "latest", - "@tanstack/react-router-devtools": "latest", - "@tanstack/react-router-ssr-query": "latest", - "@tanstack/react-start": "latest", + "@tanstack/react-query": "^5.100.14", + "@tanstack/react-query-devtools": "^5.100.14", + "@tanstack/react-router": "^1.170.8", + "@tanstack/react-router-devtools": "^1.167.0", + "@tanstack/react-router-ssr-query": "^1.167.0", + "@tanstack/react-start": "^1.168.9", "@tanstack/router-plugin": "^1.132.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", @@ -242,6 +260,7 @@ "bun-types": "^1.3.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.3.0", "dotenv": "^17.4.2", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", @@ -249,7 +268,10 @@ "jsdom": "^28.1.0", "limax": "^4.2.3", "lucide-react": "^0.545.0", + "motion": "^12.40.0", "nanoid": "^5.1.11", + "next-themes": "^0.4.6", + "nitro": "^3.0.260522-beta", "oxfmt": "^0.51.0", "oxlint": "^1.66.0", "oxlint-tsgolint": "^0.23.0", @@ -257,8 +279,11 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-icons": "^5.6.0", + "react-intersection-observer": "^10.0.3", "resend": "^6.12.3", "shadcn": "^4.7.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.1.18", "turbo": "^2.9.14", @@ -361,6 +386,10 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@bprogress/core": ["@bprogress/core@1.3.4", "", {}, "sha512-q/AqpurI/1uJzOrQROuZWixn/+ARekh+uvJGwLCP6HQ/EqAX4SkvNf618tSBxL4NysC0MwqAppb/mRw6Tzi61w=="], + + "@bprogress/react": ["@bprogress/react@1.2.7", "", { "dependencies": { "@bprogress/core": "^1.3.4" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-MqJfHW+R5CQeWqyqrLxUjdBRHk24Xl63OkBLo5DMWqUqocUikRTfCIc/jtQQbPk7BRfdr5OP3Lx7YlfQ9QOZMQ=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], @@ -375,7 +404,73 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], - "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.66.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-qlQFhHUjhRDybrinqLAD0MClVZDOrsq80O8eD5iSjz3Qa/4f3Jg7SQrOaSobrRyP1QaWIYLGtGpj2c7H0D8NUw=="], + "@dicebear/adventurer": ["@dicebear/adventurer@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-jqYp834ZmGDA9HBBDQAdgF1O2UTCwHF4vVrktXWa2Dppp1JczPL5HnVOWsjtrLmXNn61Wd6OLmBb2e6rhzp3ig=="], + + "@dicebear/adventurer-neutral": ["@dicebear/adventurer-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-5xgkG/mNL4j3Q4SJGQLBU/KnU90tng8Ze5ofThD+55wi0oeY/nSAUowg6UFCmHrktjifj/MEx3CQqbpcPWtfIA=="], + + "@dicebear/avataaars": ["@dicebear/avataaars@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-3x9jKFkOkFSPmpTbt9xvhiU2E1GX7beCSsX0tXRUShj8x6+5Ks9yBRT1VlkySbnXrZ/GglADGg7vJ/D2uIx1Yw=="], + + "@dicebear/avataaars-neutral": ["@dicebear/avataaars-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-/eNrp0YCNJRwQXqOloLm1+3Ss2C+pMpUQIGkbEnGsP1UK+13Ge80ggDDof1HpdqvG9HAZcKa7hnbG/0HSwyDSw=="], + + "@dicebear/big-ears": ["@dicebear/big-ears@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-mNfz3ppNA7UBq0IO3nXCiV5pFPG7c1DfzRB0foNU2Wo1XXT8FIcSY2BvDlYqorZTOUOz7dHb0vx06hqvG0HP5w=="], + + "@dicebear/big-ears-neutral": ["@dicebear/big-ears-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-M8Ozmzza4eY4hpLOYULgJxMYmBA0CsBnrE15/xw6LZkEREXnrX5z0NJsf8hUfdyF6BWZ+RBgzoiav32DAC5zcg=="], + + "@dicebear/big-smile": ["@dicebear/big-smile@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-hmT5i7rcPPhStjZyg28pbIhdTnnMBzK3RObI0vKCpY30EFrzaPkkdDL6Ck5fAFBdvDIW1EpOJkenyR0XPmhgbQ=="], + + "@dicebear/bottts": ["@dicebear/bottts@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-tsx+dII7EFUCVA8URj66G1GqORCCVduCAx4dY2prEY2IeFianVpkntXuFsWZ9BBGx1NZFndvDith5oTwKMQPbQ=="], + + "@dicebear/bottts-neutral": ["@dicebear/bottts-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-kFNwWt6j+gzZ5n5Pz7WVwePubREAQOF8ZwWA9ztwVYDVMLnOChWbAofy5FED4j5md2MXFH2EgLCFCMr5K2BmIA=="], + + "@dicebear/collection": ["@dicebear/collection@9.4.2", "", { "dependencies": { "@dicebear/adventurer": "9.4.2", "@dicebear/adventurer-neutral": "9.4.2", "@dicebear/avataaars": "9.4.2", "@dicebear/avataaars-neutral": "9.4.2", "@dicebear/big-ears": "9.4.2", "@dicebear/big-ears-neutral": "9.4.2", "@dicebear/big-smile": "9.4.2", "@dicebear/bottts": "9.4.2", "@dicebear/bottts-neutral": "9.4.2", "@dicebear/croodles": "9.4.2", "@dicebear/croodles-neutral": "9.4.2", "@dicebear/dylan": "9.4.2", "@dicebear/fun-emoji": "9.4.2", "@dicebear/glass": "9.4.2", "@dicebear/icons": "9.4.2", "@dicebear/identicon": "9.4.2", "@dicebear/initials": "9.4.2", "@dicebear/lorelei": "9.4.2", "@dicebear/lorelei-neutral": "9.4.2", "@dicebear/micah": "9.4.2", "@dicebear/miniavs": "9.4.2", "@dicebear/notionists": "9.4.2", "@dicebear/notionists-neutral": "9.4.2", "@dicebear/open-peeps": "9.4.2", "@dicebear/personas": "9.4.2", "@dicebear/pixel-art": "9.4.2", "@dicebear/pixel-art-neutral": "9.4.2", "@dicebear/rings": "9.4.2", "@dicebear/shapes": "9.4.2", "@dicebear/thumbs": "9.4.2", "@dicebear/toon-head": "9.4.2" }, "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-KArubv7if8H7j9sIfpDK2hJJqrdNVR5zMPAMOSpIU2JPyXx8TC9o5wsmXb8il5wOHgaS9Q/cla7jUNIiDD7Gsg=="], + + "@dicebear/core": ["@dicebear/core@9.4.2", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MF0042+Z3s8PGZKZLySfhft28bUa3B1iq0e5NSjCvY8gfMi5aIH/iRJGRJa1N9Jz1BNkxYb4yvJ/N9KO8Z6Y+w=="], + + "@dicebear/croodles": ["@dicebear/croodles@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-6VoO0JviIf7dKKMBTL/SMXxWhnXHaZuzufX90G0nXxS77ELG1YkGNMaZzawizN4C09Gbya2gJkozqrWiJN/aGw=="], + + "@dicebear/croodles-neutral": ["@dicebear/croodles-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-oG5IeUdtiYshQ89gkAVcl5w3xAEi5UZX2fTzIyelpBPCG176l7VuuFzlxi2umnB3E6LVHYy06DXvUo/p+rXB2Q=="], + + "@dicebear/dylan": ["@dicebear/dylan@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-1vQvRu9x9DrwFxhFaIU2rf0EUL04yDTbAt7fHyAjM0mEsKzTD4mRNf95tCRuavCoW6W48u7A/OY6jyIub6kxLQ=="], + + "@dicebear/fun-emoji": ["@dicebear/fun-emoji@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-kqB6LPkdYCdEU/mwbyz34xLzoNUKL6ARcoo3fr5ASq9D6ZE07qIKybC3xv5+CPz7VmspJ1Q3c/VVWVMDRP7Twg=="], + + "@dicebear/glass": ["@dicebear/glass@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-z5qUogHQ1b6UJ2zCqT848mU2U9DKbVDhiX6GPDjD7tYLisCCJVisH9p6WyNdHvflUd4SHkA6gRqVJIh2v2HnTA=="], + + "@dicebear/icons": ["@dicebear/icons@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-QSMMz0NA03ypSGhXC8HQX8FSj8lYT+/5yqH+/N03OH2IjL0q7wwGZ7nqsrtlRp76O5WqMTwGfSbTUUYPjFr+Xw=="], + + "@dicebear/identicon": ["@dicebear/identicon@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-JVDSmZsv11mSWqwAktK5x9Bslht2xY3TFUn8xzu6slAYe1Z7hEXZ76eb+UJ6F4qEzdwZ7xPWzAS6Nb0Y3A0pww=="], + + "@dicebear/initials": ["@dicebear/initials@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-yePuIUasmwtl9IrtB6rEzE/zb5fImKP/neW0CdcTC2MwLgMuP1GLHEGRgg1zI8exIh+PMv1YdLGyyUuRTE2Qpw=="], + + "@dicebear/lorelei": ["@dicebear/lorelei@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-YMv6vnriW6VLFDsreKuOnUFFno6SRe7+7X7R7zPY0rZ+MaHX9V3jcioIG+1PSjIHEDfOLUHpr5vd1JBWv8y7UA=="], + + "@dicebear/lorelei-neutral": ["@dicebear/lorelei-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-yspanTthA5vh6iCdeLzn6xZ4yYMYRcfcxblcgSvHTF1ut0bjAXtw5SXzZ6aJTrJWiHkzYOQuTOR6GVYiW80Q7w=="], + + "@dicebear/micah": ["@dicebear/micah@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-e4D3W/OlChSsLo7Llwsy0J18vk0azJqF/uFoY+EKACCNHBc1HGNsqVvu2CTf+OWOA8wTyAK6UkjBN5p01r7D+g=="], + + "@dicebear/miniavs": ["@dicebear/miniavs@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-wLwyFNNUnDRd3BbhSBhXR0XEpX8sG0/xDA5M/OkDoapLqZnnI48YLUSDd2N5QTAVMmcSEuZOYxkcnj7WW79vlg=="], + + "@dicebear/notionists": ["@dicebear/notionists@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-ZCySq+nxcD/x4xyYgytcj2N9uY3gxrL+qpnmOdp2BdA221KacVrxlsUPpIgEMqxS2rMmBQXfxg129Pzn4ycIpA=="], + + "@dicebear/notionists-neutral": ["@dicebear/notionists-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-AyD9kEfVxQUwDGf4Op059gVmYIOAkTKg3dtE9h9mEKP7zl/kMy5B67BFFOo7sB0mXCjzAegZ6ekGU02E8+hIHw=="], + + "@dicebear/open-peeps": ["@dicebear/open-peeps@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-i01tLgtp2g937T81sVeAOVlqsCtiTck/Kw20g7hN80+7xrXjOUepz2HPLy3HeiMjwjMGRy5o54kSd0/8Ht4Dqg=="], + + "@dicebear/personas": ["@dicebear/personas@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-NJlkvI5F5gugt6t2+7QrYNTwQC7+4IQZS3vG0dYk2BncxOHax0BuLovdSdiAesTL4ZkytFYIydWmKmV2/xcUwg=="], + + "@dicebear/pixel-art": ["@dicebear/pixel-art@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-peHf7oKICDgBZ8dUyj+txPnS7VZEWgvKE+xW4mNQqBt6dYZIjmva2shOVHn0b1JU+FDxMx3uIkWVixKdUq4WGg=="], + + "@dicebear/pixel-art-neutral": ["@dicebear/pixel-art-neutral@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-9e9Lz554uQvWaXV2P17ss+hPa6rTyuAKBtB8zk8ECjHiZzIl61N/KcTVLZ4dILVZwj7gYriaLo16QEqvL2GJCg=="], + + "@dicebear/rings": ["@dicebear/rings@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-Pc3ymWrRDQPJFNrbbLt7RJrzGvUuuxUiDkrfLhoVE+B6mZWEL1PC78DPbS1yUWYLErJOpJuM2GSwXmTbVjWf+g=="], + + "@dicebear/shapes": ["@dicebear/shapes@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-AFL6jAaiLztvcqyq+ds+lWZu6Vbp3PlGWhJeJRm842jxtiluJpl6r4f6nUXP2fdMz7MNpDzXfLooQK9E04NbUQ=="], + + "@dicebear/thumbs": ["@dicebear/thumbs@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-ccWvDBqbkWS5uzHbsg5L6uML6vBfX7jT3J3jHCQksvz8haHItxTK02w+6e1UavZUsvza4lG5X/XY3eji3siJ4Q=="], + + "@dicebear/toon-head": ["@dicebear/toon-head@9.4.2", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-lwFeSXyAnaKnCfMt9TiJwnD1cXQUGkey/0h6i/+4TVHVMCz5/Ri5u1ynovPNHy1SnBf858QwoXHkxilGLwQX/g=="], + + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.68.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "enquirer": "^2.4.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-YGApsPyDmonlqPFGd5d9Gp5O4JQjmdQsoLYtXHTZb0bhlaCCjj69lWbXKYucCplUgvsorQqc5RRqA+tynjb1lA=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -447,19 +542,17 @@ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], - - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], @@ -979,7 +1072,7 @@ "@tanstack/eslint-config": ["@tanstack/eslint-config@0.4.0", "", { "dependencies": { "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.8.0", "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-n": "^17.24.0", "globals": "^17.3.0", "typescript-eslint": "^8.55.0", "vue-eslint-parser": "^10.4.0" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-V+Cd81W/f65dqKJKpytbwTGx9R+IwxKAHsG/uJ3nSLYEh36hlAr54lRpstUhggQB8nf/cP733cIw8DuD2dzQUg=="], - "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.100.11", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-4JfaSf6/ql9AFAsRWaWulz40gS86bDgSr15pWCI3o+oX3sdZ0ZR8AOeNrCEqyIrV6wFxnCfhFi1kWjOlZ+66Ew=="], + "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.100.14", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-NbpiBCmeHTRuVHeV5+U+1bzmxyTW5Dzp2sCeE6Hx+ZJTJWFK9dsm8VZmRc7LQP9/ZORsF620PvgUk67AwiBo4A=="], "@tanstack/eslint-plugin-router": ["@tanstack/eslint-plugin-router@1.162.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.23.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-0mv+5fOnWXeorh6zd3VyHVfpejIzc7+z68Ts0CQMDzMiwjM5rvea46fyl2m50zx5JFennsu7RO/QJAfnqkKJXw=="], @@ -989,7 +1082,7 @@ "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], - "@tanstack/query-core": ["@tanstack/query-core@5.100.11", "", {}, "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="], "@tanstack/query-devtools": ["@tanstack/query-devtools@5.100.14", "", {}, "sha512-g96SmSSQecYTYcyuAMRXr895GplJv01UGt7qttQWPOUyZ5EGz5tbRc589bMc2m5BsPFD6O0PCEAHdbDYNP6UBw=="], @@ -1083,6 +1176,8 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -1197,6 +1292,8 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -1219,9 +1316,9 @@ "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], "better-auth": ["better-auth@1.6.11", "", { "dependencies": { "@better-auth/core": "1.6.11", "@better-auth/drizzle-adapter": "1.6.11", "@better-auth/kysely-adapter": "1.6.11", "@better-auth/memory-adapter": "1.6.11", "@better-auth/mongo-adapter": "1.6.11", "@better-auth/prisma-adapter": "1.6.11", "@better-auth/telemetry": "1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ=="], @@ -1233,7 +1330,7 @@ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -1287,9 +1384,7 @@ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "comment-parser": ["comment-parser@1.4.6", "", {}, "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg=="], - - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "comment-parser": ["comment-parser@1.4.7", "", {}, "sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], @@ -1329,6 +1424,8 @@ "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "date-fns": ["date-fns@4.3.0", "", {}, "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ=="], + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], "db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], @@ -1385,7 +1482,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.360", "", {}, "sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.361", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -1393,7 +1490,9 @@ "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], - "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], + "enhanced-resolve": ["enhanced-resolve@5.22.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A=="], + + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], @@ -1409,7 +1508,7 @@ "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], @@ -1419,7 +1518,7 @@ "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], + "eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], "eslint-compat-utils": ["eslint-compat-utils@0.5.1", "", { "dependencies": { "semver": "^7.5.4" }, "peerDependencies": { "eslint": ">=6.0.0" } }, "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q=="], @@ -1431,7 +1530,7 @@ "eslint-plugin-n": ["eslint-plugin-n@17.24.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", "enhanced-resolve": "^5.17.1", "eslint-plugin-es-x": "^7.8.0", "get-tsconfig": "^4.8.1", "globals": "^15.11.0", "globrex": "^0.1.2", "ignore": "^5.3.2", "semver": "^7.6.3", "ts-declaration-location": "^1.0.6" }, "peerDependencies": { "eslint": ">=8.23.0" } }, "sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw=="], - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], @@ -1513,6 +1612,8 @@ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], @@ -1559,8 +1660,6 @@ "h3-v2": ["h3@2.0.1-rc.20", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.13" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], @@ -1569,7 +1668,7 @@ "hepburn": ["hepburn@1.2.2", "", {}, "sha512-DeykBc4XmfAWsnN+Y1Svi9uaQnnz21Q/ARuGWvIBxP1iUFeMIWL41DfVkgTh7tU23LFIbmIBO2Bk17BTPu0kVA=="], - "hono": ["hono@4.12.21", "", {}, "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ=="], + "hono": ["hono@4.12.22", "", {}, "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw=="], "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], @@ -1681,7 +1780,7 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - "libphonenumber-js": ["libphonenumber-js@1.13.2", "", {}, "sha512-S3kmBrptp3yRTm83NUcHy9g1vbwiWMzI8WvY22+koBJ6zkRteLnedBL2VX0MIAGwx2yiyxX4J85pceZyQ6ffgg=="], + "libphonenumber-js": ["libphonenumber-js@1.13.3", "", {}, "sha512-xMkdAMqcyG7iN2WZZmGIfWbYxW4orRkny+0/AXIbwL0xll2zkDX0Vzo/BXFa6+7mh2UvJl9MbcTtHk0YXkFtBA=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -1713,8 +1812,6 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], "lru-cache": ["lru-cache@11.5.0", "", {}, "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA=="], @@ -1749,10 +1846,16 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "motion": ["motion@12.40.0", "", { "dependencies": { "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA=="], + + "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], + + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="], @@ -1769,9 +1872,11 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "nf3": ["nf3@0.3.17", "", {}, "sha512-N9zEWySuJFw+gR0lhS5863YsvNeudOdqRyFvNb+jMXbeTJOdrjDqkCpDginIZfUm0LzT1t1nCRiDeqQm/8kirQ=="], - "nitro": ["nitro-nightly@3.0.260522-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.5", "db0": "^0.3.4", "env-runner": "^0.1.9", "h3": "^2.0.1-rc.22", "hookable": "^6.1.1", "nf3": "^0.3.17", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.2", "srvx": "^0.11.15", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.2.0", "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.60.3", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-Pm0AiQ1nLcreUFZKJcmVI4l9K/ygXoFamIPhc44XJHj9vmt25Rlqjv55frw6sS0L1qHrySpo3xyVVBmR9aTBqA=="], + "nitro": ["nitro@3.0.260522-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.5", "db0": "^0.3.4", "env-runner": "^0.1.9", "h3": "^2.0.1-rc.22", "hookable": "^6.1.1", "nf3": "^0.3.17", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.2", "srvx": "^0.11.15", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.2.0", "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.60.3", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-L/z2eOWgkiQHc65kv+SEMgau505afSRF7NJlbooaaZEZscFrNSD7rXZzeVubQlgIzPbhOG8o73bk9soIiGTHRA=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], @@ -1779,7 +1884,7 @@ "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], - "node-releases": ["node-releases@2.0.45", "", {}, "sha512-iIbHXV9eBB2nB0wa7oTsrrXq+qQt+9SIlx9AX3T96YgobtEQfis5n6TJ6vV+3QP8DwdriEAcGhARaFCu37peBg=="], + "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], "node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="], @@ -1933,6 +2038,10 @@ "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="], + + "react-intersection-observer": ["react-intersection-observer@10.0.3", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-luICLMbs0zxTO/70Zy7K5jOXkABPEVSAF8T3FdZUlctsrIaPLmx8TZe2SSA+CY2HGWfz2INyNTnp82pxNNsShA=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -1995,13 +2104,13 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shadcn": ["shadcn@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-70fwnesNrY1GgeD7Kdzn+3SsYeyfibm8immsA5L68+OusoPTvYF01oWExl8/latKpMpvVXcbgdbbE6VFBJQ38w=="], + "shadcn": ["shadcn@4.8.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-LAm3I/1FdoU/zu5GVG8Hbna4X9zlzEG5TeeCPXqsopkjvGk8QUF9OFhqeRN8oM6Oh/ynUI/yQHZxQAO3Ymcqsg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], @@ -2019,6 +2128,8 @@ "solid-js": ["solid-js@1.9.13", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -2029,7 +2140,7 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="], + "srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="], "stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], @@ -2055,12 +2166,8 @@ "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "svix": ["svix@1.92.2", "", { "dependencies": { "standardwebhooks": "1.0.0" } }, "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ=="], "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], @@ -2077,7 +2184,7 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + "tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], @@ -2163,6 +2270,8 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + "vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], @@ -2199,7 +2308,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], @@ -2251,8 +2360,6 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], @@ -2273,19 +2380,13 @@ "@tailwindcss/typography/postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], - "@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="], - "@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@tanstack/start-plugin-core/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "@ts-morph/common/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "@typescript-eslint/typescript-estree/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -2311,21 +2412,21 @@ "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "eslint/@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], - - "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "eslint/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint-compat-utils/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "eslint/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], - "eslint-plugin-import-x/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "eslint-compat-utils/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - "eslint-plugin-import-x/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "eslint-plugin-import-x/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "eslint-plugin-n/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], - "eslint-plugin-n/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "eslint-plugin-n/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -2361,7 +2462,7 @@ "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "tough-cookie/tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], + "tough-cookie/tldts": ["tldts@7.1.1", "", { "dependencies": { "tldts-core": "^7.1.1" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-VuvOq9QVVdzQyIwynB0MRZlEup+u5BD62FjgmKvRDFO8u1RgAzpeg7Qd70hUmrxwkkecqoz1N6t1yGMygx7rnA=="], "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], @@ -2369,7 +2470,9 @@ "vue-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "vue-eslint-parser/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "vue-eslint-parser/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "vue-eslint-parser/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -2441,10 +2544,6 @@ "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "cheerio/htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -2455,15 +2554,11 @@ "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "eslint-plugin-import-x/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - - "eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "parse5-htmlparser2-tree-adapter/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "parse5-parser-stream/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "tough-cookie/tldts/tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="], + "tough-cookie/tldts/tldts-core": ["tldts-core@7.1.1", "", {}, "sha512-v9zYcyFEAJBeyG7g4+y/HFL9i2cHqpV+9cHohNZIhA6xjO2MSVgijFgx6quQaRBDzM5FT8fs5NPjsNITOhlCzg=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], @@ -2522,11 +2617,5 @@ "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "eslint-plugin-import-x/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..284ef66 --- /dev/null +++ b/opencode.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "better-auth": { + "type": "remote", + "url": "https://mcp.better-auth.com/mcp", + "enabled": true + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index 5a8494f..6a28538 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ ], "catalog": { "@better-auth/infra": "^0.2.8", + "@bprogress/react": "^1.2.7", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/outfit": "^5.2.8", "@orpc/client": "^1.14.3", @@ -31,12 +32,12 @@ "@tanstack/eslint-plugin-router": "^1.162.0", "@tanstack/react-devtools": "latest", "@tanstack/react-form": "latest", - "@tanstack/react-query": "latest", - "@tanstack/react-query-devtools": "latest", - "@tanstack/react-router": "latest", - "@tanstack/react-router-devtools": "latest", - "@tanstack/react-router-ssr-query": "latest", - "@tanstack/react-start": "latest", + "@tanstack/react-query": "^5.100.14", + "@tanstack/react-query-devtools": "^5.100.14", + "@tanstack/react-router": "^1.170.8", + "@tanstack/react-router-devtools": "^1.167.0", + "@tanstack/react-router-ssr-query": "^1.167.0", + "@tanstack/react-start": "^1.168.9", "@tanstack/router-plugin": "^1.132.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", @@ -51,13 +52,19 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^17.4.2", + "date-fns": "^4.3.0", + "@dicebear/collection": "^9.4.2", + "@dicebear/core": "^9.4.2", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", "drizzle-zod": "^0.8.3", "jsdom": "^28.1.0", "limax": "^4.2.3", "lucide-react": "^0.545.0", + "motion": "^12.40.0", "nanoid": "^5.1.11", + "next-themes": "^0.4.6", + "nitro": "^3.0.260522-beta", "oxfmt": "^0.51.0", "oxlint": "^1.66.0", "oxlint-tsgolint": "^0.23.0", @@ -65,8 +72,11 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-icons": "^5.6.0", + "react-intersection-observer": "^10.0.3", "resend": "^6.12.3", "shadcn": "^4.7.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.1.18", "turbo": "^2.9.14", diff --git a/packages/api/src/routers/threads.router.ts b/packages/api/src/routers/threads.router.ts index 4afbbc4..494f5bc 100644 --- a/packages/api/src/routers/threads.router.ts +++ b/packages/api/src/routers/threads.router.ts @@ -16,6 +16,11 @@ import { CursorError, generateSlug } from '@trid/shared/utils' const MAX_SLUG_ATTEMPTS = 3 +type SlugRetryErrors = { + CONFLICT: (args: { message: string }) => Error + INTERNAL_SERVER_ERROR: (args: { message: string }) => Error +} + const createThreadHandler = protectedProcedure.threads.create.handler( async ({ context, errors, input }) => { const { user } = context.auth @@ -172,10 +177,7 @@ export const orpcThreadsRouter = { async function createWithSlugRetry( fn: (attempt: number) => Promise>, - errors: { - CONFLICT: (args: { message: string }) => unknown - INTERNAL_SERVER_ERROR: (args: { message: string }) => unknown - }, + errors: SlugRetryErrors, attempts: number, currentAttempt = 0 ): Promise { @@ -196,7 +198,11 @@ async function createWithSlugRetry( return result } catch (error) { - if (isUniqueViolation(error)) { + if ( + typeof error === 'object' && + error !== null && + isUniqueViolation(error) + ) { return createWithSlugRetry(fn, errors, attempts, currentAttempt + 1) } @@ -216,15 +222,14 @@ function mapOwnThread( username: user.username, image: user.image, }, - uservote: null, + userVote: null, } } -function isUniqueViolation(error: unknown) { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - error.code === '23505' - ) +function isUniqueViolation(error: object) { + return hasStringCode(error) && error.code === '23505' +} + +function hasStringCode(error: object): error is { code: string } { + return 'code' in error && typeof error.code === 'string' } diff --git a/packages/api/src/routers/votes.router.ts b/packages/api/src/routers/votes.router.ts index 2f9f132..25ebb42 100644 --- a/packages/api/src/routers/votes.router.ts +++ b/packages/api/src/routers/votes.router.ts @@ -3,7 +3,10 @@ import { protectedProcedure } from '../middlewares' import { VoteError, voteReplyQuery, voteThreadQuery } from '@trid/db/queries' import type { VoteErrorCode } from '@trid/db/queries' -const voteErrorToStatus: Record = { +const voteErrorToStatus: Record< + VoteErrorCode, + 'NOT_FOUND' | 'BAD_REQUEST' | 'INTERNAL_SERVER_ERROR' +> = { THREAD_NOT_FOUND: 'NOT_FOUND', REPLY_NOT_FOUND: 'NOT_FOUND', DELETED_REPLY: 'BAD_REQUEST', diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index e56b046..cc4f3d3 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -12,7 +12,9 @@ import { Resend } from 'resend' import { db } from '@trid/db' import * as schema from '@trid/db/schemas' + import { env } from '@trid/env/server' + import { PasswordResetEmail } from '@trid/ui/components/email/email-reset-password' const resend = new Resend(env.RESEND_API_KEY) @@ -92,6 +94,7 @@ export function createAuth() { unique: true, input: true, fieldName: 'username', + defaultValue: `u_${crypto.randomUUID().slice(0, 8)}`, }, banner: { type: 'string', @@ -123,6 +126,13 @@ export function createAuth() { required: true, input: false, }, + onBoardingCompleted: { + type: 'boolean', + defaultValue: false, + required: true, + input: true, + fieldName: 'onBoardingCompleted', + }, }, }, }) diff --git a/packages/db/src/queries/reply.query.ts b/packages/db/src/queries/reply.query.ts index 7481df0..9dbadf3 100644 --- a/packages/db/src/queries/reply.query.ts +++ b/packages/db/src/queries/reply.query.ts @@ -36,6 +36,16 @@ const PUBLISHED_STATUS = 'PUBLISHED' const LIST_LIMIT_EXTRA = 1 const CHILD_REPLY_PREVIEW_LIMIT = 2 +type ReplyListCursorItem = { + id: string + createdAt: Date + votesScoresCount: number +} + +type RowWithNullableVote = { + userVote: TVote | null +} + type ReplyRow = typeof repliesTable.$inferSelect type ReplyListItem = Awaited>[number] @@ -650,27 +660,22 @@ function getListCursorFilter( function getListCursorValues( sort: ReplySort, - item: unknown + item: ReplyListCursorItem | undefined ): CursorValueSchemaWithKey { if (!item) throw new CursorError() - const reply = item as { - id: string - createdAt: Date - votesScoresCount: number - } switch (sort) { case 'top': return { - votesScoresCount: reply.votesScoresCount, - createdAt: reply.createdAt.toISOString(), - id: reply.id, + votesScoresCount: item.votesScoresCount, + createdAt: item.createdAt.toISOString(), + id: item.id, } case 'latest': case 'oldest': return { - createdAt: reply.createdAt.toISOString(), - id: reply.id, + createdAt: item.createdAt.toISOString(), + id: item.id, } } } @@ -683,11 +688,13 @@ function childRepliesCursorScope(parentReplyId: string, sort: ReplySort) { return `replies:list:parent:${parentReplyId}:${sort}` } -function normalizeRows(rows: Array) { +function normalizeRows>( + rows: Array +) { return rows.map((row) => normalizeRow(row)) } -function normalizeRow(row: T) { +function normalizeRow>(row: T) { return { ...row, userVote: row.userVote ?? null, diff --git a/packages/db/src/queries/thread.query.ts b/packages/db/src/queries/thread.query.ts index ea4a688..4c04987 100644 --- a/packages/db/src/queries/thread.query.ts +++ b/packages/db/src/queries/thread.query.ts @@ -19,6 +19,24 @@ import type { CursorValueSchemaWithKey } from '@trid/shared/utils' const PUBLISHED_STATUS = 'PUBLISHED' const LIST_LIMIT_EXTRA = 1 +type ThreadListCursorItem = { + id: string + createdAt: Date + lastActivityAt: Date + votesScoresCount: number + repliesCount: number +} + +type ThreadTopCursorItem = { + id: string + createdAt: Date + rankingScore: number +} + +type RowWithNullableVote = { + userVote: TVote | null +} + export type CreateThreadValues = { authorId: string title: string @@ -69,7 +87,7 @@ export async function listThreadsQuery( .select({ ...threadListSelection, author: authorSelection, - uservote: viewerVotesTable.direction, + userVote: viewerVotesTable.direction, }) .from(threadsTable) .innerJoin(userTable, eq(userTable.id, threadsTable.authorId)) @@ -119,7 +137,7 @@ export async function topThreadsQuery( .select({ ...threadListSelection, author: authorSelection, - uservote: viewerVotesTable.direction, + userVote: viewerVotesTable.direction, rankingScore, }) .from(threadsTable) @@ -157,7 +175,7 @@ export async function topThreadsQuery( .select({ ...threadListSelection, author: authorSelection, - uservote: viewerVotesTable.direction, + userVote: viewerVotesTable.direction, rankingScore, }) .from(threadsTable) @@ -211,7 +229,7 @@ export async function getThreadBySlugQuery( .select({ ...threadDetailSelection, author: authorSelection, - uservote: viewerVotesTable.direction, + userVote: viewerVotesTable.direction, }) .from(threadsTable) .innerJoin(userTable, eq(userTable.id, threadsTable.authorId)) @@ -439,52 +457,42 @@ function getTopCursorFilter( function getListCursorValues( feed: ThreadFeed, - item: unknown + item: ThreadListCursorItem | undefined ): CursorValueSchemaWithKey { if (!item) throw new CursorError() - const thread = item as { - id: string - createdAt: Date - lastActivityAt: Date - votesScoresCount: number - repliesCount: number - } switch (feed) { case 'discover': return { - lastActivityAt: thread.lastActivityAt.toISOString(), - votesScoresCount: thread.votesScoresCount, - createdAt: thread.createdAt.toISOString(), - id: thread.id, + lastActivityAt: item.lastActivityAt.toISOString(), + votesScoresCount: item.votesScoresCount, + createdAt: item.createdAt.toISOString(), + id: item.id, } case 'popular': return { - votesScoresCount: thread.votesScoresCount, - repliesCount: thread.repliesCount, - createdAt: thread.createdAt.toISOString(), - id: thread.id, + votesScoresCount: item.votesScoresCount, + repliesCount: item.repliesCount, + createdAt: item.createdAt.toISOString(), + id: item.id, } case 'latest': return { - createdAt: thread.createdAt.toISOString(), - id: thread.id, + createdAt: item.createdAt.toISOString(), + id: item.id, } } } -function getTopCursorValues(item: unknown): CursorValueSchemaWithKey { +function getTopCursorValues( + item: ThreadTopCursorItem | undefined +): CursorValueSchemaWithKey { if (!item) throw new CursorError() - const thread = item as { - id: string - createdAt: Date - rankingScore: number - } return { - rankingScore: thread.rankingScore, - createdAt: thread.createdAt.toISOString(), - id: thread.id, + rankingScore: item.rankingScore, + createdAt: item.createdAt.toISOString(), + id: item.id, } } @@ -517,13 +525,15 @@ function getPeriodStart( } } -function normalizeRows(rows: Array) { +function normalizeRows>( + rows: Array +) { return rows.map((row) => normalizeRow(row)) } -function normalizeRow(row: T) { +function normalizeRow>(row: T) { return { ...row, - uservote: row.uservote ?? null, + userVote: row.userVote ?? null, } } diff --git a/packages/db/src/queries/vote.query.ts b/packages/db/src/queries/vote.query.ts index 3bb6575..cc2c112 100644 --- a/packages/db/src/queries/vote.query.ts +++ b/packages/db/src/queries/vote.query.ts @@ -6,7 +6,6 @@ import { and, eq, sql } from 'drizzle-orm' import type { Database } from '@trid/db' import type { - VoteAction, VoteDirectionNullable, VoteReplyInput, VoteReplyOutput, @@ -38,7 +37,6 @@ export const voteThreadQuery = ( return db.transaction(async (tx) => { const { slug, direction } = input - let voteAction: VoteAction let voteDirection: VoteDirectionNullable let voteScoreCount: number @@ -75,7 +73,6 @@ export const voteThreadQuery = ( direction, }) - voteAction = 'VOTED' voteDirection = direction voteScoreCount = direction === 'UPVOTE' ? 1 : -1 } else if (existingVote.direction === direction) { @@ -88,7 +85,6 @@ export const voteThreadQuery = ( ) ) - voteAction = 'UNVOTED' voteDirection = null voteScoreCount = direction === 'UPVOTE' ? -1 : 1 } else { @@ -104,7 +100,6 @@ export const voteThreadQuery = ( ) ) - voteAction = 'CHANGED' voteDirection = direction voteScoreCount = direction === 'UPVOTE' ? 2 : -2 } @@ -125,9 +120,8 @@ export const voteThreadQuery = ( } return { - voteAction, - voteDirection, - voteScoreCount: updateThread.votesScoresCount, + userVote: voteDirection, + votesScoresCount: updateThread.votesScoresCount, } }) } @@ -140,7 +134,6 @@ export const voteReplyQuery = ( return db.transaction(async (tx) => { const { id, direction } = input - let voteAction: VoteAction let voteDirection: VoteDirectionNullable let voteScoreCount: number @@ -182,7 +175,6 @@ export const voteReplyQuery = ( direction, }) - voteAction = 'VOTED' voteDirection = direction voteScoreCount = direction === 'UPVOTE' ? 1 : -1 } else if (existingVote.direction === direction) { @@ -195,7 +187,6 @@ export const voteReplyQuery = ( ) ) - voteAction = 'UNVOTED' voteDirection = null voteScoreCount = direction === 'UPVOTE' ? -1 : 1 } else { @@ -211,7 +202,6 @@ export const voteReplyQuery = ( ) ) - voteAction = 'CHANGED' voteDirection = direction voteScoreCount = direction === 'UPVOTE' ? 2 : -2 } @@ -232,9 +222,8 @@ export const voteReplyQuery = ( } return { - voteAction, - voteDirection, - voteScoreCount: updateThread.votesScoresCount, + userVote: voteDirection, + votesScoresCount: updateThread.votesScoresCount, } }) } diff --git a/packages/db/src/schemas/auth.ts b/packages/db/src/schemas/auth.ts index 97370cf..21a4404 100644 --- a/packages/db/src/schemas/auth.ts +++ b/packages/db/src/schemas/auth.ts @@ -24,6 +24,7 @@ export const userTable = pgTable('user', { location: text('location'), verified: boolean('verified').default(false).notNull(), role: text('role'), + onBoardingCompleted: boolean('onboarding_completed').default(false).notNull(), banned: boolean('banned').default(false), banReason: text('ban_reason'), banExpires: timestamp('ban_expires'), diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index aee59c8..d65009d 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -15,3 +15,7 @@ export type VoteDirection = (typeof VOTE_DIRECTIONS)[number] export type ThreadFeed = (typeof THREAD_FEEDS)[number] export type ThreadTopPeriod = (typeof THREAD_TOP_PERIODS)[number] export type ReplySort = (typeof REPLY_SORTS)[number] + +export const THREADS_DEFAULT_LIMIT = 10 +export const THREADS_MAX_LIMIT = 50 +export const THREADS_MIN_LIMIT = 1 diff --git a/packages/shared/src/schemas/threads.test.ts b/packages/shared/src/schemas/threads.test.ts index 92b25e3..270863e 100644 --- a/packages/shared/src/schemas/threads.test.ts +++ b/packages/shared/src/schemas/threads.test.ts @@ -2,13 +2,19 @@ import { createThreadInputSchema, + threadDetailOutputSchema, listThreadsInputSchema, topThreadsInputSchema, updateThreadInputSchema, } from './threads' import { describe, expect, test } from 'bun:test' +import { THREADS_DEFAULT_LIMIT } from '@trid/shared/constants' + describe('thread schemas', () => { + const THREAD_ID = '7c5f6dfa-21f9-4498-a30e-07d960297ad0' + const USER_ID = '645128a8-4bf4-46ed-bb70-4f7a6f746393' + test('validates create thread input', () => { const input = createThreadInputSchema.parse({ title: ' Hello world ', @@ -24,35 +30,90 @@ describe('thread schemas', () => { test('applies list defaults', () => { expect(listThreadsInputSchema.parse({})).toEqual({ feed: 'discover', - limit: 20, + limit: THREADS_DEFAULT_LIMIT, }) }) test('applies top defaults', () => { expect(topThreadsInputSchema.parse({})).toEqual({ period: 'week', - limit: 20, + limit: THREADS_DEFAULT_LIMIT, }) }) test('rejects empty update payload', () => { expect(() => updateThreadInputSchema.parse({ - id: '7c5f6dfa-21f9-4498-a30e-07d960297ad0', + slug: 'hello-world', }) ).toThrow() }) - test('does not accept slug updates', () => { + test('does not accept id updates', () => { const input = updateThreadInputSchema.parse({ - id: '7c5f6dfa-21f9-4498-a30e-07d960297ad0', + slug: 'hello-world', title: 'Renamed thread', - slug: 'renamed-thread', + id: THREAD_ID, }) expect(input).toEqual({ - id: '7c5f6dfa-21f9-4498-a30e-07d960297ad0', + slug: 'hello-world', title: 'Renamed thread', }) }) + + test('accepts thread output with userVote', () => { + const now = new Date() + const thread = threadDetailOutputSchema.parse({ + id: THREAD_ID, + authorId: USER_ID, + title: 'Hello world', + slug: 'hello-world', + status: 'PUBLISHED', + votesScoresCount: 1, + repliesCount: 0, + lastActivityAt: now, + createdAt: now, + updatedAt: now, + deletedAt: null, + content: 'First post', + author: { + id: USER_ID, + name: 'Jane', + username: 'jane', + image: null, + }, + userVote: 'UPVOTE', + }) + + expect(thread.userVote).toBe('UPVOTE') + }) + + test('rejects old uservote output field', () => { + const now = new Date() + + expect(() => + threadDetailOutputSchema.parse({ + id: THREAD_ID, + authorId: USER_ID, + title: 'Hello world', + slug: 'hello-world', + status: 'PUBLISHED', + votesScoresCount: 1, + repliesCount: 0, + lastActivityAt: now, + createdAt: now, + updatedAt: now, + deletedAt: null, + content: 'First post', + author: { + id: USER_ID, + name: 'Jane', + username: 'jane', + image: null, + }, + uservote: 'UPVOTE', + }) + ).toThrow() + }) }) diff --git a/packages/shared/src/schemas/threads.ts b/packages/shared/src/schemas/threads.ts index 6e562d4..8fe897a 100644 --- a/packages/shared/src/schemas/threads.ts +++ b/packages/shared/src/schemas/threads.ts @@ -4,6 +4,9 @@ import { THREAD_FEEDS, THREAD_STATUSES, THREAD_TOP_PERIODS, + THREADS_DEFAULT_LIMIT, + THREADS_MAX_LIMIT, + THREADS_MIN_LIMIT, } from '@trid/shared/constants' import { publicUserOutputSchema } from '@trid/shared/schemas/user' import { voteDirectionNullableSchema } from '@trid/shared/schemas/votes' @@ -20,7 +23,12 @@ const contentSchema = z .trim() .min(1, { message: 'Content is required' }) const cursorSchema = z.string().optional() -const limitSchema = z.coerce.number().int().min(1).max(50).default(20) +const limitSchema = z.coerce + .number() + .int() + .min(THREADS_MIN_LIMIT) + .max(THREADS_MAX_LIMIT) + .default(THREADS_DEFAULT_LIMIT) export const threadStatusSchema = z.enum(THREAD_STATUSES) export const threadFeedSchema = z.enum(THREAD_FEEDS) @@ -75,10 +83,12 @@ const threadBaseOutputSchema = z.object({ deletedAt: z.date().nullable(), }) -export const threadListItemOutputSchema = threadBaseOutputSchema.extend({ - author: publicUserOutputSchema, - uservote: voteDirectionNullableSchema, -}) +export const threadListItemOutputSchema = threadBaseOutputSchema + .extend({ + author: publicUserOutputSchema, + userVote: voteDirectionNullableSchema, + }) + .strict() export const topThreadListItemOutputSchema = threadListItemOutputSchema.extend({ rankingScore: z.number().int(), diff --git a/packages/shared/src/schemas/votes.ts b/packages/shared/src/schemas/votes.ts index 3233dac..0a78d80 100644 --- a/packages/shared/src/schemas/votes.ts +++ b/packages/shared/src/schemas/votes.ts @@ -1,12 +1,11 @@ import { z } from 'zod' -import { VOTE_ACTIONS, VOTE_DIRECTIONS } from '@trid/shared/constants' +import { VOTE_DIRECTIONS } from '@trid/shared/constants' export const voteDirectionSchema = z.enum(VOTE_DIRECTIONS) export const voteDirectionNullableSchema = voteDirectionSchema .nullable() .default(null) -export const voteActionSchema = z.enum(VOTE_ACTIONS) export const voteThreadInputSchema = z.object({ slug: z.string().min(1, { error: 'Thread slug is required' }), @@ -14,9 +13,8 @@ export const voteThreadInputSchema = z.object({ }) export const voteThreadOutputSchema = z.object({ - voteAction: voteActionSchema, - voteDirection: voteDirectionNullableSchema, - voteScoreCount: z.number().int().default(0), + userVote: voteDirectionNullableSchema, + votesScoresCount: z.number().int().default(0), }) export const voteReplyInputSchema = z.object({ @@ -25,14 +23,12 @@ export const voteReplyInputSchema = z.object({ }) export const voteReplyOutputSchema = z.object({ - voteAction: voteActionSchema, - voteDirection: voteDirectionNullableSchema, - voteScoreCount: z.number().int().default(0), + userVote: voteDirectionNullableSchema, + votesScoresCount: z.number().int().default(0), }) export type VoteDirectionSchemaValue = z.infer export type VoteDirectionNullable = z.infer -export type VoteAction = z.infer export type VoteThreadInput = z.infer export type VoteThreadOutput = z.infer diff --git a/packages/ui/components.json b/packages/ui/components.json index 80e750d..49f1a5a 100644 --- a/packages/ui/components.json +++ b/packages/ui/components.json @@ -1,6 +1,6 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "radix-lyra", + "style": "radix-mira", "rsc": false, "tsx": true, "tailwind": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 45f1d24..c8a3790 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -16,10 +16,15 @@ "@tabler/icons-react": "catalog:", "class-variance-authority": "catalog:", "clsx": "catalog:", + "next-themes": "catalog:", "radix-ui": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", "shadcn": "catalog:", + "sonner": "catalog:", "tailwind-merge": "catalog:", "tw-animate-css": "catalog:", + "vaul": "^1.1.2", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx new file mode 100644 index 0000000..0fb0b84 --- /dev/null +++ b/packages/ui/src/components/avatar.tsx @@ -0,0 +1,111 @@ +import * as React from 'react' + +import { Avatar as AvatarPrimitive } from 'radix-ui' + +import { cn } from '@trid/ui/lib/utils' + +function Avatar({ + className, + size = 'default', + ...props +}: React.ComponentProps & { + size?: 'default' | 'sm' | 'lg' +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) { + return ( + svg]:hidden', + 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2', + 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2', + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3', + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/packages/ui/src/components/button-group.tsx b/packages/ui/src/components/button-group.tsx new file mode 100644 index 0000000..34e895a --- /dev/null +++ b/packages/ui/src/components/button-group.tsx @@ -0,0 +1,84 @@ +import { cva } from 'class-variance-authority' +import type { VariantProps } from 'class-variance-authority' +import { Slot } from 'radix-ui' + +import { Separator } from '@trid/ui/components/separator' +import { cn } from '@trid/ui/lib/utils' + +const buttonGroupVariants = cva( + "group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md!', + vertical: + 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md!', + }, + }, + defaultVariants: { + orientation: 'horizontal', + }, + } +) + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ) +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<'div'> & { + asChild?: boolean +}) { + const Comp = asChild ? Slot.Root : 'div' + + return ( + + ) +} + +function ButtonGroupSeparator({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 76e755d..029d310 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -1,65 +1,67 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" -import { Slot } from "radix-ui" +import * as React from 'react' -import { cn } from "@trid/ui/lib/utils" +import { cva } from 'class-variance-authority' +import type { VariantProps } from 'class-variance-authority' +import { Slot } from 'radix-ui' + +import { cn } from '@trid/ui/lib/utils' const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", - outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-7 rounded-none", - "icon-lg": "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } + "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/80', + outline: + 'border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', + ghost: + 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50', + destructive: + 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: + "h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5", + sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4", + icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5", + 'icon-xs': "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5", + 'icon-sm': "size-6 [&_svg:not([class*='size-'])]:size-3", + 'icon-lg': "size-8 [&_svg:not([class*='size-'])]:size-4", + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } ) function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot.Root : "button" + className, + variant = 'default', + size = 'default', + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : 'button' - return ( - - ) + return ( + + ) } export { Button, buttonVariants } diff --git a/packages/ui/src/components/card.tsx b/packages/ui/src/components/card.tsx new file mode 100644 index 0000000..e4f4da9 --- /dev/null +++ b/packages/ui/src/components/card.tsx @@ -0,0 +1,100 @@ +import * as React from 'react' + +import { cn } from '@trid/ui/lib/utils' + +function Card({ + className, + size = 'default', + ...props +}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg', + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx new file mode 100644 index 0000000..12dc45f --- /dev/null +++ b/packages/ui/src/components/dialog.tsx @@ -0,0 +1,163 @@ +import * as React from 'react' + +import { IconX } from '@tabler/icons-react' +import { Dialog as DialogPrimitive } from 'radix-ui' + +import { Button } from '@trid/ui/components/button' +import { cn } from '@trid/ui/lib/utils' + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<'div'> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/packages/ui/src/components/drawer.tsx b/packages/ui/src/components/drawer.tsx new file mode 100644 index 0000000..2a329d4 --- /dev/null +++ b/packages/ui/src/components/drawer.tsx @@ -0,0 +1,133 @@ +import * as React from 'react' + +import { Drawer as DrawerPrimitive } from 'vaul' + +import { cn } from '@trid/ui/lib/utils' + +function Drawer({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ) +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx new file mode 100644 index 0000000..514f343 --- /dev/null +++ b/packages/ui/src/components/dropdown-menu.tsx @@ -0,0 +1,274 @@ +'use client' + +import * as React from 'react' + +import { IconCheck, IconChevronRight } from '@tabler/icons-react' +import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui' + +import { cn } from '@trid/ui/lib/utils' + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + align = 'start', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: 'default' | 'destructive' +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<'span'>) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/packages/ui/src/components/empty.tsx b/packages/ui/src/components/empty.tsx new file mode 100644 index 0000000..2d6037c --- /dev/null +++ b/packages/ui/src/components/empty.tsx @@ -0,0 +1,105 @@ +import { cva } from 'class-variance-authority' +import type { VariantProps } from 'class-variance-authority' + +import { cn } from '@trid/ui/lib/utils' + +function Empty({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function 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-8 shrink-0 items-center justify-center rounded-md bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4", + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +function EmptyMedia({ + className, + variant = 'default', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ) +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary', + className + )} + {...props} + /> + ) +} + +function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} diff --git a/packages/ui/src/components/field.tsx b/packages/ui/src/components/field.tsx new file mode 100644 index 0000000..8095a27 --- /dev/null +++ b/packages/ui/src/components/field.tsx @@ -0,0 +1,238 @@ +import { useMemo } from 'react' + +import { cva } from 'class-variance-authority' +import type { VariantProps } from 'class-variance-authority' + +import { Label } from '@trid/ui/components/label' +import { Separator } from '@trid/ui/components/separator' +import { cn } from '@trid/ui/lib/utils' + +function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = 'legend', + ...props +}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +const fieldVariants = cva( + 'group/field flex w-full gap-2 data-[invalid=true]:text-destructive', + { + variants: { + orientation: { + vertical: 'flex-col *:w-full [&>.sr-only]:w-auto', + horizontal: + 'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + responsive: + 'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + }, + }, + defaultVariants: { + orientation: 'vertical', + }, + } +) + +function Field({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +