diff --git a/src/client/App.tsx b/src/client/App.tsx index d76be2c..73f3cbb 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -92,7 +92,10 @@ export const App: React.FC = React.memo(() => { > {isDev ? ( - + // Compatible with old routes + + + ) : ( diff --git a/src/client/components/CommonHeader.tsx b/src/client/components/CommonHeader.tsx new file mode 100644 index 0000000..5d3dff1 --- /dev/null +++ b/src/client/components/CommonHeader.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +interface CommonHeaderProps { + title: string; + actions?: React.ReactNode; +} +export const CommonHeader: React.FC = React.memo((props) => { + return ( + <> +

{props.title}

+ + {props.actions &&
{props.actions}
} + + ); +}); +CommonHeader.displayName = 'CommonHeader'; diff --git a/src/client/components/CommonSidebar.tsx b/src/client/components/CommonSidebar.tsx deleted file mode 100644 index 633f586..0000000 --- a/src/client/components/CommonSidebar.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { Separator } from './ui/separator'; - -interface CommonSidebarProps extends React.PropsWithChildren { - header: React.ReactNode; -} -export const CommonSidebar: React.FC = React.memo( - (props) => { - return ( -
-
- {props.header} -
- - - -
{props.children}
-
- ); - } -); -CommonSidebar.displayName = 'CommonSidebar'; diff --git a/src/client/components/CommonWrapper.tsx b/src/client/components/CommonWrapper.tsx new file mode 100644 index 0000000..f92f91e --- /dev/null +++ b/src/client/components/CommonWrapper.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Separator } from './ui/separator'; + +interface CommonWrapperProps extends React.PropsWithChildren { + header?: React.ReactNode; +} +export const CommonWrapper: React.FC = React.memo( + (props) => { + return ( +
+
+ {props.header} +
+ + + +
{props.children}
+
+ ); + } +); +CommonWrapper.displayName = 'CommonWrapper'; diff --git a/src/client/components/ui/card.tsx b/src/client/components/ui/card.tsx new file mode 100644 index 0000000..6d8501b --- /dev/null +++ b/src/client/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/utils/style" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

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

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

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/client/components/ui/sheet.tsx b/src/client/components/ui/sheet.tsx new file mode 100644 index 0000000..987bfff --- /dev/null +++ b/src/client/components/ui/sheet.tsx @@ -0,0 +1,138 @@ +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { Cross2Icon } from "@radix-ui/react-icons" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/utils/style" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/client/components/website/AddWebsiteBtn.tsx b/src/client/components/website/AddWebsiteBtn.tsx deleted file mode 100644 index 4bd6c1e..0000000 --- a/src/client/components/website/AddWebsiteBtn.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React, { useState } from 'react'; -import { z } from 'zod'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; -import { hostnameRegex } from '@tianji/shared'; -import { useTranslation } from '@i18next-toolkit/react'; -import { LuPlus } from 'react-icons/lu'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogOverlay, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form'; -import { useEvent } from '@/hooks/useEvent'; -import { Input } from '../ui/input'; -import { preventDefault } from '@/utils/dom'; -import { trpc } from '@/api/trpc'; -import { useCurrentWorkspaceId } from '@/store/user'; - -const addFormSchema = z.object({ - name: z.string(), - domain: z.union([z.string().ip(), z.string().regex(hostnameRegex)]), -}); - -export const AddWebsiteBtn: React.FC = React.memo(() => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const workspaceId = useCurrentWorkspaceId(); - const addWebsiteMutation = trpc.website.add.useMutation(); - const utils = trpc.useUtils(); - - const form = useForm>({ - resolver: zodResolver(addFormSchema), - defaultValues: { - name: '', - domain: '', - }, - }); - - const onSubmit = useEvent(async (values: z.infer) => { - await addWebsiteMutation.mutateAsync({ - workspaceId, - name: values.name, - domain: values.domain, - }); - - utils.website.all.refetch(); - form.reset(); - - setOpen(false); - }); - - return ( - - - - - - - {t('Add Website')} - {t('Add new website')} - - -
-
- - ( - - {t('Website Name')} - - - - - {t('Website Name to Display')} - - - - )} - /> - ( - - {t('Domain')} - - - - - - )} - /> - - - -
-
-
- ); -}); -AddWebsiteBtn.displayName = 'AddWebsiteBtn'; diff --git a/src/client/components/website/WebsiteCodeBtn.tsx b/src/client/components/website/WebsiteCodeBtn.tsx new file mode 100644 index 0000000..ca16ebc --- /dev/null +++ b/src/client/components/website/WebsiteCodeBtn.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Button } from '../ui/button'; +import { LuCode2 } from 'react-icons/lu'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '../ui/dialog'; +import { useTranslation } from '@i18next-toolkit/react'; +import { Typography } from 'antd'; + +interface WebsiteCodeBtnProps { + websiteId: string; +} +export const WebsiteCodeBtn: React.FC = React.memo( + (props) => { + const { t } = useTranslation(); + + const trackScript = ``; + + return ( + + + + + + + {t('Tracking code')} + + {t('Add this code into your website head script')} + + + + + {trackScript} + + + + ); + } +); +WebsiteCodeBtn.displayName = 'WebsiteCodeBtn'; diff --git a/src/client/components/website/WebsiteVisitorMapBtn.tsx b/src/client/components/website/WebsiteVisitorMapBtn.tsx new file mode 100644 index 0000000..23a6629 --- /dev/null +++ b/src/client/components/website/WebsiteVisitorMapBtn.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from '@i18next-toolkit/react'; +import React from 'react'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '../ui/sheet'; +import { Button } from '../ui/button'; +import { WebsiteVisitorMap } from './WebsiteVisitorMap'; +import { LuMap } from 'react-icons/lu'; + +interface WebsiteVisitorMapBtnProps { + websiteId: string; +} +export const WebsiteVisitorMapBtn: React.FC = + React.memo((props) => { + const { t } = useTranslation(); + + return ( + + + + + + + + {t('Vistor Map')} + + + + + + ); + }); +WebsiteVisitorMapBtn.displayName = 'WebsiteVisitorMapBtn'; diff --git a/src/client/pages/LayoutV2.tsx b/src/client/pages/LayoutV2.tsx index 9b8d3ad..2c231fb 100644 --- a/src/client/pages/LayoutV2.tsx +++ b/src/client/pages/LayoutV2.tsx @@ -120,12 +120,12 @@ export const LayoutV2: React.FC<{ - +
{props.list}
-
+
diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index 578bb95..6d61d70 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as WebsiteImport } from './routes/website' import { Route as RegisterImport } from './routes/register' import { Route as LoginImport } from './routes/login' import { Route as IndexImport } from './routes/index' +import { Route as WebsiteAddImport } from './routes/website/add' import { Route as WebsiteWebsiteIdImport } from './routes/website/$websiteId' // Create/Update Routes @@ -39,6 +40,11 @@ const IndexRoute = IndexImport.update({ getParentRoute: () => rootRoute, } as any) +const WebsiteAddRoute = WebsiteAddImport.update({ + path: '/add', + getParentRoute: () => WebsiteRoute, +} as any) + const WebsiteWebsiteIdRoute = WebsiteWebsiteIdImport.update({ path: '/$websiteId', getParentRoute: () => WebsiteRoute, @@ -68,6 +74,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WebsiteWebsiteIdImport parentRoute: typeof WebsiteImport } + '/website/add': { + preLoaderRoute: typeof WebsiteAddImport + parentRoute: typeof WebsiteImport + } } } @@ -77,7 +87,7 @@ export const routeTree = rootRoute.addChildren([ IndexRoute, LoginRoute, RegisterRoute, - WebsiteRoute.addChildren([WebsiteWebsiteIdRoute]), + WebsiteRoute.addChildren([WebsiteWebsiteIdRoute, WebsiteAddRoute]), ]) /* prettier-ignore-end */ diff --git a/src/client/routes/website.tsx b/src/client/routes/website.tsx index dded314..a176efc 100644 --- a/src/client/routes/website.tsx +++ b/src/client/routes/website.tsx @@ -1,20 +1,20 @@ import { trpc } from '@/api/trpc'; +import { CommonHeader } from '@/components/CommonHeader'; import { CommonList } from '@/components/CommonList'; -import { CommonSidebar } from '@/components/CommonSidebar'; -import { Separator } from '@/components/ui/separator'; -import { AddWebsiteBtn } from '@/components/website/AddWebsiteBtn'; +import { CommonWrapper } from '@/components/CommonWrapper'; +import { Button } from '@/components/ui/button'; import { useDataReady } from '@/hooks/useDataReady'; +import { useEvent } from '@/hooks/useEvent'; import { LayoutV2 } from '@/pages/LayoutV2'; import { useCurrentWorkspaceId } from '@/store/user'; import { routeAuthBeforeLoad } from '@/utils/route'; import { useTranslation } from '@i18next-toolkit/react'; import { createFileRoute, - getRouteApi, useNavigate, + useRouterState, } from '@tanstack/react-router'; - -const routeApi = getRouteApi('/website/$websiteId'); +import { LuPlus } from 'react-icons/lu'; export const Route = createFileRoute('/website')({ beforeLoad: routeAuthBeforeLoad, @@ -28,6 +28,9 @@ function WebsiteComponent() { workspaceId, }); const navigate = useNavigate(); + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); const items = data.map((item) => ({ id: item.id, @@ -36,12 +39,11 @@ function WebsiteComponent() { tags: [], href: `/website/${item.id}`, })); - const params = routeApi.useParams<{ websiteId: string }>(); useDataReady( () => data.length > 0, () => { - if (!params.websiteId && data[0]) { + if (pathname === Route.fullPath) { navigate({ to: '/website/$websiteId', params: { @@ -52,22 +54,33 @@ function WebsiteComponent() { } ); + const handleClickAdd = useEvent(() => { + navigate({ + to: '/website/add', + }); + }); + return ( -

