diff --git a/web/.env.example b/web/.env.example index a398c8f..ba4c9f5 100644 --- a/web/.env.example +++ b/web/.env.example @@ -17,4 +17,5 @@ SPACES_BUCKET="comfyui-deploy" SPACES_KEY="xyz" SPACES_SECRET="aaa" -JWT_SECRET="openssl rand -hex 32" \ No newline at end of file +JWT_SECRET="openssl rand -hex 32" +PLAUSIBLE_DOMAIN= \ No newline at end of file diff --git a/web/bun.lockb b/web/bun.lockb index f32eacf..892e2b9 100755 Binary files a/web/bun.lockb and b/web/bun.lockb differ diff --git a/web/next-gen/next-gen.config.json b/web/next-gen/next-gen.config.json new file mode 100644 index 0000000..4869c02 --- /dev/null +++ b/web/next-gen/next-gen.config.json @@ -0,0 +1,23 @@ +{ + "name": "Comfy Deploy", + "og:title": "Comfy Deploy | Stable Diffusion from your terminal to the world", + "og:description": "", + "author": "BennyKok", + "tagline": "Stable Diffusion from your terminal to the world", + "description": "Manage all your open development ports and remote Vercel deployment.", + "pricingPlan": [ + { + "name": "Free", + "description": "Free", + "prices": [ + { + "price": 0, + "currency": "usd", + "pricingType": "month", + "paymentLink": "", + "paymentLink_Prod": "" + } + ] + } + ] +} \ No newline at end of file diff --git a/web/package.json b/web/package.json index 004cf35..79db5e9 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "@clerk/nextjs": "^4.27.4", "@hookform/resolvers": "^3.3.2", "@neondatabase/serverless": "^0.6.0", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -38,6 +39,7 @@ "lucide-react": "^0.294.0", "nanoid": "^5.0.4", "next": "14.0.3", + "next-plausible": "^3.12.0", "next-usequerystate": "^1.13.2", "react": "^18", "react-dom": "^18", diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index bb7cfd5..7f127e6 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -4,14 +4,27 @@ import { Button } from "@/components/ui/button"; import { ClerkProvider, UserButton } from "@clerk/nextjs"; import { Github } from "lucide-react"; import type { Metadata } from "next"; +import PlausibleProvider from "next-plausible"; import { Inter } from "next/font/google"; import { Toaster } from "sonner"; const inter = Inter({ subsets: ["latin"] }); +import meta from 'next-gen/config'; + export const metadata: Metadata = { - title: "Comfy Deploy", - description: "Generated by create next app", + title: meta['og:title'], + description: meta['og:description'], + + category: 'technology', + + openGraph: { + type: 'website', + title: meta['og:title'], + description: meta['og:description'], + locale: 'en_US', + images: '/og.jpg', + }, }; export default function RootLayout({ @@ -22,12 +35,17 @@ export default function RootLayout({ return ( + + {process.env.PLAUSIBLE_DOMAIN && ( + + )} + -
+
@@ -38,14 +56,17 @@ export default function RootLayout({ variant={"outline"} className="rounded-full aspect-square p-2" > - +
{/*
*/} -
+
{children}
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 24e5ca7..cb25c09 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,77 +1,5 @@ -import { WorkflowList } from "@/components/WorkflowList"; -import { db } from "@/db/db"; -import { usersTable, workflowTable, workflowVersionTable } from "@/db/schema"; -import { auth, clerkClient } from "@clerk/nextjs"; -import { desc, eq } from "drizzle-orm"; +import Main from '@/components/Main'; export default function Home() { - return ; -} - -async function WorkflowServer() { - const { userId } = await auth(); - - if (!userId) { - return
No auth
; - } - - const user = await db.query.usersTable.findFirst({ - where: eq(usersTable.id, userId), - }); - - if (!user) { - await setInitialUserData(userId); - } - - const workflow = await db.query.workflowTable.findMany({ - // extras: { - // count: sql`(select count(*) from ${workflowVersionTable})`.as( - // "count", - // ), - // }, - with: { - versions: { - limit: 1, - orderBy: desc(workflowVersionTable.version), - }, - }, - orderBy: desc(workflowTable.updated_at), - where: eq(workflowTable.user_id, userId), - }); - - return ( - { - return { - id: x.id, - email: x.name, - amount: x.versions[0]?.version ?? 0, - date: x.updated_at, - }; - })} - /> - ); -} - -async function setInitialUserData(userId: string) { - const user = await clerkClient.users.getUser(userId); - - // incase we dont have username such as google login, fallback to first name + last name - const usernameFallback = - user.username ?? (user.firstName ?? "") + (user.lastName ?? ""); - - // For the display name, if it for some reason is empty, fallback to username - let nameFallback = (user.firstName ?? "") + (user.lastName ?? ""); - if (nameFallback === "") { - nameFallback = usernameFallback; - } - - const result = await db.insert(usersTable).values({ - id: userId, - // this is used for path, make sure this is unique - username: usernameFallback, - - // this is for display name, maybe different from username - name: nameFallback, - }); -} + return
; +} \ No newline at end of file diff --git a/web/src/app/workflow/[workflow_id]/page.tsx b/web/src/app/workflows/[workflow_id]/page.tsx similarity index 100% rename from web/src/app/workflow/[workflow_id]/page.tsx rename to web/src/app/workflows/[workflow_id]/page.tsx diff --git a/web/src/app/workflows/page.tsx b/web/src/app/workflows/page.tsx new file mode 100644 index 0000000..24e5ca7 --- /dev/null +++ b/web/src/app/workflows/page.tsx @@ -0,0 +1,77 @@ +import { WorkflowList } from "@/components/WorkflowList"; +import { db } from "@/db/db"; +import { usersTable, workflowTable, workflowVersionTable } from "@/db/schema"; +import { auth, clerkClient } from "@clerk/nextjs"; +import { desc, eq } from "drizzle-orm"; + +export default function Home() { + return ; +} + +async function WorkflowServer() { + const { userId } = await auth(); + + if (!userId) { + return
No auth
; + } + + const user = await db.query.usersTable.findFirst({ + where: eq(usersTable.id, userId), + }); + + if (!user) { + await setInitialUserData(userId); + } + + const workflow = await db.query.workflowTable.findMany({ + // extras: { + // count: sql`(select count(*) from ${workflowVersionTable})`.as( + // "count", + // ), + // }, + with: { + versions: { + limit: 1, + orderBy: desc(workflowVersionTable.version), + }, + }, + orderBy: desc(workflowTable.updated_at), + where: eq(workflowTable.user_id, userId), + }); + + return ( + { + return { + id: x.id, + email: x.name, + amount: x.versions[0]?.version ?? 0, + date: x.updated_at, + }; + })} + /> + ); +} + +async function setInitialUserData(userId: string) { + const user = await clerkClient.users.getUser(userId); + + // incase we dont have username such as google login, fallback to first name + last name + const usernameFallback = + user.username ?? (user.firstName ?? "") + (user.lastName ?? ""); + + // For the display name, if it for some reason is empty, fallback to username + let nameFallback = (user.firstName ?? "") + (user.lastName ?? ""); + if (nameFallback === "") { + nameFallback = usernameFallback; + } + + const result = await db.insert(usersTable).values({ + id: userId, + // this is used for path, make sure this is unique + username: usernameFallback, + + // this is for display name, maybe different from username + name: nameFallback, + }); +} diff --git a/web/src/components/CopyButton.tsx b/web/src/components/CopyButton.tsx index ca0ea8c..a720583 100644 --- a/web/src/components/CopyButton.tsx +++ b/web/src/components/CopyButton.tsx @@ -21,4 +21,4 @@ export function CopyButton({ ); -} +} \ No newline at end of file diff --git a/web/src/components/Main.tsx b/web/src/components/Main.tsx new file mode 100644 index 0000000..4bde7c8 --- /dev/null +++ b/web/src/components/Main.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Badge } from "./ui/badge"; +import macBookMainImage from "@/assets/images/macbook-main.png"; +import { Section } from "@/components/Section"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { Fragment } from "react"; +import meta from 'next-gen/config'; + +function isDevelopment() { + return process.env.NODE_ENV === "development"; +} + +function FeatureCard(props: { + className?: String; + title: React.ReactNode; + description: String; +}) { + return ( +
+ {/*
*/} +
+
+ +
+
{props.title}
+
+
{props.description}
+
+
+ ); +} + +export default function Main() { + return ( +
+
+ {/* Hero Section */} + +
+
+ + ✨ Open Source on Github + + + + + {meta.tagline} + + + + + {meta.description} + + + + Get Started + +
+ +
+ {/* Find My Ports on MacBook Pro 14 */} +
+
+ +
+ +
+ {/*
*/} +
{meta.name}
+
© {meta.author} 2023 . All rights reserved.
+ {/*
*/} +
+
+ ); +} diff --git a/web/src/components/NavbarRight.tsx b/web/src/components/NavbarRight.tsx index f64a853..a879c94 100644 --- a/web/src/components/NavbarRight.tsx +++ b/web/src/components/NavbarRight.tsx @@ -15,7 +15,8 @@ export function NavbarRight() { ? "machines" : pathname.startsWith("/api-keys") ? "api-keys" - : "workflow" + : pathname.startsWith("/workflows") + ? "workflows" : "" } className="w-[300px]" onValueChange={(value) => { @@ -24,12 +25,12 @@ export function NavbarRight() { } else if (value === "api-keys") { router.push("/api-keys"); } else { - router.push("/"); + router.push("/workflows"); } }} > - Workflow + Workflows Machines API Keys diff --git a/web/src/components/Section.tsx b/web/src/components/Section.tsx new file mode 100644 index 0000000..7ac27c9 --- /dev/null +++ b/web/src/components/Section.tsx @@ -0,0 +1,540 @@ +import { Button, buttonVariants } from '@/components/ui/button'; +type ButtonProps = React.ComponentProps; +type LinkProps = React.ComponentProps; +import { Card as BaseCard } from '@/components/ui/card'; +type CardProps = React.ComponentProps; +import { Tabs, TabsTrigger as Tab, TabsList } from '@/components/ui/tabs'; +type TabsProps = React.ComponentProps; +import { + Accordion, + AccordionItem, + AccordionContent, + AccordionTrigger, +} from '@/components/ui/accordion'; +type AccordionProps = React.ComponentProps; +import { Badge as Chip } from '@/components/ui/badge'; +type ChipProps = React.ComponentProps; + +import Link from 'next/link'; +import type { + HTMLAttributeAnchorTarget, + HTMLAttributes, + ReactNode, +} from 'react'; +import { twMerge } from 'tailwind-merge'; +import { ChevronRight as MdChevronRight} from 'lucide-react' +// import { MdChevronRight } from 'react-icons/md'; +import React from 'react'; +import { CheckCircle as PiCheckCircleDuotone } from 'lucide-react' +// import { PiCheckCircleDuotone } from 'react-icons/pi'; +import { cn } from '@/lib/utils'; + +function Section({ + className, + children, + ...props +}: HTMLAttributes) { + // extract the primary action and secondary action from the children + const primaryAction = getChildComponent(children, PrimaryAction); + const secondaryAction = getChildComponent(children, SecondaryAction); + + return ( +
+ {removeFromChildren(children, [PrimaryAction, SecondaryAction])} +
+ {primaryAction} + {secondaryAction} +
+
+ ); +} + +function Title({ + children, + className, + ...props +}: HTMLAttributes) { + return ( +

+ {children} +

+ ); +} + +function Subtitle({ + children, + className, + ...props +}: HTMLAttributes) { + return ( +

+ {children} +

+ ); +} + +function Announcement({ + className, + children, + href, + target = '_blank', + ...props +}: ChipProps & { + href?: string; //string | UrlObject; + target?: HTMLAttributeAnchorTarget | undefined; +}) { + return ( + + // } + style={{ + // @ts-ignore + textWrap: 'balance', + }} + {...props} + > + + {children} + {' '} + + + ); +} + +type ActionProps = ButtonProps & { + be: 'button'; + hideArrow?: boolean; +}; + +type ActionLinkProps = LinkProps & { + be?: 'a'; + hideArrow?: boolean; + variant?: ButtonProps['variant']; +}; + +function PrimaryAction({ + className, + variant, + children, + hideArrow, + ...props +}: ActionLinkProps | ActionProps) { + if (props.be === 'button') { + return ( + + ); + } + + return ( + + {children} + {!hideArrow && ( + + )} + + ); +} + +function SecondaryAction({ + className, + variant, + children, + hideArrow, + ...props +}: ActionLinkProps | ActionProps) { + if (props.be === 'button') { + return ( + + ); + } + + return ( + + {children} + {!hideArrow && ( + + )} + + ); +} +function PricingCard({ + className, + children, + ...props +}: Omit & { + children: + | ReactNode + | ReactNode[] + | ((pricingType: PricingType) => ReactNode | ReactNode[]); +}) { + // const { pricingType } = usePricingContext(); + if (typeof children === 'function') + children = (children('month') as React.ReactElement).props.children as + | ReactNode + | ReactNode[]; + + // extract the title and subtitle from the children + // const cardTitleStyles = + const title = getChildComponent(children, Title, { + className: 'text-2xl md:text-2xl text-start font-bold', + }); + const subTitle = getChildComponent(children, Subtitle, { + className: 'text-md text-start text-foreground-500 mt-4', + }); + const priceTags = getChildComponents(children, PriceTag, { + className: 'text-4xl font-bold', + }); + const primaryAction = getChildComponent(children, PrimaryAction, { + className: 'w-full', + }); + + return ( + +
+ {title} + {priceTags} + {subTitle} +
+
+ {removeFromChildren(children, [ + Title, + Subtitle, + ImageArea, + PriceTag, + PrimaryAction, + ]).map((item, i) => { + return ( +
+ + {item} +
+ ); + })} +
+
{primaryAction}
+
+ ); +} + +// create a Pricing Context that store the monthly and pricing state +// const PricingContext = createContext<{ +// pricingType: PricingType; +// setPricingType: (pricingType: PricingType) => void; +// }>({ +// pricingType: 'month', +// setPricingType: (pricingType: PricingType) => {}, +// }); + +const PricingTypeValue = ['month', 'year'] as const; +export type PricingType = (typeof PricingTypeValue)[number]; + +// // an helper function to useContext +// export function usePricingContext() { +// return React.useContext(PricingContext); +// } + +function Pricing({ children, ...props }: HTMLAttributes) { + // const [pricingType, setPricingType] = useState('month'); + + // const context = useMemo(() => { + // return { + // pricingType, + // setPricingType, + // }; + // }, [pricingType]); + + return ( + // +
{children}
+ //
+ ); +} + +function PricingOption({ className, ...props }: TabsProps) { + // const { setPricingType } = usePricingContext(); + + return ( + { + // setPricingType(key as PricingType); + }} + // onSelectionChange={(key: any) => { + // setPricingType(key as PricingType); + // }} + > + + {PricingTypeValue.map((pricingType) => { + return ( + + {pricingType} + + ); + })} + + + ); +} + +function PriceTag({ + children, + pricingType, + ...props +}: HTMLAttributes & { + pricingType?: 'month' | 'year' | string; +}) { + // const { pricingType: currentPricingType } = usePricingContext(); + let currentPricingType = 'month'; + + if (pricingType != undefined && currentPricingType !== pricingType) + return <>; + + return

{children}

; +} + +function Card({ className, children, ...props }: CardProps) { + // extract the title and subtitle from the children + // const cardTitleStyles = + const title = getChildComponent(children, Title, { + className: 'text-2xl md:text-2xl font-normal text-center', + }); + const subTitle = getChildComponent(children, Subtitle, { + className: 'text-md text-center', + }); + const image = getChildComponent(children, ImageArea); + + return ( + + {image} + {title} + {subTitle} + {removeFromChildren(children, [Title, Subtitle, ImageArea])} + + ); +} + +function ImageArea({ + className, + children, + ...props +}: HTMLAttributes) { + return ( +
+ {children} +
+ ); +} + +// create a helper to get and remove the title and subtitle from the children +function getChildComponent React.JSX.Element>( + children: React.ReactNode | React.ReactNode[], + type: T, + propsOverride?: Partial[0]>, +) { + const childrenArr = React.Children.toArray(children); + let child = childrenArr.find( + (child) => React.isValidElement(child) && child.type === type, + ) as React.ReactElement< + Parameters[0], + string | React.JSXElementConstructor + >; + + if (child && propsOverride) { + const { className, ...rest } = child.props; + child = React.cloneElement(child, { + className: twMerge(className, propsOverride.className), + ...rest, + }); + } + + return child; +} + +function getChildComponents React.JSX.Element>( + children: React.ReactNode | React.ReactNode[], + type: T, + propsOverride?: Partial[0]>, +) { + const childrenArr = React.Children.toArray(children); + let child = ( + childrenArr.filter( + (child) => React.isValidElement(child) && child.type === type, + ) as React.ReactElement< + Parameters[0], + string | React.JSXElementConstructor + >[] + ).map((child) => { + if (child && propsOverride) { + const { className, ...rest } = child.props; + child = React.cloneElement(child, { + className: twMerge(className, propsOverride.className), + ...rest, + }); + } + return child; + }); + + return child; +} + +function removeFromChildren( + children: React.ReactNode | React.ReactNode[], + types: any[], +): React.ReactNode[] { + return React.Children.toArray(children).filter( + (child) => React.isValidElement(child) && !types.includes(child.type), + ); +} + +function FAQ({ children, ...props }: AccordionProps) { + return {children}; +} + +function FAQItem({ + children, + ...props +}: { + children: React.ReactNode | React.ReactNode[]; + 'aria-label': string; + title: string; +}): JSX.Element { + return ( + + {props.title} + {children} + + ); +} + +// const FAQItem = AccordionItem; + +const pkg = Object.assign(Section, { + Pricing, + PricingOption, + Title, + Subtitle, + Announcement, + PrimaryAction, + SecondaryAction, + ImageArea, + Card, + FAQItem, + FAQ, + PricingCard, + PriceTag, +}); + +export { pkg as Section }; diff --git a/web/src/components/WorkflowList.tsx b/web/src/components/WorkflowList.tsx index 39798e5..e1abe9d 100644 --- a/web/src/components/WorkflowList.tsx +++ b/web/src/components/WorkflowList.tsx @@ -84,7 +84,7 @@ export const columns: ColumnDef[] = [ }, cell: ({ row }) => { return ( - + {row.getValue("email")} ); diff --git a/web/src/components/ui/accordion.tsx b/web/src/components/ui/accordion.tsx new file mode 100644 index 0000000..24c788c --- /dev/null +++ b/web/src/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/web/src/middleware.ts b/web/src/middleware.ts index f7375b8..612e677 100644 --- a/web/src/middleware.ts +++ b/web/src/middleware.ts @@ -5,7 +5,7 @@ import { authMiddleware, redirectToSignIn } from "@clerk/nextjs"; // See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware export default authMiddleware({ // debug: true, - publicRoutes: ["/api/(.*)"], + publicRoutes: ['/',"/api/(.*)"], // publicRoutes: ["/", "/(.*)"], async afterAuth(auth, req, evt) { // redirect them to organization selection page diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts index 52a11bf..18b63cf 100644 --- a/web/tailwind.config.ts +++ b/web/tailwind.config.ts @@ -66,10 +66,19 @@ const config: Config = { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" }, }, + 'background-shine': { + from: { + backgroundPosition: '0 0', + }, + to: { + backgroundPosition: '-200% 0', + }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + 'background-shine': 'background-shine 2s linear infinite', }, }, }, diff --git a/web/tsconfig.json b/web/tsconfig.json index af05591..9640de0 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -19,7 +19,8 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "next-gen/config": ["./next-gen/next-gen.config.json"], } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],