feat(v2): add website detail
This commit is contained in:
parent
f9a51e4c79
commit
958b1c0932
@ -92,7 +92,10 @@ export const App: React.FC = React.memo(() => {
|
||||
>
|
||||
<TokenLoginContainer>
|
||||
{isDev ? (
|
||||
// Compatible with old routes
|
||||
<BrowserRouter>
|
||||
<RouterProvider router={router} context={{ userInfo }} />
|
||||
</BrowserRouter>
|
||||
) : (
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
|
16
src/client/components/CommonHeader.tsx
Normal file
16
src/client/components/CommonHeader.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CommonHeaderProps {
|
||||
title: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
export const CommonHeader: React.FC<CommonHeaderProps> = React.memo((props) => {
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-xl font-bold">{props.title}</h1>
|
||||
|
||||
{props.actions && <div className="ml-auto">{props.actions}</div>}
|
||||
</>
|
||||
);
|
||||
});
|
||||
CommonHeader.displayName = 'CommonHeader';
|
@ -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<CommonSidebarProps> = React.memo(
|
||||
(props) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center px-4 py-2 h-[52px]">
|
||||
{props.header}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex-1 overflow-hidden">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
CommonSidebar.displayName = 'CommonSidebar';
|
22
src/client/components/CommonWrapper.tsx
Normal file
22
src/client/components/CommonWrapper.tsx
Normal file
@ -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<CommonWrapperProps> = React.memo(
|
||||
(props) => {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-[52px] items-center px-4 py-2">
|
||||
{props.header}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex-1 overflow-hidden">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
CommonWrapper.displayName = 'CommonWrapper';
|
76
src/client/components/ui/card.tsx
Normal file
76
src/client/components/ui/card.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/utils/style"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
138
src/client/components/ui/sheet.tsx
Normal file
138
src/client/components/ui/sheet.tsx
Normal file
@ -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<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={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<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
@ -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<z.infer<typeof addFormSchema>>({
|
||||
resolver: zodResolver(addFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
domain: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = useEvent(async (values: z.infer<typeof addFormSchema>) => {
|
||||
await addWebsiteMutation.mutateAsync({
|
||||
workspaceId,
|
||||
name: values.name,
|
||||
domain: values.domain,
|
||||
});
|
||||
|
||||
utils.website.all.refetch();
|
||||
form.reset();
|
||||
|
||||
setOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild={false}>
|
||||
<Button variant="outline" Icon={LuPlus}>
|
||||
{t('Add')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent onPointerDownOutside={preventDefault}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Add Website')}</DialogTitle>
|
||||
<DialogDescription>{t('Add new website')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Website Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Website Name to Display')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Domain')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" loading={addWebsiteMutation.isLoading}>
|
||||
{t('Submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
AddWebsiteBtn.displayName = 'AddWebsiteBtn';
|
53
src/client/components/website/WebsiteCodeBtn.tsx
Normal file
53
src/client/components/website/WebsiteCodeBtn.tsx
Normal file
@ -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<WebsiteCodeBtnProps> = React.memo(
|
||||
(props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const trackScript = `<script async defer src="${location.origin}/tracker.js" data-website-id="${props.websiteId}"></script>`;
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button variant="outline" Icon={LuCode2}>
|
||||
{t('Code')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Tracking code')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Add this code into your website head script')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Typography.Paragraph
|
||||
copyable={{
|
||||
format: 'text/plain',
|
||||
text: trackScript,
|
||||
}}
|
||||
className="bg-muted flex h-[96px] overflow-auto rounded border border-black border-opacity-10 bg-opacity-5 p-2"
|
||||
>
|
||||
<span>{trackScript}</span>
|
||||
</Typography.Paragraph>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
WebsiteCodeBtn.displayName = 'WebsiteCodeBtn';
|
39
src/client/components/website/WebsiteVisitorMapBtn.tsx
Normal file
39
src/client/components/website/WebsiteVisitorMapBtn.tsx
Normal file
@ -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<WebsiteVisitorMapBtnProps> =
|
||||
React.memo((props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" Icon={LuMap}>
|
||||
{t('Vistor Map')}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent side="top">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('Vistor Map')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<WebsiteVisitorMap websiteId={props.websiteId} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
});
|
||||
WebsiteVisitorMapBtn.displayName = 'WebsiteVisitorMapBtn';
|
@ -120,12 +120,12 @@ export const LayoutV2: React.FC<{
|
||||
<UserConfig isCollapsed={isCollapsed} />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={layout[1]} minSize={30}>
|
||||
<ResizablePanel defaultSize={layout[1]} minSize={25}>
|
||||
<div className="h-full overflow-hidden">{props.list}</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={layout[2]}>
|
||||
<div>
|
||||
<div className="h-full overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
@ -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 */
|
||||
|
@ -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 (
|
||||
<LayoutV2
|
||||
list={
|
||||
<CommonSidebar
|
||||
<CommonWrapper
|
||||
header={
|
||||
<>
|
||||
<h1 className="text-xl font-bold">{t('Website')}</h1>
|
||||
|
||||
<div className="ml-auto">
|
||||
<AddWebsiteBtn />
|
||||
</div>
|
||||
</>
|
||||
<CommonHeader
|
||||
title={t('Website')}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
Icon={LuPlus}
|
||||
onClick={handleClickAdd}
|
||||
>
|
||||
{t('Add')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CommonList hasSearch={true} items={items} />
|
||||
</CommonSidebar>
|
||||
</CommonWrapper>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
@ -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 <div>website: {params.websiteId}</div>;
|
||||
if (!websiteId) {
|
||||
return <ErrorTip />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!website) {
|
||||
return <NotFoundTip />;
|
||||
}
|
||||
|
||||
const startAt = startDate.unix() * 1000;
|
||||
const endAt = endDate.unix() * 1000;
|
||||
|
||||
return (
|
||||
<CommonWrapper
|
||||
header={
|
||||
<CommonHeader
|
||||
title={website.name}
|
||||
actions={
|
||||
<>
|
||||
<WebsiteCodeBtn websiteId={website.id} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ScrollArea className="h-full overflow-hidden p-4">
|
||||
<ScrollBar orientation="horizontal" />
|
||||
|
||||
<Card>
|
||||
<Card.Grid hoverable={false} className="!w-full">
|
||||
<WebsiteOverview website={website} showDateFilter={true} />
|
||||
</Card.Grid>
|
||||
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/2">
|
||||
<WebsiteMetricsTable
|
||||
websiteId={websiteId}
|
||||
type="url"
|
||||
title={[t('Pages'), t('Views')]}
|
||||
startAt={startAt}
|
||||
endAt={endAt}
|
||||
/>
|
||||
</Card.Grid>
|
||||
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/2">
|
||||
<WebsiteMetricsTable
|
||||
websiteId={websiteId}
|
||||
type="referrer"
|
||||
title={[t('Referrers'), t('Views')]}
|
||||
startAt={startAt}
|
||||
endAt={endAt}
|
||||
/>
|
||||
</Card.Grid>
|
||||
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/3">
|
||||
<WebsiteMetricsTable
|
||||
websiteId={websiteId}
|
||||
type="browser"
|
||||
title={[t('Browser'), t('Visitors')]}
|
||||
startAt={startAt}
|
||||
endAt={endAt}
|
||||
/>
|
||||
</Card.Grid>
|
||||
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/3">
|
||||
<WebsiteMetricsTable
|
||||
websiteId={websiteId}
|
||||
type="os"
|
||||
title={[t('OS'), t('Visitors')]}
|
||||
startAt={startAt}
|
||||
endAt={endAt}
|
||||
/>
|
||||
</Card.Grid>
|
||||
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/3">
|
||||
<WebsiteMetricsTable
|
||||
websiteId={websiteId}
|
||||
type="device"
|
||||
title={[t('Devices'), t('Visitors')]}
|
||||
startAt={startAt}
|
||||
endAt={endAt}
|
||||
/>
|
||||
</Card.Grid>
|
||||
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/2">
|
||||
<WebsiteMetricsTable
|
||||
websiteId={websiteId}
|
||||
type="country"
|
||||
title={[t('Countries'), t('Visitors')]}
|
||||
startAt={startAt}
|
||||
endAt={endAt}
|
||||
/>
|
||||
|
||||
<div className="mt-2 text-center">
|
||||
<WebsiteVisitorMapBtn websiteId={websiteId} />
|
||||
</div>
|
||||
</Card.Grid>
|
||||
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/2">
|
||||
<WebsiteMetricsTable
|
||||
websiteId={websiteId}
|
||||
type="event"
|
||||
title={[t('Events'), t('Actions')]}
|
||||
startAt={startAt}
|
||||
endAt={endAt}
|
||||
/>
|
||||
</Card.Grid>
|
||||
</Card>
|
||||
</ScrollArea>
|
||||
</CommonWrapper>
|
||||
);
|
||||
}
|
||||
|
119
src/client/routes/website/add.tsx
Normal file
119
src/client/routes/website/add.tsx
Normal file
@ -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<z.infer<typeof addFormSchema>>({
|
||||
resolver: zodResolver(addFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
domain: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = useEvent(async (values: z.infer<typeof addFormSchema>) => {
|
||||
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 (
|
||||
<CommonWrapper
|
||||
header={<h1 className="text-xl font-bold">{t('Add Website')}</h1>}
|
||||
>
|
||||
<div className="p-4">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Website Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Website Name to Display')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Domain')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" loading={addWebsiteMutation.isLoading}>
|
||||
{t('Create')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</CommonWrapper>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user