diff --git a/@/components/ui/tabs.tsx b/@/components/ui/tabs.tsx new file mode 100644 index 0000000..f57fffd --- /dev/null +++ b/@/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/@/components/ui/toast.tsx b/@/components/ui/toast.tsx new file mode 100644 index 0000000..a822477 --- /dev/null +++ b/@/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/@/components/ui/toaster.tsx b/@/components/ui/toaster.tsx new file mode 100644 index 0000000..5887f08 --- /dev/null +++ b/@/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +import { useToast } from "@/hooks/use-toast"; +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast"; + +export function Toaster() { + const { toasts } = useToast(); + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ); + })} + +
+ ); +} diff --git a/@/components/ui/tooltip.tsx b/@/components/ui/tooltip.tsx new file mode 100644 index 0000000..e121f0a --- /dev/null +++ b/@/components/ui/tooltip.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/@/hooks/use-toast.ts b/@/hooks/use-toast.ts new file mode 100644 index 0000000..02e111d --- /dev/null +++ b/@/hooks/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/app/client/convertUtcDateToLocalDateString.ts b/app/client/convertUtcDateToLocalDateString.ts new file mode 100644 index 0000000..eb44744 --- /dev/null +++ b/app/client/convertUtcDateToLocalDateString.ts @@ -0,0 +1,15 @@ +/** + * @description + * Takes a UTC date formatted string and returns the date as a date string + * formatted as the clients local time. + */ + +export const convertUtcDateToLocalDateString = ( + date: string | Date +): string => { + // const momentObject = convertUtcDateToLocalMomentObject(UTCDateString); + // return momentObject.toLocaleString(); + // return momentObject.local().format(DATE_FORMAT); + + return new Date(date).toLocaleString(); +}; diff --git a/app/client/fallbackImageSource.ts b/app/client/fallbackImageSource.ts new file mode 100644 index 0000000..0319323 --- /dev/null +++ b/app/client/fallbackImageSource.ts @@ -0,0 +1,2 @@ +export const fallbackImageSource = + "data:image/png;base64,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"; diff --git a/app/client/index.ts b/app/client/index.ts new file mode 100644 index 0000000..daef4ec --- /dev/null +++ b/app/client/index.ts @@ -0,0 +1,2 @@ +export * from "./convertUtcDateToLocalDateString"; +export * from "./fallbackImageSource"; diff --git a/app/components/CopyToClipboardButton.tsx b/app/components/CopyToClipboardButton.tsx new file mode 100644 index 0000000..ec10c8e --- /dev/null +++ b/app/components/CopyToClipboardButton.tsx @@ -0,0 +1,69 @@ +import { Copy, Check } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface CopyToClipboardButtonProps { + stringToCopy: string; +} + +const CopyToClipboardButton = ({ + stringToCopy, +}: CopyToClipboardButtonProps) => { + const { toast } = useToast(); + const [copied, setCopied] = useState(false); + + const handleCopyToClipboard = async () => { + try { + await navigator.clipboard.writeText(stringToCopy); + setCopied(true); + + toast({ + title: "Copied to clipboard", + description: stringToCopy, + duration: 2000, + }); + + // Reset copied state after 2 seconds + setTimeout(() => setCopied(false), 2000); + } catch (err) { + toast({ + title: "Failed to copy", + description: "Please try again", + variant: "destructive", + }); + } + }; + + return ( + + + + + + +

Copy to clipboard

+
+
+
+ ); +}; + +export default CopyToClipboardButton; diff --git a/app/components/GeneralErrorBoundary.tsx b/app/components/GeneralErrorBoundary.tsx index e24f985..1bc3627 100644 --- a/app/components/GeneralErrorBoundary.tsx +++ b/app/components/GeneralErrorBoundary.tsx @@ -24,7 +24,7 @@ type StatusHandler = (info: { params: Record; }) => JSX.Element | null; -export function GeneralErrorBoundary({ +const GeneralErrorBoundary = ({ defaultStatusHandler = ({ error }) => (

{error.status} {getErrorMessage(error.data)} @@ -36,7 +36,7 @@ export function GeneralErrorBoundary({ defaultStatusHandler?: StatusHandler; statusHandlers?: Record; unexpectedErrorHandler?: (error: unknown) => JSX.Element | null; -}) { +}) => { const error = useRouteError(); const params = useParams(); @@ -54,4 +54,6 @@ export function GeneralErrorBoundary({ : unexpectedErrorHandler(error)} ); -} +}; + +export { GeneralErrorBoundary }; diff --git a/app/components/ImageCard.tsx b/app/components/ImageCard.tsx new file mode 100644 index 0000000..a9e2d74 --- /dev/null +++ b/app/components/ImageCard.tsx @@ -0,0 +1,41 @@ +import { Link } from "@remix-run/react"; +// import { Heart, MessageCircle } from "lucide-react"; +import { ImageTagType } from "server/getImages"; + +const ImageCard = ({ + imageData, + onClickRedirectTo = "", +}: { + imageData: ImageTagType; + onClickRedirectTo?: string; +}) => { + const redirectTo = onClickRedirectTo || `/explore/${imageData.id}`; + + return ( +

+ + {imageData.prompt} + + {/* Hover Overlay */} + {/*
+
+
+ + 0 +
+
+ + 0 +
+
+
*/} +
+ ); +}; + +export default ImageCard; diff --git a/app/components/ImageV2.tsx b/app/components/ImageV2.tsx deleted file mode 100644 index 5513563..0000000 --- a/app/components/ImageV2.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// import { useLocation, useNavigate } from "@remix-run/react"; -import { ImageTagType } from "server/getImages"; - -const ImageV2 = ({ imageData }: { imageData: ImageTagType }) => { - // const navigate = useNavigate(); - // const location = useLocation(); - - // const handleImageClick = () => { - // // redirect user to explore.$imageId page when image is clicked - // // (this is the page that shows the image modal) - // navigate( - // `${ - // location.search - // ? `${imageData.id}${location.search}` - // : `${imageData.id}` - // }`, - // ); - // }; - - return ( -
handleImageClick()} - > - {imageData.prompt} -
- ); -}; - -export default ImageV2; diff --git a/app/components/NavigationSidebar.tsx b/app/components/NavigationSidebar.tsx index 856bdff..4921107 100644 --- a/app/components/NavigationSidebar.tsx +++ b/app/components/NavigationSidebar.tsx @@ -50,8 +50,7 @@ const NavigationSidebar = () => { { title: "Profile", icon: , - // href: `/profile/${userData?.username || ""}`, - href: `/profile/`, + href: `/profile/${userData?.id || ""}`, }, // ! TODO: Hide for now, get barebones out first // { diff --git a/app/components/UserAvatarButton.tsx b/app/components/UserAvatarButton.tsx index 9426256..1edb407 100644 --- a/app/components/UserAvatarButton.tsx +++ b/app/components/UserAvatarButton.tsx @@ -66,7 +66,7 @@ const UserAvatarButton = () => { diff --git a/app/components/index.ts b/app/components/index.ts index b19e7f0..5170363 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -3,3 +3,6 @@ export * from "./UserAvatarButton"; export * from "./LogOutButton"; export * from "./PageContainer"; export * from "./CreatePageForm"; +export { default as CopyToClipboardButton } from "./CopyToClipboardButton"; +export { default as ImageCard } from "./ImageCard"; +export * from "./GeneralErrorBoundary"; diff --git a/app/hooks/useLoggedInUser.ts b/app/hooks/useLoggedInUser.ts index b65cbb5..96dfed7 100644 --- a/app/hooks/useLoggedInUser.ts +++ b/app/hooks/useLoggedInUser.ts @@ -1,8 +1,18 @@ import { useRouteLoaderData } from "@remix-run/react"; -import type { loader as rootLoader } from "~/root"; // Make sure to export the loader type from root.tsx +import type { loader as RootLoaderData } from "~/root"; // Make sure to export the loader type from root.tsx -export const useLoggedInUser = () => { - const rootData = useRouteLoaderData("root"); +export function useOptionalUser() { + const data = useRouteLoaderData("root"); + return data?.userData ?? null; +} - return rootData?.userData; -}; +export function useLoggedInUser() { + const maybeUser = useOptionalUser(); + if (!maybeUser) { + console.log( + "No user found in root loader, but user is required by useLoggedInUser. If user is optional, try useOptionalUser instead." + ); + } + + return maybeUser; +} diff --git a/app/pages/ExploreImageDetailsPage.tsx b/app/pages/ExploreImageDetailsPage.tsx new file mode 100644 index 0000000..2fe1af7 --- /dev/null +++ b/app/pages/ExploreImageDetailsPage.tsx @@ -0,0 +1,258 @@ +import React from "react"; +import { useLoggedInUser } from "~/hooks"; +import { useLoaderData } from "@remix-run/react"; +import { ExplorePageImageLoader } from "~/routes/explore.$imageId"; +import { convertUtcDateToLocalDateString, fallbackImageSource } from "~/client"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Heart, MessageCircle, Send, User, Bookmark, Info } from "lucide-react"; +import { CopyToClipboardButton } from "~/components"; + +interface ExploreImageDetailsPageProps { + onClose: () => void; +} + +const ExploreImageDetailsPage = ({ onClose }: ExploreImageDetailsPageProps) => { + const { data: imageData } = useLoaderData(); + + const userData = useLoggedInUser(); + const isUserLoggedIn = Boolean(userData); + const formRef = React.useRef(null); + const isLoadingFetcher = false; + + const handleCommentFormSubmit = (event: React.FormEvent) => { + event.preventDefault(); + formRef.current?.reset(); + }; + + if (!imageData) return null; + + return ( + <> + {/* Dark overlay */} +
+ + + e.preventDefault()} + > +
+ {/* Left side - Image */} +
+
+ {imageData.prompt { + e.currentTarget.src = fallbackImageSource; + }} + /> +
+
+ + {/* Right side - Details */} +
+ {/* Header */} +
+
+
+ + + {imageData.user?.username.charAt(0) || ""} + {/* */} + + + +
+
+
+ + {/* Tabs Section */} + + + + + Comments + + + + Info + + + + +
+ {/* Original Post */} + {/*
+ + + + + +
+
+ + {imageData.user?.username} + + + {imageData.prompt} + +
+
+ + {convertUtcDateToLocalDateString( + imageData.createdAt! + )} + +
+
+
*/} + + {imageData.comments && imageData.comments.length ? ( + imageData.comments.map((comment) => ( +
{comment.message}
+ )) + ) : ( +

+ No comments yet. +

+ )} +
+
+ + +
+
+

Engine Model

+

{imageData.model}

+
+ +
+

Style Preset

+ {imageData.stylePreset ? ( +

+ {imageData.stylePreset} +

+ ) : ( +

none

+ )} +
+ +
+

Prompt

+
+

{imageData.prompt}

+ +
+
+
+
+
+ + {/* Actions Bar - Fixed at bottom */} +
+
+
+
+ + {/* */} + +
+ +
+ +
+

0 likes

+

+ {convertUtcDateToLocalDateString(imageData.createdAt!)} +

+
+ + {/* Comment Input - Now separated with better contrast */} + {isUserLoggedIn && ( +
+ + +
+ )} +
+
+
+
+
+
+ + ); +}; + +export default ExploreImageDetailsPage; diff --git a/app/pages/ExplorePage.tsx b/app/pages/ExplorePage.tsx index a80d676..cd4c930 100644 --- a/app/pages/ExplorePage.tsx +++ b/app/pages/ExplorePage.tsx @@ -1,10 +1,8 @@ import React from "react"; import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; -import { ErrorList } from "components/ErrorList"; -import ImageV2 from "components/ImageV2"; -import { type ExplorePageLoader } from "../routes/explore"; +import { type ExplorePageLoader } from "../routes/explore._index"; import { Search as MagnifyingGlassIcon } from "lucide-react"; -import { PageContainer } from "~/components"; +import { PageContainer, ImageCard, ErrorList } from "~/components"; /** * @@ -13,6 +11,7 @@ import { PageContainer } from "~/components"; const ExplorePage = () => { const loaderData = useLoaderData(); + const images = loaderData.data.images || []; const [searchParams] = useSearchParams(); const initialSearchTerm = searchParams.get("q") || ""; @@ -52,13 +51,13 @@ const ExplorePage = () => {
{/* highlight on hover reference: https://www.hyperui.dev/blog/highlight-hover-effect-with-tailwindcss */} {images.length > 0 ? ( -
    +
      {images.map( (image) => // This removes Typescript error: "image is possibly 'null'." image && (
    • - +
    • ) )} diff --git a/app/pages/UserProfilePage.tsx b/app/pages/UserProfilePage.tsx new file mode 100644 index 0000000..0a43459 --- /dev/null +++ b/app/pages/UserProfilePage.tsx @@ -0,0 +1,178 @@ +import { useLoaderData } from "@remix-run/react"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +// import { Grid, Settings, User } from "lucide-react"; +import { PageContainer, ImageCard } from "~/components"; +import type { UserProfilePageLoader } from "~/routes/profile.$userId"; +import { Grid, User } from "lucide-react"; + +const UserDoesNotExist = () => { + return ( + +
      +
      +
      + + + + + + +
      +
      +

      Unknown User

      +
      + +
      +
      + 0{" "} + posts +
      +
      + 0{" "} + followers +
      +
      + 0{" "} + following +
      +
      +
      +
      + + + + + + POSTS + + + +
      + +
      +

      User Does Not Exist

      +
      +
      +
      +
      +
      +
      +
      + ); +}; + +export default function UserProfilePage() { + const data = useLoaderData(); + // console.log(data); + const userData = data.user || {}; + const userImages = data.images || []; + + if (!userData) return ; + + return ( + +
      + {/* Sticky Header */} +
      + {/* Profile Header */} +
      + {/* Avatar */} + {userData.image && ( + + + {/* */} + {userData.name.charAt(0)} + + + )} + + {/* Profile Info */} +
      +
      +

      {userData.username}

      + {/* + */} +
      + +
      +
      + {userImages.length}{" "} + posts +
      +
      + 0{" "} + followers +
      +
      + 0{" "} + following +
      +
      + + {/*
      +

      {userData.name}

      +

      + {userData.bio || "No bio yet."} +

      +
      */} +
      +
      + + {/* Tabs */} + + + + + POSTS + + + + {/* Scrollable Content */} +
      + + {userImages.length > 0 ? ( +
        + {userImages.map((image) => ( +
      • + {/*
        */} + {/*
        */} + + {/*
        */} +
      • + ))} +
      + ) : ( +
      +

      No Posts Yet

      +

      + When you create images, they will appear here. +

      + +
      + )} +
      +
      +
      +
      +
      +
      + ); +} diff --git a/app/root.tsx b/app/root.tsx index 437c34f..746f101 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -8,11 +8,12 @@ import { ScrollRestoration, useLoaderData, useLocation, - useRouteLoaderData, + // useRouteLoaderData, } from "@remix-run/react"; import { LoaderFunctionArgs } from "@remix-run/node"; import { Analytics } from "@vercel/analytics/react"; -import { Toaster, toast as showToast } from "sonner"; +// import { Toaster, toast as showToast } from "sonner"; +import { Toaster } from "@/components/ui/toaster"; import NavigationSidebar from "components/NavigationSidebar"; import { csrf } from "./utils/csrf.server"; import { getEnv } from "./utils/env.server"; @@ -20,7 +21,7 @@ import { combineHeaders } from "./utils/combineHeaders"; import { AuthenticityTokenProvider } from "remix-utils/csrf/react"; import { HoneypotProvider } from "remix-utils/honeypot/react"; import { honeypot } from "utils/honeypot.server"; -import { getToast, type Toast } from "utils/toast.server"; +// import { getToast, type Toast } from "utils/toast.server"; import { getLoggedInUserGoogleSSOData } from "./server"; import { GeneralErrorBoundary } from "./components/GeneralErrorBoundary"; import { @@ -37,7 +38,7 @@ export async function loader({ request }: LoaderFunctionArgs) { const [csrfToken, csrfCookieHeader] = await csrf.commitToken(request); const honeyProps = honeypot.getInputProps(); - const { toast, headers: toastHeaders } = await getToast(request); + // const { toast, headers: toastHeaders } = await getToast(request); // let user; // try { @@ -61,7 +62,7 @@ export async function loader({ request }: LoaderFunctionArgs) { return json( { userData, - toast, + // toast, ENV: getEnv(), csrfToken, honeyProps, @@ -69,8 +70,8 @@ export async function loader({ request }: LoaderFunctionArgs) { }, { headers: combineHeaders( - csrfCookieHeader ? { "set-cookie": csrfCookieHeader } : null, - toastHeaders + csrfCookieHeader ? { "set-cookie": csrfCookieHeader } : null + // toastHeaders // sessionCookieHeader ), } @@ -115,7 +116,7 @@ function Document({ __html: `window.ENV = ${JSON.stringify(env)}`, }} /> */} - + {/* */} @@ -125,7 +126,7 @@ function Document({ } export function Layout({ children }: { children: React.ReactNode }) { - const loaderData = useRouteLoaderData("root"); + // const loaderData = useRouteLoaderData("root"); // console.log(loaderData); const location = useLocation(); const isHome = location.pathname === "/"; @@ -135,9 +136,9 @@ export function Layout({ children }: { children: React.ReactNode }) { {/* */} {!isHome && } {children} - {loaderData && loaderData.toast ? ( + {/* {loaderData && loaderData.toast ? ( - ) : null} + ) : null} */} ); @@ -150,20 +151,21 @@ export default function App() { + ); } -function ShowToast({ toast }: { toast: Toast }) { - const { id, type, title, description } = toast; - React.useEffect(() => { - setTimeout(() => { - showToast[type](title, { id, description }); - }, 0); - }, [description, id, title, type]); - return null; -} +// function ShowToast({ toast }: { toast: Toast }) { +// const { id, type, title, description } = toast; +// React.useEffect(() => { +// setTimeout(() => { +// showToast[type](title, { id, description }); +// }, 0); +// }, [description, id, title, type]); +// return null; +// } export function ErrorBoundary() { return ; diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 6eb3b79..e3a62c1 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -1,5 +1,14 @@ import LandingPage from "pages/LandingPage"; +import { PageContainer, GeneralErrorBoundary } from "~/components"; export default function Index() { return ; } + +export const ErrorBoundary = () => { + return ( + + + + ); +}; diff --git a/app/routes/create.tsx b/app/routes/create.tsx index 485a9ac..841a7b7 100644 --- a/app/routes/create.tsx +++ b/app/routes/create.tsx @@ -5,11 +5,11 @@ import { MetaFunction, redirect, } from "@remix-run/node"; -import { GeneralErrorBoundary } from "~/components/GeneralErrorBoundary"; import { requireUserLogin } from "~/services"; import CreatePage from "~/pages/CreatePage"; import { createNewImages, updateUserCredits } from "~/server"; import { z } from "zod"; +import { PageContainer, GeneralErrorBoundary } from "~/components"; export const meta: MetaFunction = () => { return [{ title: "Create AI Generated Images" }]; @@ -247,5 +247,9 @@ export default function Index() { } export function ErrorBoundary() { - return ; + return ( + + + + ); } diff --git a/app/routes/explore.$imageId.tsx b/app/routes/explore.$imageId.tsx new file mode 100644 index 0000000..f7d5d78 --- /dev/null +++ b/app/routes/explore.$imageId.tsx @@ -0,0 +1,54 @@ +import { + type LoaderFunctionArgs, + json, + type SerializeFrom, + MetaFunction, +} from "@remix-run/node"; +import { useNavigate } from "@remix-run/react"; +import { getImage } from "~/server"; +import { invariantResponse } from "~/utils"; +import ExploreImageDetailsPage from "~/pages/ExploreImageDetailsPage"; +import { GeneralErrorBoundary, PageContainer } from "~/components"; + +export const meta: MetaFunction = () => { + return [{ title: "Explore AI Generated Images" }]; +}; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const imageId = params.imageId || ""; + invariantResponse(imageId, "Image does not exist"); + + const image = await getImage(imageId); + return json({ data: image }); +}; + +export type ExplorePageImageLoader = SerializeFrom; + +export default function Index() { + const navigate = useNavigate(); + + const handleCloseModal = () => { + if (window.history.length > 2) { + navigate(-1); + } else { + navigate("/explore"); // Fallback if there's no history + } + }; + + return ; +} + +export const ErrorBoundary = () => { + return ( + +

      You do not have permission

      , + 404: ({ params }) => ( +

      Image with id: "{params.imageId}" does not exist

      + ), + }} + /> +
      + ); +}; diff --git a/app/routes/explore.tsx b/app/routes/explore._index.tsx similarity index 82% rename from app/routes/explore.tsx rename to app/routes/explore._index.tsx index 676d917..fca0389 100644 --- a/app/routes/explore.tsx +++ b/app/routes/explore._index.tsx @@ -2,6 +2,7 @@ import { type LoaderFunctionArgs, json, MetaFunction } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import ExplorePage from "pages/ExplorePage"; import { getImages } from "server/getImages"; +import { PageContainer, GeneralErrorBoundary } from "~/components"; import { requireUserLogin } from "~/services"; export const meta: MetaFunction = () => { @@ -31,3 +32,11 @@ export default function Index() { ); } + +export const ErrorBoundary = () => { + return ( + + + + ); +}; diff --git a/app/routes/login.tsx b/app/routes/login.tsx index d173fff..414a1c6 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -1,4 +1,4 @@ -import { PageContainer } from "~/components"; +import { PageContainer, GeneralErrorBoundary } from "~/components"; import PixelStudioIcon from "components/PixelStudioIcon"; import type { LoaderFunctionArgs } from "@remix-run/node"; import GoogleLoginButton from "../components/GoogleLoginButton"; @@ -43,3 +43,11 @@ export default function Index() { ); } + +export const ErrorBoundary = () => { + return ( + + + + ); +}; diff --git a/app/routes/p.$imageId.tsx b/app/routes/p.$imageId.tsx new file mode 100644 index 0000000..8c59467 --- /dev/null +++ b/app/routes/p.$imageId.tsx @@ -0,0 +1,37 @@ +import { + type LoaderFunctionArgs, + json, + type SerializeFrom, + MetaFunction, +} from "@remix-run/node"; +import { getImage } from "~/server"; +import { GeneralErrorBoundary } from "~/components"; +import { invariantResponse } from "~/utils"; + +export const meta: MetaFunction = () => { + return [{ title: "Image Details Page" }]; +}; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const imageId = params.imageId || ""; + invariantResponse(imageId, "Image does not exist"); + + const image = await getImage(imageId); + + return json({ data: image }); +}; + +export type ImageDetailsPageImageLoader = SerializeFrom; + +export const ErrorBoundary = () => { + return ( +

      You do not have permission

      , + 404: ({ params }) => ( +

      Image with id: "{params.imageId}" does not exist

      + ), + }} + /> + ); +}; diff --git a/app/routes/profile.$userId.tsx b/app/routes/profile.$userId.tsx new file mode 100644 index 0000000..4448681 --- /dev/null +++ b/app/routes/profile.$userId.tsx @@ -0,0 +1,67 @@ +import { + type LoaderFunctionArgs, + json, + MetaFunction, +} from "@remix-run/node"; +import UserProfilePage from "~/pages/UserProfilePage"; +import { getUserDataByUserId } from "~/server"; +import { loader as UserLoaderData } from "../root"; +import { invariantResponse } from "~/utils/invariantResponse"; +import { PageContainer, GeneralErrorBoundary } from "~/components"; + +export const meta: MetaFunction< + typeof loader, + { root: typeof UserLoaderData } +> = ({ params, matches }) => { + // TODO: Use user's username instead of userId so we can dynamically store it in our meta tag + const userId = params.userId; + + // Incase our Profile loader ever fails, we can get logged in user data from root + const userMatch = matches.find((match) => match.id === "root"); + const username = + userMatch?.data.data?.username || userMatch?.data.data?.name || userId; + + return [ + { title: `${username} | Profile` }, + { + name: "description", + content: `Checkout ${username}'s AI generated images`, + }, + ]; +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = params.userId || ""; + invariantResponse(userId, "UserId does not exist"); + + const searchParams = new URL(request.url).searchParams; + const currentPage = Math.max(Number(searchParams.get("page") || 1), 1); + const pageSize = Number(searchParams.get("page_size")) || 250; + + const data = await getUserDataByUserId(userId, currentPage, pageSize); + + invariantResponse(data.user, "User does not exist"); + + return json(data); +}; + +export type UserProfilePageLoader = typeof loader; + +export default function Index() { + return ; +} + +export const ErrorBoundary = () => { + return ( + +

      You do not have permission

      , + 404: ({ params }) => ( +

      User with id: "{params.userId}" does not exist

      + ), + }} + /> +
      + ); +}; diff --git a/app/routes/set.$setId.tsx b/app/routes/set.$setId.tsx index ce86c9f..e783249 100644 --- a/app/routes/set.$setId.tsx +++ b/app/routes/set.$setId.tsx @@ -4,6 +4,7 @@ import { MetaFunction, redirect, } from "@remix-run/node"; +import { PageContainer, GeneralErrorBoundary } from "~/components"; import SetDetailsPage from "~/pages/SetDetailsPage"; import { getSet } from "~/server/getSet"; import { requireUserLogin } from "~/services"; @@ -34,3 +35,11 @@ export default function Index() { ); } + +export const ErrorBoundary = () => { + return ( + + + + ); +}; diff --git a/app/server/getUserDataByUserId.ts b/app/server/getUserDataByUserId.ts new file mode 100644 index 0000000..65d6066 --- /dev/null +++ b/app/server/getUserDataByUserId.ts @@ -0,0 +1,93 @@ +import { prisma } from "~/services/prisma.server"; +import { getS3BucketThumbnailURL, getS3BucketURL } from "utils/s3Utils"; + +const DEFAULT_CURRENT_PAGE = 1; +const DEFAULT_PAGE_SIZE = 50; + +export const getUserDataByUserId = async ( + userId: string, + page = DEFAULT_CURRENT_PAGE, + pageSize = DEFAULT_PAGE_SIZE +) => { + // If UserA is visiting UserB's profile, we do not want to show UserB's Private images to UserA + // const selectImageQuery = createImageSelectQuery(); + + const count = await prisma.image.count({ + where: { + user: { + id: userId, + }, + }, + }); + const userData = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + id: true, + name: true, + username: true, + image: true, + createdAt: true, + + images: { + // take: pageSize, + // skip: (page - 1) * pageSize, + orderBy: { + createdAt: "desc", + }, + where: { private: false }, + select: { + id: true, + title: true, + prompt: true, + model: true, + stylePreset: true, + private: true, + user: { + select: { + id: true, + username: true, + image: true, + }, + }, + createdAt: true, + comments: { + orderBy: { + createdAt: "desc", + }, + select: { + id: true, + message: true, + createdAt: true, + updatedAt: true, + user: { + select: { + id: true, + username: true, + image: true, + }, + }, + parentId: true, + likes: true, + }, + }, + likes: { + select: { + userId: true, + }, + }, + }, + }, + }, + }); + + // Append images source URL since we cannot use `env` variables in our UI + const formattedImages = userData?.images.map((image) => ({ + ...image, + url: getS3BucketURL(image.id), + thumbnailURL: getS3BucketThumbnailURL(image.id), + })); + + return { user: userData, images: formattedImages, count }; +}; diff --git a/app/server/index.ts b/app/server/index.ts index accc984..de766f3 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -15,3 +15,4 @@ export * from "./getImage"; export * from "./getImageBase64"; export * from "./getImageBlobFromS3"; export * from "./updateUserCredits"; +export * from "./getUserDataByUserId"; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f1c7634..da90fc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,15 @@ "@conform-to/zod": "^1.1.5", "@paralleldrive/cuid2": "^2.2.2", "@prisma/client": "^5.19.0", - "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-tooltip": "^1.1.3", "@remix-run/express": "^2.11.2", "@remix-run/node": "^2.11.2", "@remix-run/react": "^2.11.2", @@ -2792,11 +2795,11 @@ } }, "node_modules/@radix-ui/react-avatar": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.0.tgz", - "integrity": "sha512-Q/PbuSMk/vyAd/UoIShVGZ7StHHeRFYU7wXmi5GV+8cLXflZAEpHL/F697H1klrzxKXNtZ97vWiC0q3RKUH8UA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.1.tgz", + "integrity": "sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==", "dependencies": { - "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -2816,6 +2819,20 @@ } } }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", @@ -3461,6 +3478,310 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", + "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz", + "integrity": "sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.3.tgz", + "integrity": "sha512-Z4w1FIS0BqVFI2c1jZvb/uDVJijJjJ2ZMuPV81oVgTZ7g3BZxobplnMVvXtFWgtozdvYJ+MFWtwkM5S2HnAong==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -3557,6 +3878,28 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", + "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", diff --git a/package.json b/package.json index 33f8d59..0afa915 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,15 @@ "@conform-to/zod": "^1.1.5", "@paralleldrive/cuid2": "^2.2.2", "@prisma/client": "^5.19.0", - "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-tooltip": "^1.1.3", "@remix-run/express": "^2.11.2", "@remix-run/node": "^2.11.2", "@remix-run/react": "^2.11.2",