{t('Website')}

- -
- -
- + + {t('Add')} + + } + /> } > - + } /> ); diff --git a/src/client/routes/website/$websiteId.tsx b/src/client/routes/website/$websiteId.tsx index 4e5f7e3..7c7c7fe 100644 --- a/src/client/routes/website/$websiteId.tsx +++ b/src/client/routes/website/$websiteId.tsx @@ -1,5 +1,22 @@ +import { trpc } from '@/api/trpc'; +import { CommonHeader } from '@/components/CommonHeader'; +import { CommonWrapper } from '@/components/CommonWrapper'; +import { ErrorTip } from '@/components/ErrorTip'; +import { Loading } from '@/components/Loading'; +import { NotFoundTip } from '@/components/NotFoundTip'; +import { Button } from '@/components/ui/button'; +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; +import { WebsiteCodeBtn } from '@/components/website/WebsiteCodeBtn'; +import { WebsiteMetricsTable } from '@/components/website/WebsiteMetricsTable'; +import { WebsiteOverview } from '@/components/website/WebsiteOverview'; +import { WebsiteVisitorMapBtn } from '@/components/website/WebsiteVisitorMapBtn'; +import { useGlobalRangeDate } from '@/hooks/useGlobalRangeDate'; +import { useCurrentWorkspaceId } from '@/store/user'; import { routeAuthBeforeLoad } from '@/utils/route'; -import { createFileRoute, getRouteApi } from '@tanstack/react-router'; +import { useTranslation } from '@i18next-toolkit/react'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { Card } from 'antd'; +import { LuArrowRight } from 'react-icons/lu'; export const Route = createFileRoute('/website/$websiteId')({ beforeLoad: routeAuthBeforeLoad, @@ -7,7 +24,119 @@ export const Route = createFileRoute('/website/$websiteId')({ }); function WebsiteDetailComponent() { - const params = Route.useParams<{ websiteId: string }>(); + const { websiteId } = Route.useParams<{ websiteId: string }>(); + const { t } = useTranslation(); + const workspaceId = useCurrentWorkspaceId(); + const { data: website, isLoading } = trpc.website.info.useQuery({ + workspaceId, + websiteId, + }); + const { startDate, endDate } = useGlobalRangeDate(); - return
website: {params.websiteId}
; + if (!websiteId) { + return ; + } + + if (isLoading) { + return ; + } + + if (!website) { + return ; + } + + const startAt = startDate.unix() * 1000; + const endAt = endDate.unix() * 1000; + + return ( + + + + } + /> + } + > + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + +
+
+
+ ); } diff --git a/src/client/routes/website/add.tsx b/src/client/routes/website/add.tsx new file mode 100644 index 0000000..7a54b2b --- /dev/null +++ b/src/client/routes/website/add.tsx @@ -0,0 +1,119 @@ +import { routeAuthBeforeLoad } from '@/utils/route'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { t, useTranslation } from '@i18next-toolkit/react'; +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { useEvent } from '@/hooks/useEvent'; +import { Input } from '@/components/ui/input'; +import { useCurrentWorkspaceId } from '@/store/user'; +import { trpc } from '@/api/trpc'; +import { hostnameRegex } from '@tianji/shared'; +import { Card, CardContent, CardFooter } from '@/components/ui/card'; +import { CommonWrapper } from '@/components/CommonWrapper'; + +export const Route = createFileRoute('/website/add')({ + beforeLoad: routeAuthBeforeLoad, + component: WebsiteDetailComponent, +}); + +const addFormSchema = z.object({ + name: z.string(), + domain: z.union([z.string().ip(), z.string().regex(hostnameRegex)]), +}); + +function WebsiteDetailComponent() { + const { t } = useTranslation(); + const workspaceId = useCurrentWorkspaceId(); + const addWebsiteMutation = trpc.website.add.useMutation(); + const utils = trpc.useUtils(); + const navigate = useNavigate(); + + const form = useForm>({ + resolver: zodResolver(addFormSchema), + defaultValues: { + name: '', + domain: '', + }, + }); + + const onSubmit = useEvent(async (values: z.infer) => { + const res = await addWebsiteMutation.mutateAsync({ + workspaceId, + name: values.name, + domain: values.domain, + }); + + utils.website.all.refetch(); + form.reset(); + + navigate({ + to: '/website/$websiteId', + params: { + websiteId: res.id, + }, + }); + }); + + return ( + {t('Add Website')}

} + > +
+
+ + + + ( + + {t('Website Name')} + + + + + {t('Website Name to Display')} + + + + )} + /> + ( + + {t('Domain')} + + + + + + )} + /> + + + + + +
+ +
+ + ); +}