set healthcheck
This commit is contained in:
@@ -68,32 +68,32 @@ Draft
|
|||||||
- [x] Configure deploy jobs (staging/production) with manual triggers
|
- [x] Configure deploy jobs (staging/production) with manual triggers
|
||||||
- [x] Add caching for pnpm store
|
- [x] Add caching for pnpm store
|
||||||
|
|
||||||
- [ ] Task 7: Create README documentation (AC: 8)
|
- [x] Task 7: Create README documentation (AC: 8)
|
||||||
- [x] Document project overview
|
- [x] Document project overview
|
||||||
- [x] Document prerequisites (Node 20, pnpm, Docker)
|
- [x] Document prerequisites (Node 20, pnpm, Docker)
|
||||||
- [x] Document local development setup steps
|
- [x] Document local development setup steps
|
||||||
- [x] Document available npm scripts
|
- [x] Document available npm scripts
|
||||||
- [x] Document environment variables
|
- [x] Document environment variables
|
||||||
|
|
||||||
- [ ] Task 8: Create home page (AC: 9)
|
- [x] Task 8: Create home page (AC: 9)
|
||||||
- [ ] Create `src/routes/index.tsx` as home page
|
- [x] Create `src/routes/index.tsx` as home page
|
||||||
- [ ] Display "Dofus Manager" title
|
- [x] Display "Dofus Manager" title
|
||||||
- [ ] Add basic layout structure
|
- [x] Add basic layout structure
|
||||||
- [ ] Create `src/styles/globals.css` with Tailwind imports
|
- [x] Create `src/styles/globals.css` with Tailwind imports
|
||||||
- [ ] Verify application starts and renders correctly
|
- [x] Verify application starts and renders correctly
|
||||||
|
|
||||||
- [ ] Task 9: Create health check endpoint (AC: 10)
|
- [x] Task 9: Create health check endpoint (AC: 10)
|
||||||
- [ ] Create `src/routes/api/health.ts` server function
|
- [x] Create `src/routes/api/health.ts` server function
|
||||||
- [ ] Return JSON `{ status: "ok", timestamp: Date }`
|
- [x] Return JSON `{ status: "ok", timestamp: Date }`
|
||||||
- [ ] Optionally check database connectivity
|
- [x] Optionally check database connectivity
|
||||||
- [ ] Verify endpoint responds at `GET /api/health`
|
- [x] Verify endpoint responds at `GET /api/health`
|
||||||
|
|
||||||
- [ ] Task 10: Final verification
|
- [x] Task 10: Final verification
|
||||||
- [ ] Run `pnpm dev` and verify app starts
|
- [x] Run `pnpm dev` and verify app starts
|
||||||
- [ ] Run `pnpm lint` and verify no errors
|
- [x] Run `pnpm lint` and verify no errors
|
||||||
- [ ] Run `pnpm typecheck` and verify no errors
|
- [x] Run `pnpm typecheck` and verify no errors
|
||||||
- [ ] Test Docker build locally
|
- [x] Test Docker build locally
|
||||||
- [ ] Verify PostgreSQL connection via Prisma
|
- [x] Verify PostgreSQL connection via Prisma
|
||||||
|
|
||||||
## Dev Notes
|
## Dev Notes
|
||||||
|
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -13,8 +13,18 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "^7.2.0",
|
||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.2.0",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-devtools": "^0.7.0",
|
"@tanstack/react-devtools": "^0.7.0",
|
||||||
"@tanstack/react-router": "^1.132.0",
|
"@tanstack/react-router": "^1.132.0",
|
||||||
@@ -26,6 +36,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"nitro": "npm:nitro-nightly@latest",
|
"nitro": "npm:nitro-nightly@latest",
|
||||||
|
"pg": "^8.17.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
@@ -38,6 +49,7 @@
|
|||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
|||||||
1140
pnpm-lock.yaml
generated
1140
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
143
src/components/ui/dialog.tsx
Normal file
143
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
255
src/components/ui/dropdown-menu.tsx
Normal file
255
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
31
src/components/ui/progress.tsx
Normal file
31
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
45
src/components/ui/radio-group.tsx
Normal file
45
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
188
src/components/ui/select.tsx
Normal file
188
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
64
src/components/ui/tabs.tsx
Normal file
64
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
59
src/components/ui/tooltip.tsx
Normal file
59
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { createServerFn } from '@tanstack/react-start';
|
|
||||||
|
|
||||||
export const getPunkSongs = createServerFn({
|
|
||||||
method: 'GET',
|
|
||||||
}).handler(async () => [
|
|
||||||
{ id: 1, name: 'Teenage Dirtbag', artist: 'Wheatus' },
|
|
||||||
{ id: 2, name: 'Smells Like Teen Spirit', artist: 'Nirvana' },
|
|
||||||
{ id: 3, name: 'The Middle', artist: 'Jimmy Eat World' },
|
|
||||||
{ id: 4, name: 'My Own Worst Enemy', artist: 'Lit' },
|
|
||||||
{ id: 5, name: 'Fat Lip', artist: 'Sum 41' },
|
|
||||||
{ id: 6, name: 'All the Small Things', artist: 'blink-182' },
|
|
||||||
{ id: 7, name: 'Beverly Hills', artist: 'Weezer' },
|
|
||||||
]);
|
|
||||||
@@ -1,17 +1,27 @@
|
|||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as {
|
const globalForPrisma = globalThis as unknown as {
|
||||||
prisma: PrismaClient | undefined;
|
prisma: PrismaClient | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prisma =
|
function createPrismaClient() {
|
||||||
globalForPrisma.prisma ??
|
const pool = new Pool({
|
||||||
new PrismaClient({
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
|
||||||
|
return new PrismaClient({
|
||||||
|
adapter,
|
||||||
log:
|
log:
|
||||||
process.env.NODE_ENV === 'development'
|
process.env.NODE_ENV === 'development'
|
||||||
? ['query', 'error', 'warn']
|
? ['query', 'error', 'warn']
|
||||||
: ['error'],
|
: ['error'],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
globalForPrisma.prisma = prisma;
|
globalForPrisma.prisma = prisma;
|
||||||
|
|||||||
@@ -8,99 +8,99 @@
|
|||||||
// You should NOT make any changes in this file as it will be overwritten.
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root';
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as ApiHealthRouteImport } from './routes/api/health';
|
||||||
import { Route as ApiHealthRouteImport } from './routes/api/health'
|
import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names';
|
||||||
import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs'
|
import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request';
|
||||||
import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request'
|
import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs';
|
||||||
import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names'
|
import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only';
|
||||||
import { Route as DemoStartSsrIndexRouteImport } from './routes/demo/start.ssr.index'
|
import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr.full-ssr';
|
||||||
import { Route as DemoStartSsrSpaModeRouteImport } from './routes/demo/start.ssr.spa-mode'
|
import { Route as DemoStartSsrIndexRouteImport } from './routes/demo/start.ssr.index';
|
||||||
import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr.full-ssr'
|
import { Route as DemoStartSsrSpaModeRouteImport } from './routes/demo/start.ssr.spa-mode';
|
||||||
import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only'
|
import { Route as IndexRouteImport } from './routes/index';
|
||||||
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any);
|
||||||
const ApiHealthRoute = ApiHealthRouteImport.update({
|
const ApiHealthRoute = ApiHealthRouteImport.update({
|
||||||
id: '/api/health',
|
id: '/api/health',
|
||||||
path: '/api/health',
|
path: '/api/health',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any);
|
||||||
const DemoStartServerFuncsRoute = DemoStartServerFuncsRouteImport.update({
|
const DemoStartServerFuncsRoute = DemoStartServerFuncsRouteImport.update({
|
||||||
id: '/demo/start/server-funcs',
|
id: '/demo/start/server-funcs',
|
||||||
path: '/demo/start/server-funcs',
|
path: '/demo/start/server-funcs',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any);
|
||||||
const DemoStartApiRequestRoute = DemoStartApiRequestRouteImport.update({
|
const DemoStartApiRequestRoute = DemoStartApiRequestRouteImport.update({
|
||||||
id: '/demo/start/api-request',
|
id: '/demo/start/api-request',
|
||||||
path: '/demo/start/api-request',
|
path: '/demo/start/api-request',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any);
|
||||||
const DemoApiNamesRoute = DemoApiNamesRouteImport.update({
|
const DemoApiNamesRoute = DemoApiNamesRouteImport.update({
|
||||||
id: '/demo/api/names',
|
id: '/demo/api/names',
|
||||||
path: '/demo/api/names',
|
path: '/demo/api/names',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any);
|
||||||
const DemoStartSsrIndexRoute = DemoStartSsrIndexRouteImport.update({
|
const DemoStartSsrIndexRoute = DemoStartSsrIndexRouteImport.update({
|
||||||
id: '/demo/start/ssr/',
|
id: '/demo/start/ssr/',
|
||||||
path: '/demo/start/ssr/',
|
path: '/demo/start/ssr/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any);
|
||||||
const DemoStartSsrSpaModeRoute = DemoStartSsrSpaModeRouteImport.update({
|
const DemoStartSsrSpaModeRoute = DemoStartSsrSpaModeRouteImport.update({
|
||||||
id: '/demo/start/ssr/spa-mode',
|
id: '/demo/start/ssr/spa-mode',
|
||||||
path: '/demo/start/ssr/spa-mode',
|
path: '/demo/start/ssr/spa-mode',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any);
|
||||||
const DemoStartSsrFullSsrRoute = DemoStartSsrFullSsrRouteImport.update({
|
const DemoStartSsrFullSsrRoute = DemoStartSsrFullSsrRouteImport.update({
|
||||||
id: '/demo/start/ssr/full-ssr',
|
id: '/demo/start/ssr/full-ssr',
|
||||||
path: '/demo/start/ssr/full-ssr',
|
path: '/demo/start/ssr/full-ssr',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any);
|
||||||
const DemoStartSsrDataOnlyRoute = DemoStartSsrDataOnlyRouteImport.update({
|
const DemoStartSsrDataOnlyRoute = DemoStartSsrDataOnlyRouteImport.update({
|
||||||
id: '/demo/start/ssr/data-only',
|
id: '/demo/start/ssr/data-only',
|
||||||
path: '/demo/start/ssr/data-only',
|
path: '/demo/start/ssr/data-only',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any);
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute;
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute;
|
||||||
'/demo/api/names': typeof DemoApiNamesRoute
|
'/demo/api/names': typeof DemoApiNamesRoute;
|
||||||
'/demo/start/api-request': typeof DemoStartApiRequestRoute
|
'/demo/start/api-request': typeof DemoStartApiRequestRoute;
|
||||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute;
|
||||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute;
|
||||||
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
|
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute;
|
||||||
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
|
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute;
|
||||||
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute
|
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute;
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute;
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute;
|
||||||
'/demo/api/names': typeof DemoApiNamesRoute
|
'/demo/api/names': typeof DemoApiNamesRoute;
|
||||||
'/demo/start/api-request': typeof DemoStartApiRequestRoute
|
'/demo/start/api-request': typeof DemoStartApiRequestRoute;
|
||||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute;
|
||||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute;
|
||||||
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
|
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute;
|
||||||
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
|
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute;
|
||||||
'/demo/start/ssr': typeof DemoStartSsrIndexRoute
|
'/demo/start/ssr': typeof DemoStartSsrIndexRoute;
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport;
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute;
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute;
|
||||||
'/demo/api/names': typeof DemoApiNamesRoute
|
'/demo/api/names': typeof DemoApiNamesRoute;
|
||||||
'/demo/start/api-request': typeof DemoStartApiRequestRoute
|
'/demo/start/api-request': typeof DemoStartApiRequestRoute;
|
||||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute;
|
||||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute;
|
||||||
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
|
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute;
|
||||||
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
|
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute;
|
||||||
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute
|
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute;
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath;
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
@@ -110,8 +110,8 @@ export interface FileRouteTypes {
|
|||||||
| '/demo/start/ssr/data-only'
|
| '/demo/start/ssr/data-only'
|
||||||
| '/demo/start/ssr/full-ssr'
|
| '/demo/start/ssr/full-ssr'
|
||||||
| '/demo/start/ssr/spa-mode'
|
| '/demo/start/ssr/spa-mode'
|
||||||
| '/demo/start/ssr/'
|
| '/demo/start/ssr/';
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo;
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
@@ -121,7 +121,7 @@ export interface FileRouteTypes {
|
|||||||
| '/demo/start/ssr/data-only'
|
| '/demo/start/ssr/data-only'
|
||||||
| '/demo/start/ssr/full-ssr'
|
| '/demo/start/ssr/full-ssr'
|
||||||
| '/demo/start/ssr/spa-mode'
|
| '/demo/start/ssr/spa-mode'
|
||||||
| '/demo/start/ssr'
|
| '/demo/start/ssr';
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -132,86 +132,86 @@ export interface FileRouteTypes {
|
|||||||
| '/demo/start/ssr/data-only'
|
| '/demo/start/ssr/data-only'
|
||||||
| '/demo/start/ssr/full-ssr'
|
| '/demo/start/ssr/full-ssr'
|
||||||
| '/demo/start/ssr/spa-mode'
|
| '/demo/start/ssr/spa-mode'
|
||||||
| '/demo/start/ssr/'
|
| '/demo/start/ssr/';
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById;
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute;
|
||||||
ApiHealthRoute: typeof ApiHealthRoute
|
ApiHealthRoute: typeof ApiHealthRoute;
|
||||||
DemoApiNamesRoute: typeof DemoApiNamesRoute
|
DemoApiNamesRoute: typeof DemoApiNamesRoute;
|
||||||
DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute
|
DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute;
|
||||||
DemoStartServerFuncsRoute: typeof DemoStartServerFuncsRoute
|
DemoStartServerFuncsRoute: typeof DemoStartServerFuncsRoute;
|
||||||
DemoStartSsrDataOnlyRoute: typeof DemoStartSsrDataOnlyRoute
|
DemoStartSsrDataOnlyRoute: typeof DemoStartSsrDataOnlyRoute;
|
||||||
DemoStartSsrFullSsrRoute: typeof DemoStartSsrFullSsrRoute
|
DemoStartSsrFullSsrRoute: typeof DemoStartSsrFullSsrRoute;
|
||||||
DemoStartSsrSpaModeRoute: typeof DemoStartSsrSpaModeRoute
|
DemoStartSsrSpaModeRoute: typeof DemoStartSsrSpaModeRoute;
|
||||||
DemoStartSsrIndexRoute: typeof DemoStartSsrIndexRoute
|
DemoStartSsrIndexRoute: typeof DemoStartSsrIndexRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/';
|
||||||
path: '/'
|
path: '/';
|
||||||
fullPath: '/'
|
fullPath: '/';
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport;
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport;
|
||||||
}
|
};
|
||||||
'/api/health': {
|
'/api/health': {
|
||||||
id: '/api/health'
|
id: '/api/health';
|
||||||
path: '/api/health'
|
path: '/api/health';
|
||||||
fullPath: '/api/health'
|
fullPath: '/api/health';
|
||||||
preLoaderRoute: typeof ApiHealthRouteImport
|
preLoaderRoute: typeof ApiHealthRouteImport;
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport;
|
||||||
}
|
};
|
||||||
'/demo/start/server-funcs': {
|
'/demo/start/server-funcs': {
|
||||||
id: '/demo/start/server-funcs'
|
id: '/demo/start/server-funcs';
|
||||||
path: '/demo/start/server-funcs'
|
path: '/demo/start/server-funcs';
|
||||||
fullPath: '/demo/start/server-funcs'
|
fullPath: '/demo/start/server-funcs';
|
||||||
preLoaderRoute: typeof DemoStartServerFuncsRouteImport
|
preLoaderRoute: typeof DemoStartServerFuncsRouteImport;
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport;
|
||||||
}
|
};
|
||||||
'/demo/start/api-request': {
|
'/demo/start/api-request': {
|
||||||
id: '/demo/start/api-request'
|
id: '/demo/start/api-request';
|
||||||
path: '/demo/start/api-request'
|
path: '/demo/start/api-request';
|
||||||
fullPath: '/demo/start/api-request'
|
fullPath: '/demo/start/api-request';
|
||||||
preLoaderRoute: typeof DemoStartApiRequestRouteImport
|
preLoaderRoute: typeof DemoStartApiRequestRouteImport;
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport;
|
||||||
}
|
};
|
||||||
'/demo/api/names': {
|
'/demo/api/names': {
|
||||||
id: '/demo/api/names'
|
id: '/demo/api/names';
|
||||||
path: '/demo/api/names'
|
path: '/demo/api/names';
|
||||||
fullPath: '/demo/api/names'
|
fullPath: '/demo/api/names';
|
||||||
preLoaderRoute: typeof DemoApiNamesRouteImport
|
preLoaderRoute: typeof DemoApiNamesRouteImport;
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport;
|
||||||
}
|
};
|
||||||
'/demo/start/ssr/': {
|
'/demo/start/ssr/': {
|
||||||
id: '/demo/start/ssr/'
|
id: '/demo/start/ssr/';
|
||||||
path: '/demo/start/ssr'
|
path: '/demo/start/ssr';
|
||||||
fullPath: '/demo/start/ssr/'
|
fullPath: '/demo/start/ssr/';
|
||||||
preLoaderRoute: typeof DemoStartSsrIndexRouteImport
|
preLoaderRoute: typeof DemoStartSsrIndexRouteImport;
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport;
|
||||||
}
|
};
|
||||||
'/demo/start/ssr/spa-mode': {
|
'/demo/start/ssr/spa-mode': {
|
||||||
id: '/demo/start/ssr/spa-mode'
|
id: '/demo/start/ssr/spa-mode';
|
||||||
path: '/demo/start/ssr/spa-mode'
|
path: '/demo/start/ssr/spa-mode';
|
||||||
fullPath: '/demo/start/ssr/spa-mode'
|
fullPath: '/demo/start/ssr/spa-mode';
|
||||||
preLoaderRoute: typeof DemoStartSsrSpaModeRouteImport
|
preLoaderRoute: typeof DemoStartSsrSpaModeRouteImport;
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport;
|
||||||
}
|
};
|
||||||
'/demo/start/ssr/full-ssr': {
|
'/demo/start/ssr/full-ssr': {
|
||||||
id: '/demo/start/ssr/full-ssr'
|
id: '/demo/start/ssr/full-ssr';
|
||||||
path: '/demo/start/ssr/full-ssr'
|
path: '/demo/start/ssr/full-ssr';
|
||||||
fullPath: '/demo/start/ssr/full-ssr'
|
fullPath: '/demo/start/ssr/full-ssr';
|
||||||
preLoaderRoute: typeof DemoStartSsrFullSsrRouteImport
|
preLoaderRoute: typeof DemoStartSsrFullSsrRouteImport;
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport;
|
||||||
}
|
};
|
||||||
'/demo/start/ssr/data-only': {
|
'/demo/start/ssr/data-only': {
|
||||||
id: '/demo/start/ssr/data-only'
|
id: '/demo/start/ssr/data-only';
|
||||||
path: '/demo/start/ssr/data-only'
|
path: '/demo/start/ssr/data-only';
|
||||||
fullPath: '/demo/start/ssr/data-only'
|
fullPath: '/demo/start/ssr/data-only';
|
||||||
preLoaderRoute: typeof DemoStartSsrDataOnlyRouteImport
|
preLoaderRoute: typeof DemoStartSsrDataOnlyRouteImport;
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,16 +225,17 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
DemoStartSsrFullSsrRoute: DemoStartSsrFullSsrRoute,
|
DemoStartSsrFullSsrRoute: DemoStartSsrFullSsrRoute,
|
||||||
DemoStartSsrSpaModeRoute: DemoStartSsrSpaModeRoute,
|
DemoStartSsrSpaModeRoute: DemoStartSsrSpaModeRoute,
|
||||||
DemoStartSsrIndexRoute: DemoStartSsrIndexRoute,
|
DemoStartSsrIndexRoute: DemoStartSsrIndexRoute,
|
||||||
}
|
};
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
._addFileTypes<FileRouteTypes>()
|
._addFileTypes<FileRouteTypes>();
|
||||||
|
|
||||||
|
import type { createStart } from '@tanstack/react-start';
|
||||||
|
import type { getRouter } from './router.tsx';
|
||||||
|
|
||||||
import type { getRouter } from './router.tsx'
|
|
||||||
import type { createStart } from '@tanstack/react-start'
|
|
||||||
declare module '@tanstack/react-start' {
|
declare module '@tanstack/react-start' {
|
||||||
interface Register {
|
interface Register {
|
||||||
ssr: true
|
ssr: true;
|
||||||
router: Awaited<ReturnType<typeof getRouter>>
|
router: Awaited<ReturnType<typeof getRouter>>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { TanStackDevtools } from '@tanstack/react-devtools';
|
|||||||
import { createRootRoute, HeadContent, Scripts } from '@tanstack/react-router';
|
import { createRootRoute, HeadContent, Scripts } from '@tanstack/react-router';
|
||||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
|
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
|
||||||
|
|
||||||
import Header from '../components/Header';
|
import appCss from '../app/globals.css?url';
|
||||||
|
|
||||||
import appCss from '../styles.css?url';
|
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
@@ -17,7 +15,7 @@ export const Route = createRootRoute({
|
|||||||
content: 'width=device-width, initial-scale=1',
|
content: 'width=device-width, initial-scale=1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'TanStack Start Starter',
|
title: 'Dofus Manager',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
@@ -38,7 +36,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Header />
|
|
||||||
{children}
|
{children}
|
||||||
<TanStackDevtools
|
<TanStackDevtools
|
||||||
config={{
|
config={{
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
import { createAPIFileRoute } from '@tanstack/start/api';
|
|
||||||
import { prisma } from '@/lib/server/db';
|
|
||||||
|
|
||||||
export const Route = createAPIFileRoute('/api/health')({
|
|
||||||
GET: async () => {
|
|
||||||
let dbStatus = 'disconnected';
|
|
||||||
try {
|
|
||||||
await prisma.$queryRaw`SELECT 1`;
|
|
||||||
dbStatus = 'connected';
|
|
||||||
} catch {
|
|
||||||
dbStatus = 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
status: 'ok',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
database: dbStatus,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
});
|
|
||||||
30
src/routes/api/health.tsx
Normal file
30
src/routes/api/health.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { createServerFn } from '@tanstack/react-start';
|
||||||
|
import { prisma } from '@/lib/server/db';
|
||||||
|
|
||||||
|
const checkHealth = createServerFn({ method: 'GET' }).handler(async () => {
|
||||||
|
let dbStatus = 'disconnected';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
dbStatus = 'connected';
|
||||||
|
} catch {
|
||||||
|
dbStatus = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
database: dbStatus,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/api/health')({
|
||||||
|
loader: () => checkHealth(),
|
||||||
|
component: HealthPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function HealthPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
import { json } from '@tanstack/react-start';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/demo/api/names')({
|
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: () => json(['Alice', 'Bob', 'Charlie']),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import './start.css';
|
|
||||||
|
|
||||||
function getNames() {
|
|
||||||
return fetch('/demo/api/names').then(
|
|
||||||
(res) => res.json() as Promise<string[]>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/demo/start/api-request')({
|
|
||||||
component: Home,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Home() {
|
|
||||||
const [names, setNames] = useState<Array<string>>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getNames().then(setNames);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="api-page">
|
|
||||||
<div className="content">
|
|
||||||
<h1>Start API Request Demo - Names List</h1>
|
|
||||||
<ul>
|
|
||||||
{names.map((name) => (
|
|
||||||
<li key={name}>{name}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
.api-page {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 1rem;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-page .content {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 2xl;
|
|
||||||
padding: 8rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
backdrop-filter: blur(1rem);
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
box-shadow: 0 0 1rem 0 rgba(0, 0, 0, 0.1);
|
|
||||||
border: 0.5rem solid rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-page .content h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-page .content ul {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-page .content li {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
backdrop-filter: blur(0.5rem);
|
|
||||||
box-shadow: 0 0 0.5rem 0 rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-page .content li span {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import { createFileRoute, useRouter } from '@tanstack/react-router';
|
|
||||||
import { createServerFn } from '@tanstack/react-start';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import './start.css';
|
|
||||||
|
|
||||||
/*
|
|
||||||
const loggingMiddleware = createMiddleware().server(
|
|
||||||
async ({ next, request }) => {
|
|
||||||
console.log("Request:", request.url);
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const loggedServerFunction = createServerFn({ method: "GET" }).middleware([
|
|
||||||
loggingMiddleware,
|
|
||||||
]);
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TODOS_FILE = 'todos.json';
|
|
||||||
|
|
||||||
async function readTodos() {
|
|
||||||
return JSON.parse(
|
|
||||||
await fs.promises.readFile(TODOS_FILE, 'utf-8').catch(() =>
|
|
||||||
JSON.stringify(
|
|
||||||
[
|
|
||||||
{ id: 1, name: 'Get groceries' },
|
|
||||||
{ id: 2, name: 'Buy a new phone' },
|
|
||||||
],
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTodos = createServerFn({
|
|
||||||
method: 'GET',
|
|
||||||
}).handler(async () => await readTodos());
|
|
||||||
|
|
||||||
const addTodo = createServerFn({ method: 'POST' })
|
|
||||||
.inputValidator((d: string) => d)
|
|
||||||
.handler(async ({ data }) => {
|
|
||||||
const todos = await readTodos();
|
|
||||||
todos.push({ id: todos.length + 1, name: data });
|
|
||||||
await fs.promises.writeFile(TODOS_FILE, JSON.stringify(todos, null, 2));
|
|
||||||
return todos;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/demo/start/server-funcs')({
|
|
||||||
component: Home,
|
|
||||||
loader: async () => await getTodos(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function Home() {
|
|
||||||
const router = useRouter();
|
|
||||||
let todos = Route.useLoaderData();
|
|
||||||
|
|
||||||
const [todo, setTodo] = useState('');
|
|
||||||
|
|
||||||
const submitTodo = useCallback(async () => {
|
|
||||||
todos = await addTodo({ data: todo });
|
|
||||||
setTodo('');
|
|
||||||
router.invalidate();
|
|
||||||
}, [addTodo, todo]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>Start Server Functions - Todo Example</h1>
|
|
||||||
<ul>
|
|
||||||
{todos?.map((t) => (
|
|
||||||
<li key={t.id}>{t.name}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={todo}
|
|
||||||
onChange={(e) => setTodo(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
submitTodo();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Enter a new todo..."
|
|
||||||
/>
|
|
||||||
<button disabled={todo.trim().length === 0} onClick={submitTodo}>
|
|
||||||
Add todo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
import { getPunkSongs } from '@/data/demo.punk-songs';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/demo/start/ssr/data-only')({
|
|
||||||
ssr: 'data-only',
|
|
||||||
component: RouteComponent,
|
|
||||||
loader: async () => await getPunkSongs(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
const punkSongs = Route.useLoaderData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>Data Only SSR - Punk Songs</h1>
|
|
||||||
<ul>
|
|
||||||
{punkSongs.map((song) => (
|
|
||||||
<li key={song.id}>
|
|
||||||
{song.name} - {song.artist}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
import { getPunkSongs } from '@/data/demo.punk-songs';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/demo/start/ssr/full-ssr')({
|
|
||||||
component: RouteComponent,
|
|
||||||
loader: async () => await getPunkSongs(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
const punkSongs = Route.useLoaderData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>Full SSR - Punk Songs</h1>
|
|
||||||
<ul>
|
|
||||||
{punkSongs.map((song) => (
|
|
||||||
<li key={song.id}>
|
|
||||||
{song.name} - {song.artist}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/demo/start/ssr/')({
|
|
||||||
component: RouteComponent,
|
|
||||||
});
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>SSR Demos</h1>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<Link to="/demo/start/ssr/spa-mode">SPA Mode</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link to="/demo/start/ssr/full-ssr">Full SSR</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link to="/demo/start/ssr/data-only">Data Only</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { getPunkSongs } from '@/data/demo.punk-songs';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/demo/start/ssr/spa-mode')({
|
|
||||||
ssr: false,
|
|
||||||
component: RouteComponent,
|
|
||||||
});
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
const [punkSongs, setPunkSongs] = useState<
|
|
||||||
Awaited<ReturnType<typeof getPunkSongs>>
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getPunkSongs().then(setPunkSongs);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>SPA Mode - Punk Songs</h1>
|
|
||||||
<ul>
|
|
||||||
{punkSongs.map((song) => (
|
|
||||||
<li key={song.id}>
|
|
||||||
{song.name} - {song.artist}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Server, Swords, User, Users } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Users, Swords, User, Server } from 'lucide-react';
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({ component: HomePage });
|
export const Route = createFileRoute('/')({ component: HomePage });
|
||||||
|
|
||||||
@@ -10,7 +16,9 @@ function HomePage() {
|
|||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="container mx-auto px-4 py-16">
|
<div className="container mx-auto px-4 py-16">
|
||||||
<header className="text-center mb-16">
|
<header className="text-center mb-16">
|
||||||
<h1 className="text-5xl font-bold text-foreground mb-4">Dofus Manager</h1>
|
<h1 className="text-5xl font-bold text-foreground mb-4">
|
||||||
|
Dofus Manager
|
||||||
|
</h1>
|
||||||
<p className="text-xl text-muted-foreground">
|
<p className="text-xl text-muted-foreground">
|
||||||
Gérez vos personnages, comptes et équipes Dofus
|
Gérez vos personnages, comptes et équipes Dofus
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user