feat: add website add button and fuse search
This commit is contained in:
parent
3f13447e1f
commit
68ace91321
@ -75,6 +75,9 @@ importers:
|
|||||||
'@antv/larkmap':
|
'@antv/larkmap':
|
||||||
specifier: ^1.4.13
|
specifier: ^1.4.13
|
||||||
version: 1.4.13(@antv/l7@2.20.14)(react-dom@18.2.0)(react@18.2.0)
|
version: 1.4.13(@antv/l7@2.20.14)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@hookform/resolvers':
|
||||||
|
specifier: ^3.3.4
|
||||||
|
version: 3.3.4(react-hook-form@7.51.1)
|
||||||
'@i18next-toolkit/react':
|
'@i18next-toolkit/react':
|
||||||
specifier: ^1.0.6
|
specifier: ^1.0.6
|
||||||
version: 1.0.6(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0)
|
version: 1.0.6(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0)
|
||||||
@ -96,6 +99,9 @@ importers:
|
|||||||
'@radix-ui/react-icons':
|
'@radix-ui/react-icons':
|
||||||
specifier: ^1.3.0
|
specifier: ^1.3.0
|
||||||
version: 1.3.0(react@18.2.0)
|
version: 1.3.0(react@18.2.0)
|
||||||
|
'@radix-ui/react-label':
|
||||||
|
specifier: ^2.0.2
|
||||||
|
version: 2.0.2(@types/react-dom@18.2.7)(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0)
|
||||||
'@radix-ui/react-menubar':
|
'@radix-ui/react-menubar':
|
||||||
specifier: ^1.0.4
|
specifier: ^1.0.4
|
||||||
version: 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0)
|
version: 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0)
|
||||||
@ -177,6 +183,9 @@ importers:
|
|||||||
filesize:
|
filesize:
|
||||||
specifier: ^10.0.12
|
specifier: ^10.0.12
|
||||||
version: 10.0.12
|
version: 10.0.12
|
||||||
|
fuse.js:
|
||||||
|
specifier: ^7.0.0
|
||||||
|
version: 7.0.0
|
||||||
leaflet:
|
leaflet:
|
||||||
specifier: ^1.9.4
|
specifier: ^1.9.4
|
||||||
version: 1.9.4
|
version: 1.9.4
|
||||||
@ -207,6 +216,9 @@ importers:
|
|||||||
react-grid-layout:
|
react-grid-layout:
|
||||||
specifier: 1.4.2
|
specifier: 1.4.2
|
||||||
version: 1.4.2(react-dom@18.2.0)(react@18.2.0)
|
version: 1.4.2(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
react-hook-form:
|
||||||
|
specifier: ^7.51.1
|
||||||
|
version: 7.51.1(react@18.2.0)
|
||||||
react-icons:
|
react-icons:
|
||||||
specifier: ^4.12.0
|
specifier: ^4.12.0
|
||||||
version: 4.12.0(react@18.2.0)
|
version: 4.12.0(react@18.2.0)
|
||||||
@ -6251,6 +6263,14 @@ packages:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@hapi/hoek': 9.3.0
|
'@hapi/hoek': 9.3.0
|
||||||
|
|
||||||
|
/@hookform/resolvers@3.3.4(react-hook-form@7.51.1):
|
||||||
|
resolution: {integrity: sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react-hook-form: ^7.0.0
|
||||||
|
dependencies:
|
||||||
|
react-hook-form: 7.51.1(react@18.2.0)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@hutson/parse-repository-url@5.0.0:
|
/@hutson/parse-repository-url@5.0.0:
|
||||||
resolution: {integrity: sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg==}
|
resolution: {integrity: sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@ -7402,6 +7422,27 @@ packages:
|
|||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-label@2.0.2(@types/react-dom@18.2.7)(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.24.0
|
||||||
|
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@types/react': 18.2.21
|
||||||
|
'@types/react-dom': 18.2.7
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-menu@2.0.6(@types/react-dom@18.2.7)(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0):
|
/@radix-ui/react-menu@2.0.6(@types/react-dom@18.2.7)(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==}
|
resolution: {integrity: sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -14953,6 +14994,11 @@ packages:
|
|||||||
/functions-have-names@1.2.3:
|
/functions-have-names@1.2.3:
|
||||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||||
|
|
||||||
|
/fuse.js@7.0.0:
|
||||||
|
resolution: {integrity: sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/gensync@1.0.0-beta.2:
|
/gensync@1.0.0-beta.2:
|
||||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@ -22274,6 +22320,15 @@ packages:
|
|||||||
react-fast-compare: 3.2.2
|
react-fast-compare: 3.2.2
|
||||||
shallowequal: 1.1.0
|
shallowequal: 1.1.0
|
||||||
|
|
||||||
|
/react-hook-form@7.51.1(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-ifnBjl+kW0ksINHd+8C/Gp6a4eZOdWyvRv0UBaByShwU8JbVx5hTcTWEcd5VdybvmPTATkVVXk9npXArHmo56w==}
|
||||||
|
engines: {node: '>=12.22.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/react-i18next@14.0.5(i18next@23.10.0)(react-dom@18.2.0)(react@18.2.0):
|
/react-i18next@14.0.5(i18next@23.10.0)(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-5+bQSeEtgJrMBABBL5lO7jPdSNAbeAZ+MlFWDw//7FnVacuVu3l9EeWFzBQvZsKy+cihkbThWOAThEdH8YjGEw==}
|
resolution: {integrity: sha512-5+bQSeEtgJrMBABBL5lO7jPdSNAbeAZ+MlFWDw//7FnVacuVu3l9EeWFzBQvZsKy+cihkbThWOAThEdH8YjGEw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -3,6 +3,9 @@ import { ScrollArea } from './ui/scroll-area';
|
|||||||
import { cn } from '@/utils/style';
|
import { cn } from '@/utils/style';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { useNavigate, useRouterState } from '@tanstack/react-router';
|
import { useNavigate, useRouterState } from '@tanstack/react-router';
|
||||||
|
import { LuSearch } from 'react-icons/lu';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { useFuseSearch } from '@/hooks/useFuseSearch';
|
||||||
|
|
||||||
export interface CommonListItem {
|
export interface CommonListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -13,55 +16,96 @@ export interface CommonListItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CommonListProps {
|
interface CommonListProps {
|
||||||
|
hasSearch?: boolean;
|
||||||
items: CommonListItem[];
|
items: CommonListItem[];
|
||||||
}
|
}
|
||||||
export const CommonList: React.FC<CommonListProps> = React.memo((props) => {
|
export const CommonList: React.FC<CommonListProps> = React.memo((props) => {
|
||||||
const { location } = useRouterState();
|
const { location } = useRouterState();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
const { searchText, setSearchText, searchResult } = useFuseSearch(
|
||||||
<ScrollArea className="h-screen">
|
props.items,
|
||||||
<div className="flex flex-col gap-2 p-4 pt-0">
|
{
|
||||||
{props.items.map((item) => {
|
keys: [
|
||||||
const isSelected = item.href === location.pathname;
|
{
|
||||||
|
name: 'id',
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
weight: 0.7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tags',
|
||||||
|
weight: 0.3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
const finalList = searchResult ?? props.items;
|
||||||
<button
|
|
||||||
key={item.id}
|
return (
|
||||||
className={cn(
|
<>
|
||||||
'flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent',
|
{props.hasSearch && (
|
||||||
isSelected && 'bg-muted'
|
<div className="bg-background/95 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
)}
|
<form>
|
||||||
onClick={() =>
|
<div className="relative">
|
||||||
navigate({
|
<LuSearch className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
to: item.href,
|
<Input
|
||||||
})
|
placeholder="Search"
|
||||||
}
|
className="pl-8"
|
||||||
>
|
value={searchText}
|
||||||
<div className="flex w-full flex-col gap-1">
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
<div className="flex items-center">
|
/>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<div className="font-semibold">{item.title}</div>
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScrollArea className="h-screen">
|
||||||
|
<div className="flex flex-col gap-2 p-4 pt-0">
|
||||||
|
{finalList.map((item) => {
|
||||||
|
const isSelected = item.href === location.pathname;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent',
|
||||||
|
isSelected && 'bg-muted'
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: item.href,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-semibold">{item.title}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="line-clamp-2 text-xs text-muted-foreground">
|
||||||
<div className="line-clamp-2 text-xs text-muted-foreground">
|
{item.content}
|
||||||
{item.content}
|
|
||||||
</div>
|
|
||||||
{item.tags.length > 0 ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{item.tags.map((tag) => (
|
|
||||||
<Badge key={tag} variant={getBadgeVariantFromLabel(tag)}>
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
{item.tags.length > 0 ? (
|
||||||
</button>
|
<div className="flex items-center gap-2">
|
||||||
);
|
{item.tags.map((tag) => (
|
||||||
})}
|
<Badge key={tag} variant={getBadgeVariantFromLabel(tag)}>
|
||||||
</div>
|
{tag}
|
||||||
</ScrollArea>
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
CommonList.displayName = 'CommonList';
|
CommonList.displayName = 'CommonList';
|
||||||
|
@ -3,6 +3,8 @@ import { Slot } from '@radix-ui/react-slot';
|
|||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
import { cn } from '@/utils/style';
|
import { cn } from '@/utils/style';
|
||||||
|
import { Spinner } from './spinner';
|
||||||
|
import { IconType } from 'react-icons';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-zinc-300',
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-zinc-300',
|
||||||
@ -39,16 +41,40 @@ export interface ButtonProps
|
|||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
Icon?: IconType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
loading = false,
|
||||||
|
Icon,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
const Comp = asChild ? Slot : 'button';
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
|
||||||
|
const icon = Icon ? <Icon className="mr-1" /> : undefined;
|
||||||
|
const children = (
|
||||||
|
<>
|
||||||
|
{loading ? <Spinner className="mr-1" /> : icon}
|
||||||
|
{props.children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
disabled={loading}
|
||||||
{...props}
|
{...props}
|
||||||
|
children={children}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
176
src/client/components/ui/form.tsx
Normal file
176
src/client/components/ui/form.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ControllerProps,
|
||||||
|
FieldPath,
|
||||||
|
FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/utils/style"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState, formState } = useFormContext()
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
24
src/client/components/ui/label.tsx
Normal file
24
src/client/components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/utils/style"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
12
src/client/components/ui/spinner.tsx
Normal file
12
src/client/components/ui/spinner.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { cn } from '@/utils/style';
|
||||||
|
import React from 'react';
|
||||||
|
import { IconBaseProps } from 'react-icons';
|
||||||
|
import { LuLoader } from 'react-icons/lu';
|
||||||
|
|
||||||
|
interface SpinnerProps extends IconBaseProps {}
|
||||||
|
export const Spinner: React.FC<SpinnerProps> = React.memo((props) => {
|
||||||
|
return (
|
||||||
|
<LuLoader {...props} className={cn('animate-spin', props.className)} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Spinner.displayName = 'Spinner';
|
121
src/client/components/website/AddWebsiteBtn.tsx
Normal file
121
src/client/components/website/AddWebsiteBtn.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
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, useEventWithLoading } 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';
|
27
src/client/hooks/useFuseSearch.ts
Normal file
27
src/client/hooks/useFuseSearch.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import Fuse, { IFuseOptions } from 'fuse.js';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
export function useFuseSearch<T>(
|
||||||
|
source: T[],
|
||||||
|
options: IFuseOptions<T>
|
||||||
|
): {
|
||||||
|
searchText: string;
|
||||||
|
setSearchText: (text: string) => void;
|
||||||
|
searchResult: T[] | undefined;
|
||||||
|
} {
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
|
||||||
|
const fuse = useMemo(() => new Fuse(source, options), [source, options]);
|
||||||
|
|
||||||
|
const searchResult = useMemo(
|
||||||
|
() =>
|
||||||
|
searchText ? fuse.search(searchText).map((item) => item.item) : undefined,
|
||||||
|
[fuse, searchText]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchText,
|
||||||
|
setSearchText,
|
||||||
|
searchResult,
|
||||||
|
};
|
||||||
|
}
|
@ -20,12 +20,14 @@
|
|||||||
"@antv/l7": "^2.20.14",
|
"@antv/l7": "^2.20.14",
|
||||||
"@antv/larkmap": "^1.4.13",
|
"@antv/larkmap": "^1.4.13",
|
||||||
"@i18next-toolkit/react": "^1.0.6",
|
"@i18next-toolkit/react": "^1.0.6",
|
||||||
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@loadable/component": "^5.16.3",
|
"@loadable/component": "^5.16.3",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@radix-ui/react-menubar": "^1.0.4",
|
"@radix-ui/react-menubar": "^1.0.4",
|
||||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
@ -53,6 +55,7 @@
|
|||||||
"dayjs": "^1.11.9",
|
"dayjs": "^1.11.9",
|
||||||
"eventemitter-strict": "^1.0.1",
|
"eventemitter-strict": "^1.0.1",
|
||||||
"filesize": "^10.0.12",
|
"filesize": "^10.0.12",
|
||||||
|
"fuse.js": "^7.0.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lucide-react": "^0.358.0",
|
"lucide-react": "^0.358.0",
|
||||||
@ -63,6 +66,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-easy-sort": "^1.5.3",
|
"react-easy-sort": "^1.5.3",
|
||||||
"react-grid-layout": "1.4.2",
|
"react-grid-layout": "1.4.2",
|
||||||
|
"react-hook-form": "^7.51.1",
|
||||||
"react-icons": "^4.12.0",
|
"react-icons": "^4.12.0",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-resizable": "^3.0.5",
|
"react-resizable": "^3.0.5",
|
||||||
|
@ -1,15 +1,9 @@
|
|||||||
import { routeAuthBeforeLoad } from '@/utils/route';
|
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
beforeLoad: routeAuthBeforeLoad,
|
beforeLoad: () => {
|
||||||
component: Index,
|
redirect({
|
||||||
|
to: '/website',
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function Index() {
|
|
||||||
return (
|
|
||||||
<div className="p-2">
|
|
||||||
<h3>Welcome Home!</h3>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@ -1,13 +1,19 @@
|
|||||||
import { trpc } from '@/api/trpc';
|
import { trpc } from '@/api/trpc';
|
||||||
import { CommonList } from '@/components/CommonList';
|
import { CommonList } from '@/components/CommonList';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { AddWebsiteBtn } from '@/components/website/AddWebsiteBtn';
|
||||||
|
import { useDataReady } from '@/hooks/useDataReady';
|
||||||
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 { createFileRoute } from '@tanstack/react-router';
|
import { useTranslation } from '@i18next-toolkit/react';
|
||||||
import { LuSearch } from 'react-icons/lu';
|
import {
|
||||||
|
createFileRoute,
|
||||||
|
getRouteApi,
|
||||||
|
useNavigate,
|
||||||
|
} from '@tanstack/react-router';
|
||||||
|
|
||||||
|
const routeApi = getRouteApi('/website/$websiteId');
|
||||||
|
|
||||||
export const Route = createFileRoute('/website')({
|
export const Route = createFileRoute('/website')({
|
||||||
beforeLoad: routeAuthBeforeLoad,
|
beforeLoad: routeAuthBeforeLoad,
|
||||||
@ -16,9 +22,11 @@ export const Route = createFileRoute('/website')({
|
|||||||
|
|
||||||
function WebsiteComponent() {
|
function WebsiteComponent() {
|
||||||
const workspaceId = useCurrentWorkspaceId();
|
const workspaceId = useCurrentWorkspaceId();
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data = [] } = trpc.website.all.useQuery({
|
const { data = [] } = trpc.website.all.useQuery({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const items = data.map((item) => ({
|
const items = data.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@ -27,44 +35,37 @@ function WebsiteComponent() {
|
|||||||
tags: [],
|
tags: [],
|
||||||
href: `/website/${item.id}`,
|
href: `/website/${item.id}`,
|
||||||
}));
|
}));
|
||||||
|
const params = routeApi.useParams<{ websiteId: string }>();
|
||||||
|
|
||||||
|
useDataReady(
|
||||||
|
() => data.length > 0,
|
||||||
|
() => {
|
||||||
|
if (!params.websiteId && data[0]) {
|
||||||
|
navigate({
|
||||||
|
to: '/website/$websiteId',
|
||||||
|
params: {
|
||||||
|
websiteId: data[0].id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutV2
|
<LayoutV2
|
||||||
list={
|
list={
|
||||||
<Tabs defaultValue="all">
|
<div>
|
||||||
<div className="flex items-center px-4 py-2">
|
<div className="flex items-center px-4 py-2">
|
||||||
<h1 className="text-xl font-bold">Website</h1>
|
<h1 className="text-xl font-bold">{t('Website')}</h1>
|
||||||
<TabsList className="ml-auto">
|
|
||||||
<TabsTrigger
|
<div className="ml-auto">
|
||||||
value="all"
|
<AddWebsiteBtn />
|
||||||
className="text-zinc-600 dark:text-zinc-200"
|
</div>
|
||||||
>
|
|
||||||
All mail
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="unread"
|
|
||||||
className="text-zinc-600 dark:text-zinc-200"
|
|
||||||
>
|
|
||||||
Unread
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="bg-background/95 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
||||||
<form>
|
<CommonList hasSearch={true} items={items} />
|
||||||
<div className="relative">
|
</div>
|
||||||
<LuSearch className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input placeholder="Search" className="pl-8" />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<TabsContent value="all" className="m-0">
|
|
||||||
<CommonList items={items} />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="unread" className="m-0">
|
|
||||||
<CommonList items={items} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
13
src/client/utils/dom.ts
Normal file
13
src/client/utils/dom.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* A shortcut for executing directly in the component: stopPropagation
|
||||||
|
*/
|
||||||
|
export function stopPropagation(e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shortcut for executing directly in the component: preventDefault
|
||||||
|
*/
|
||||||
|
export function preventDefault(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
@ -77,6 +77,9 @@ export const websiteRouter = router({
|
|||||||
where: {
|
where: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
},
|
},
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: 'desc',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return websites;
|
return websites;
|
||||||
|
Loading…
Reference in New Issue
Block a user