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>
|
<TokenLoginContainer>
|
||||||
{isDev ? (
|
{isDev ? (
|
||||||
<RouterProvider router={router} context={{ userInfo }} />
|
// Compatible with old routes
|
||||||
|
<BrowserRouter>
|
||||||
|
<RouterProvider router={router} context={{ userInfo }} />
|
||||||
|
</BrowserRouter>
|
||||||
) : (
|
) : (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppRoutes />
|
<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} />
|
<UserConfig isCollapsed={isCollapsed} />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
<ResizablePanel defaultSize={layout[1]} minSize={30}>
|
<ResizablePanel defaultSize={layout[1]} minSize={25}>
|
||||||
<div className="h-full overflow-hidden">{props.list}</div>
|
<div className="h-full overflow-hidden">{props.list}</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
<ResizablePanel defaultSize={layout[2]}>
|
<ResizablePanel defaultSize={layout[2]}>
|
||||||
<div>
|
<div className="h-full overflow-hidden">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
@ -15,6 +15,7 @@ import { Route as WebsiteImport } from './routes/website'
|
|||||||
import { Route as RegisterImport } from './routes/register'
|
import { Route as RegisterImport } from './routes/register'
|
||||||
import { Route as LoginImport } from './routes/login'
|
import { Route as LoginImport } from './routes/login'
|
||||||
import { Route as IndexImport } from './routes/index'
|
import { Route as IndexImport } from './routes/index'
|
||||||
|
import { Route as WebsiteAddImport } from './routes/website/add'
|
||||||
import { Route as WebsiteWebsiteIdImport } from './routes/website/$websiteId'
|
import { Route as WebsiteWebsiteIdImport } from './routes/website/$websiteId'
|
||||||
|
|
||||||
// Create/Update Routes
|
// Create/Update Routes
|
||||||
@ -39,6 +40,11 @@ const IndexRoute = IndexImport.update({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
|
const WebsiteAddRoute = WebsiteAddImport.update({
|
||||||
|
path: '/add',
|
||||||
|
getParentRoute: () => WebsiteRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const WebsiteWebsiteIdRoute = WebsiteWebsiteIdImport.update({
|
const WebsiteWebsiteIdRoute = WebsiteWebsiteIdImport.update({
|
||||||
path: '/$websiteId',
|
path: '/$websiteId',
|
||||||
getParentRoute: () => WebsiteRoute,
|
getParentRoute: () => WebsiteRoute,
|
||||||
@ -68,6 +74,10 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof WebsiteWebsiteIdImport
|
preLoaderRoute: typeof WebsiteWebsiteIdImport
|
||||||
parentRoute: typeof WebsiteImport
|
parentRoute: typeof WebsiteImport
|
||||||
}
|
}
|
||||||
|
'/website/add': {
|
||||||
|
preLoaderRoute: typeof WebsiteAddImport
|
||||||
|
parentRoute: typeof WebsiteImport
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,7 +87,7 @@ export const routeTree = rootRoute.addChildren([
|
|||||||
IndexRoute,
|
IndexRoute,
|
||||||
LoginRoute,
|
LoginRoute,
|
||||||
RegisterRoute,
|
RegisterRoute,
|
||||||
WebsiteRoute.addChildren([WebsiteWebsiteIdRoute]),
|
WebsiteRoute.addChildren([WebsiteWebsiteIdRoute, WebsiteAddRoute]),
|
||||||
])
|
])
|
||||||
|
|
||||||
/* prettier-ignore-end */
|
/* prettier-ignore-end */
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
import { trpc } from '@/api/trpc';
|
import { trpc } from '@/api/trpc';
|
||||||
|
import { CommonHeader } from '@/components/CommonHeader';
|
||||||
import { CommonList } from '@/components/CommonList';
|
import { CommonList } from '@/components/CommonList';
|
||||||
import { CommonSidebar } from '@/components/CommonSidebar';
|
import { CommonWrapper } from '@/components/CommonWrapper';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Button } from '@/components/ui/button';
|
||||||
import { AddWebsiteBtn } from '@/components/website/AddWebsiteBtn';
|
|
||||||
import { useDataReady } from '@/hooks/useDataReady';
|
import { useDataReady } from '@/hooks/useDataReady';
|
||||||
|
import { useEvent } from '@/hooks/useEvent';
|
||||||
import { LayoutV2 } from '@/pages/LayoutV2';
|
import { LayoutV2 } from '@/pages/LayoutV2';
|
||||||
import { useCurrentWorkspaceId } from '@/store/user';
|
import { useCurrentWorkspaceId } from '@/store/user';
|
||||||
import { routeAuthBeforeLoad } from '@/utils/route';
|
import { routeAuthBeforeLoad } from '@/utils/route';
|
||||||
import { useTranslation } from '@i18next-toolkit/react';
|
import { useTranslation } from '@i18next-toolkit/react';
|
||||||
import {
|
import {
|
||||||
createFileRoute,
|
createFileRoute,
|
||||||
getRouteApi,
|
|
||||||
useNavigate,
|
useNavigate,
|
||||||
|
useRouterState,
|
||||||
} from '@tanstack/react-router';
|
} from '@tanstack/react-router';
|
||||||
|
import { LuPlus } from 'react-icons/lu';
|
||||||
const routeApi = getRouteApi('/website/$websiteId');
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/website')({
|
export const Route = createFileRoute('/website')({
|
||||||
beforeLoad: routeAuthBeforeLoad,
|
beforeLoad: routeAuthBeforeLoad,
|
||||||
@ -28,6 +28,9 @@ function WebsiteComponent() {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const pathname = useRouterState({
|
||||||
|
select: (state) => state.location.pathname,
|
||||||
|
});
|
||||||
|
|
||||||
const items = data.map((item) => ({
|
const items = data.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@ -36,12 +39,11 @@ function WebsiteComponent() {
|
|||||||
tags: [],
|
tags: [],
|
||||||
href: `/website/${item.id}`,
|
href: `/website/${item.id}`,
|
||||||
}));
|
}));
|
||||||
const params = routeApi.useParams<{ websiteId: string }>();
|
|
||||||
|
|
||||||
useDataReady(
|
useDataReady(
|
||||||
() => data.length > 0,
|
() => data.length > 0,
|
||||||
() => {
|
() => {
|
||||||
if (!params.websiteId && data[0]) {
|
if (pathname === Route.fullPath) {
|
||||||
navigate({
|
navigate({
|
||||||
to: '/website/$websiteId',
|
to: '/website/$websiteId',
|
||||||
params: {
|
params: {
|
||||||
@ -52,22 +54,33 @@ function WebsiteComponent() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleClickAdd = useEvent(() => {
|
||||||
|
navigate({
|
||||||
|
to: '/website/add',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutV2
|
<LayoutV2
|
||||||
list={
|
list={
|
||||||
<CommonSidebar
|
<CommonWrapper
|
||||||
header={
|
header={
|
||||||
<>
|
<CommonHeader
|
||||||
<h1 className="text-xl font-bold">{t('Website')}</h1>
|
title={t('Website')}
|
||||||
|
actions={
|
||||||
<div className="ml-auto">
|
<Button
|
||||||
<AddWebsiteBtn />
|
variant="outline"
|
||||||
</div>
|
Icon={LuPlus}
|
||||||
</>
|
onClick={handleClickAdd}
|
||||||
|
>
|
||||||
|
{t('Add')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CommonList hasSearch={true} items={items} />
|
<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 { 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')({
|
export const Route = createFileRoute('/website/$websiteId')({
|
||||||
beforeLoad: routeAuthBeforeLoad,
|
beforeLoad: routeAuthBeforeLoad,
|
||||||
@ -7,7 +24,119 @@ export const Route = createFileRoute('/website/$websiteId')({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function WebsiteDetailComponent() {
|
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