admin and qr
This commit is contained in:
parent
7df70be593
commit
7ad18d53f3
105 changed files with 20181 additions and 44 deletions
|
|
@ -6,7 +6,8 @@
|
|||
"Bash(npx prisma init)",
|
||||
"Bash(npx prisma generate:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(docker-compose up:*)"
|
||||
"Bash(docker-compose up:*)",
|
||||
"Bash(npx prisma migrate:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
admin/.dockerignore
Normal file
9
admin/.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
dist
|
||||
.vscode
|
||||
.idea
|
||||
.cursor
|
||||
*.log
|
||||
**/.DS_Store
|
||||
24
admin/.gitignore
vendored
Normal file
24
admin/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
139
admin/@/components/ui/alert-dialog.tsx
Normal file
139
admin/@/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
57
admin/@/components/ui/button.tsx
Normal file
57
admin/@/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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 buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants }
|
||||
79
admin/@/components/ui/card.tsx
Normal file
79
admin/@/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
120
admin/@/components/ui/dialog.tsx
Normal file
120
admin/@/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
177
admin/@/components/ui/form.tsx
Normal file
177
admin/@/components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
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 | null>(null)
|
||||
|
||||
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()
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
if (!itemContext) {
|
||||
throw new Error("useFormField should be used within <FormItem>")
|
||||
}
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
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 | null>(null)
|
||||
|
||||
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-sm 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-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
22
admin/@/components/ui/input.tsx
Normal file
22
admin/@/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
24
admin/@/components/ui/label.tsx
Normal file
24
admin/@/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
160
admin/@/components/ui/select.tsx
Normal file
160
admin/@/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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 origin-[--radix-select-content-transform-origin]",
|
||||
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}
|
||||
{...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)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
43
admin/@/components/ui/sonner.tsx
Normal file
43
admin/@/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import {
|
||||
CircleCheck,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
OctagonX,
|
||||
TriangleAlert,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheck className="h-4 w-4" />,
|
||||
info: <Info className="h-4 w-4" />,
|
||||
warning: <TriangleAlert className="h-4 w-4" />,
|
||||
error: <OctagonX className="h-4 w-4" />,
|
||||
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
|
||||
}}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
117
admin/@/components/ui/table.tsx
Normal file
117
admin/@/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
22
admin/@/components/ui/textarea.tsx
Normal file
22
admin/@/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
37
admin/Dockerfile
Normal file
37
admin/Dockerfile
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# --- Build stage ------------------------------------------------------------
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files for dependency caching
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Verify build output
|
||||
RUN ls -la dist/ && test -f dist/index.html || (echo "ERROR: index.html not found!" && exit 1)
|
||||
|
||||
# --- Runtime stage ----------------------------------------------------------
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
# Install wget for healthcheck
|
||||
RUN apk add --no-cache wget
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built app
|
||||
COPY --from=build /app/dist/ /usr/share/nginx/html/
|
||||
|
||||
# Verify files were copied
|
||||
RUN ls -la /usr/share/nginx/html/ && test -f /usr/share/nginx/html/index.html || (echo "ERROR: index.html not copied!" && exit 1)
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1/ || exit 1
|
||||
73
admin/README.md
Normal file
73
admin/README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
18
admin/components.json
Normal file
18
admin/components.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui"
|
||||
}
|
||||
}
|
||||
23
admin/eslint.config.js
Normal file
23
admin/eslint.config.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
admin/index.html
Normal file
13
admin/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
41
admin/nginx.conf
Normal file
41
admin/nginx.conf
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json;
|
||||
|
||||
# Security headers (enhanced for admin panel)
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1h;
|
||||
add_header Cache-Control "private, must-revalidate";
|
||||
}
|
||||
|
||||
# React SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Security - deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
7436
admin/package-lock.json
generated
Normal file
7436
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
59
admin/package.json
Normal file
59
admin/package.json
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"name": "sto-k-odnomu-admin",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5174",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview --port 5174",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.67.0",
|
||||
"react-router-dom": "^7.10.0",
|
||||
"recharts": "^3.5.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^27.0.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
6
admin/postcss.config.cjs
Normal file
6
admin/postcss.config.cjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
admin/public/vite.svg
Normal file
1
admin/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
admin/src/App.css
Normal file
42
admin/src/App.css
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
47
admin/src/App.tsx
Normal file
47
admin/src/App.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import LoginPage from '@/pages/LoginPage'
|
||||
import DashboardPage from '@/pages/DashboardPage'
|
||||
import CardsPage from '@/pages/CardsPage'
|
||||
import PacksPage from '@/pages/PacksPage'
|
||||
import UsersPage from '@/pages/UsersPage'
|
||||
import TestsPage from '@/pages/TestsPage'
|
||||
import Layout from '@/components/layout/Layout'
|
||||
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
|
||||
return (
|
||||
<TokenRefreshProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/cards" element={<CardsPage />} />
|
||||
<Route path="/packs" element={<PacksPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/tests" element={<TestsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</TokenRefreshProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
55
admin/src/api/analytics.ts
Normal file
55
admin/src/api/analytics.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { adminApiClient } from './client'
|
||||
|
||||
export interface DashboardStats {
|
||||
users: number
|
||||
cards: number
|
||||
packs: number
|
||||
enabledPacks: number
|
||||
payments: number
|
||||
}
|
||||
|
||||
export interface RecentUser {
|
||||
id: number
|
||||
name?: string
|
||||
email?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface TopPack {
|
||||
id: number
|
||||
title: string
|
||||
cards: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface ChartDataPoint {
|
||||
date: string
|
||||
registrations?: number
|
||||
revenue?: number
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
stats: DashboardStats
|
||||
recentUsers: RecentUser[]
|
||||
topPacks: TopPack[]
|
||||
}
|
||||
|
||||
export const analyticsApi = {
|
||||
// Get dashboard analytics
|
||||
getDashboard: async (): Promise<DashboardData> => {
|
||||
const response = await adminApiClient.get('/api/v2/admin/analytics/dashboard')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get user registration chart data
|
||||
getUsersChart: async (): Promise<{ data: ChartDataPoint[]; period: string }> => {
|
||||
const response = await adminApiClient.get('/api/v2/admin/analytics/users/chart')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get revenue chart data
|
||||
getRevenueChart: async (): Promise<{ data: ChartDataPoint[]; period: string; currency: string }> => {
|
||||
const response = await adminApiClient.get('/api/v2/admin/analytics/revenue/chart')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
105
admin/src/api/auth.test.ts
Normal file
105
admin/src/api/auth.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { MockedFunction } from 'vitest'
|
||||
import { authApi } from './auth'
|
||||
|
||||
// Mock the adminApiClient module
|
||||
vi.mock('./client', () => ({
|
||||
adminApiClient: {
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Import the mocked module
|
||||
import { adminApiClient } from './client'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
// Type the mocked functions
|
||||
const mockPost = adminApiClient.post as MockedFunction<typeof adminApiClient.post>
|
||||
const mockGet = adminApiClient.get as MockedFunction<typeof adminApiClient.get>
|
||||
|
||||
describe('authApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('requestCode', () => {
|
||||
it('should call adminApiClient.post with correct endpoint', async () => {
|
||||
const mockResponse = {
|
||||
data: { success: true, message: 'Code sent' },
|
||||
} as AxiosResponse
|
||||
mockPost.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await authApi.requestCode()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/api/v2/admin/auth/request-code')
|
||||
expect(result).toEqual({ success: true, message: 'Code sent' })
|
||||
})
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
const error = new Error('Network error')
|
||||
mockPost.mockRejectedValue(error)
|
||||
|
||||
await expect(authApi.requestCode()).rejects.toThrow('Network error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyCode', () => {
|
||||
it('should call adminApiClient.post with code and correct endpoint', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
token: 'jwt-token',
|
||||
user: { id: 1, name: 'Admin', admin: true },
|
||||
},
|
||||
} as AxiosResponse
|
||||
mockPost.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await authApi.verifyCode('123456')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/api/v2/admin/auth/verify-code', {
|
||||
code: '123456',
|
||||
})
|
||||
expect(result).toEqual(mockResponse.data)
|
||||
})
|
||||
|
||||
it('should handle invalid code response', async () => {
|
||||
const mockResponse = {
|
||||
data: { success: false, message: 'Invalid code' },
|
||||
} as AxiosResponse
|
||||
mockPost.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await authApi.verifyCode('invalid')
|
||||
|
||||
expect(result).toEqual({ success: false, message: 'Invalid code' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
it('should call adminApiClient.get with correct endpoint', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
user: { id: 1, name: 'Admin', admin: true },
|
||||
},
|
||||
} as AxiosResponse
|
||||
mockGet.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await authApi.getCurrentUser()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/api/v2/admin/auth/me')
|
||||
expect(result).toEqual(mockResponse.data)
|
||||
})
|
||||
|
||||
it('should handle unauthorized access', async () => {
|
||||
const mockResponse = {
|
||||
data: { success: false, message: 'Not authenticated' },
|
||||
} as AxiosResponse
|
||||
mockGet.mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await authApi.getCurrentUser()
|
||||
|
||||
expect(result).toEqual({ success: false, message: 'Not authenticated' })
|
||||
})
|
||||
})
|
||||
})
|
||||
57
admin/src/api/auth.ts
Normal file
57
admin/src/api/auth.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { adminApiClient } from './client'
|
||||
import type { AuthResponse, RequestCodeResponse, CodeStatusResponse, RefreshTokenResponse } from '@/types/models'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export const authApi = {
|
||||
// Request authentication code (creates web code)
|
||||
requestCode: async (): Promise<RequestCodeResponse> => {
|
||||
const response = await adminApiClient.post('/api/admin/auth/request-code')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get code status
|
||||
getCodeStatus: async (code: string): Promise<CodeStatusResponse> => {
|
||||
try {
|
||||
const response = await adminApiClient.get(`/api/admin/auth/code-status/${code}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError
|
||||
// If 404, return a proper error response
|
||||
if (axiosError.response?.status === 404) {
|
||||
return {
|
||||
success: false,
|
||||
code: code,
|
||||
status: 'not_found',
|
||||
remainingSeconds: 0,
|
||||
isClaimed: false,
|
||||
isUsed: false,
|
||||
error: 'NotFound',
|
||||
message: 'Code not found',
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Verify authentication code and login
|
||||
verifyCode: async (code: string): Promise<AuthResponse> => {
|
||||
const response = await adminApiClient.post('/api/admin/auth/verify-code', {
|
||||
code,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get current admin info (for token validation)
|
||||
getCurrentUser: async (): Promise<{ success: boolean; user?: unknown; message?: string }> => {
|
||||
const response = await adminApiClient.get('/api/admin/auth/me')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Refresh access token using refresh token
|
||||
refreshToken: async (refreshToken: string): Promise<RefreshTokenResponse> => {
|
||||
const response = await adminApiClient.post('/api/admin/auth/refresh', {
|
||||
refreshToken,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
131
admin/src/api/cards.ts
Normal file
131
admin/src/api/cards.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { adminApiClient } from './client'
|
||||
import type { GameCardDto, PaginatedResponse } from '@/types/models'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export interface CardsApiError {
|
||||
message: string
|
||||
statusCode?: number
|
||||
field?: string
|
||||
originalError?: unknown
|
||||
name: 'CardsApiError'
|
||||
}
|
||||
|
||||
export function createCardsApiError(
|
||||
message: string,
|
||||
statusCode?: number,
|
||||
field?: string,
|
||||
originalError?: unknown
|
||||
): CardsApiError {
|
||||
return {
|
||||
message,
|
||||
statusCode,
|
||||
field,
|
||||
originalError,
|
||||
name: 'CardsApiError',
|
||||
}
|
||||
}
|
||||
|
||||
export function isCardsApiError(error: unknown): error is CardsApiError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
error.name === 'CardsApiError'
|
||||
)
|
||||
}
|
||||
|
||||
export const cardsApi = {
|
||||
// Get all cards with pagination and search
|
||||
getCards: async (params?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
}): Promise<PaginatedResponse<GameCardDto>> => {
|
||||
try {
|
||||
const response = await adminApiClient.get('/api/v2/admin/cards', {
|
||||
params: {
|
||||
page: params?.page || 1,
|
||||
limit: params?.limit || 20,
|
||||
search: params?.search,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }>
|
||||
throw createCardsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
'Failed to load cards',
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.field,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Get a specific card by ID
|
||||
getCard: async (cardId: string): Promise<GameCardDto> => {
|
||||
try {
|
||||
const response = await adminApiClient.get(`/api/v2/admin/cards/${cardId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
throw createCardsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to load card ${cardId}`,
|
||||
axiosError.response?.status,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Create or update a card
|
||||
upsertCard: async (card: GameCardDto): Promise<{ success: boolean; card: GameCardDto }> => {
|
||||
try {
|
||||
const response = await adminApiClient.post('/api/v2/admin/cards', card)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }>
|
||||
const isUpdate = card.id !== null && card.id !== undefined && card.id !== ''
|
||||
const operation = isUpdate ? 'update card' : 'create card'
|
||||
const message = axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to ${operation}`
|
||||
|
||||
let fullMessage = message
|
||||
if (axiosError.response?.data?.details) {
|
||||
fullMessage += `. ${axiosError.response.data.details}`
|
||||
}
|
||||
if (axiosError.response?.data?.field) {
|
||||
fullMessage += ` (Field: ${axiosError.response.data.field})`
|
||||
}
|
||||
|
||||
throw createCardsApiError(
|
||||
fullMessage,
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.field,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a card by ID
|
||||
deleteCard: async (cardId: string): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await adminApiClient.delete(`/api/v2/admin/cards/${cardId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
throw createCardsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to delete card ${cardId}`,
|
||||
axiosError.response?.status,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
87
admin/src/api/cardsWithVoice.test.ts
Normal file
87
admin/src/api/cardsWithVoice.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import type { GameCardDto } from '@/types/models'
|
||||
import { cardsApi } from './cards'
|
||||
import { voicesApi } from './voices'
|
||||
import { upsertCardWithOptionalVoice } from './cardsWithVoice'
|
||||
|
||||
describe('upsertCardWithOptionalVoice', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does not add voice when none provided', async () => {
|
||||
vi.spyOn(cardsApi, 'upsertCard').mockResolvedValue({
|
||||
success: true,
|
||||
card: { id: 'card-1' } satisfies GameCardDto,
|
||||
})
|
||||
const addVoiceSpy = vi
|
||||
.spyOn(voicesApi, 'addCardVoice')
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
voice: {
|
||||
id: 'voice-1',
|
||||
cardId: 'card-1',
|
||||
voiceUrl: 'BASE64',
|
||||
language: 'en',
|
||||
createdAt: new Date(0).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
await upsertCardWithOptionalVoice({ id: null } satisfies GameCardDto)
|
||||
|
||||
expect(addVoiceSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds voice after upsert when provided (uses response card id)', async () => {
|
||||
vi.spyOn(cardsApi, 'upsertCard').mockResolvedValue({
|
||||
success: true,
|
||||
card: { id: 'card-42' } satisfies GameCardDto,
|
||||
})
|
||||
const addVoiceSpy = vi
|
||||
.spyOn(voicesApi, 'addCardVoice')
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
voice: {
|
||||
id: 'voice-1',
|
||||
cardId: 'card-42',
|
||||
voiceUrl: 'BASE64',
|
||||
language: 'en',
|
||||
createdAt: new Date(0).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
await upsertCardWithOptionalVoice(
|
||||
{ id: null } satisfies GameCardDto,
|
||||
{ voiceUrl: 'BASE64', language: 'en' },
|
||||
)
|
||||
|
||||
expect(addVoiceSpy).toHaveBeenCalledWith('card-42', 'BASE64', 'en')
|
||||
})
|
||||
|
||||
it('falls back to request card id when response card id is missing', async () => {
|
||||
vi.spyOn(cardsApi, 'upsertCard').mockResolvedValue({
|
||||
success: true,
|
||||
card: { id: null } satisfies GameCardDto,
|
||||
})
|
||||
const addVoiceSpy = vi
|
||||
.spyOn(voicesApi, 'addCardVoice')
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
voice: {
|
||||
id: 'voice-1',
|
||||
cardId: 'card-req',
|
||||
voiceUrl: 'BASE64',
|
||||
language: 'es',
|
||||
createdAt: new Date(0).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
await upsertCardWithOptionalVoice(
|
||||
{ id: 'card-req' } satisfies GameCardDto,
|
||||
{ voiceUrl: 'BASE64', language: 'es' },
|
||||
)
|
||||
|
||||
expect(addVoiceSpy).toHaveBeenCalledWith('card-req', 'BASE64', 'es')
|
||||
})
|
||||
})
|
||||
|
||||
59
admin/src/api/cardsWithVoice.ts
Normal file
59
admin/src/api/cardsWithVoice.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { GameCardDto } from '@/types/models'
|
||||
import { cardsApi } from './cards'
|
||||
import { voicesApi } from './voices'
|
||||
|
||||
export interface PendingVoiceUpload {
|
||||
voiceUrl: string
|
||||
language: string
|
||||
}
|
||||
|
||||
export async function upsertCardWithOptionalVoice(
|
||||
card: GameCardDto,
|
||||
voice?: PendingVoiceUpload,
|
||||
): Promise<{ success: boolean; card: GameCardDto }> {
|
||||
console.log('🔍 upsertCardWithOptionalVoice: Starting', {
|
||||
cardId: card.id,
|
||||
hasVoice: !!voice,
|
||||
voiceUrl: voice?.voiceUrl,
|
||||
language: voice?.language,
|
||||
})
|
||||
|
||||
const response = await cardsApi.upsertCard(card)
|
||||
|
||||
const responseCardId = response.card?.id != null ? String(response.card.id) : ''
|
||||
const requestCardId = card.id != null ? String(card.id) : ''
|
||||
const cardId = responseCardId || requestCardId
|
||||
|
||||
console.log('🔍 upsertCardWithOptionalVoice: After upsertCard', {
|
||||
responseCardId,
|
||||
requestCardId,
|
||||
cardId,
|
||||
hasVoice: !!voice,
|
||||
voiceUrl: voice?.voiceUrl,
|
||||
})
|
||||
|
||||
if (!voice || !voice.voiceUrl || !cardId) {
|
||||
console.log('⚠️ upsertCardWithOptionalVoice: Skipping voice link', {
|
||||
hasVoice: !!voice,
|
||||
voiceUrl: voice?.voiceUrl,
|
||||
cardId,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
console.log('✅ upsertCardWithOptionalVoice: Calling addCardVoice', {
|
||||
cardId,
|
||||
voiceUrl: voice.voiceUrl,
|
||||
language: voice.language,
|
||||
})
|
||||
|
||||
try {
|
||||
await voicesApi.addCardVoice(cardId, voice.voiceUrl, voice.language)
|
||||
console.log('✅ upsertCardWithOptionalVoice: Successfully added voice')
|
||||
} catch (error) {
|
||||
console.error('❌ upsertCardWithOptionalVoice: Error adding voice', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
127
admin/src/api/client.test.ts
Normal file
127
admin/src/api/client.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { apiClient, adminApiClient } from './client'
|
||||
|
||||
describe('API Clients', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Clear localStorage
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('apiClient', () => {
|
||||
it('should be defined and have correct base URL', () => {
|
||||
expect(apiClient).toBeDefined()
|
||||
expect(apiClient.defaults.baseURL).toBe('https://api.mnemo-cards.online')
|
||||
})
|
||||
|
||||
it('should have interceptors configured', () => {
|
||||
expect(apiClient.interceptors).toBeDefined()
|
||||
expect(apiClient.interceptors.request).toBeDefined()
|
||||
expect(apiClient.interceptors.response).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('adminApiClient', () => {
|
||||
it('should always use production API URL', () => {
|
||||
expect(adminApiClient).toBeDefined()
|
||||
expect(adminApiClient.defaults.baseURL).toBe('https://api.mnemo-cards.online')
|
||||
})
|
||||
|
||||
it('should be a separate instance from apiClient', () => {
|
||||
expect(adminApiClient).not.toBe(apiClient)
|
||||
expect(adminApiClient.defaults.baseURL).toBe(apiClient.defaults.baseURL)
|
||||
})
|
||||
|
||||
it('should have interceptors configured', () => {
|
||||
expect(adminApiClient.interceptors).toBeDefined()
|
||||
expect(adminApiClient.interceptors.request).toBeDefined()
|
||||
expect(adminApiClient.interceptors.response).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auth token handling', () => {
|
||||
it('should handle auth tokens correctly in request interceptor', () => {
|
||||
const token = 'test-admin-token'
|
||||
localStorage.setItem('admin_token', token)
|
||||
|
||||
const requestConfig = { headers: {} as Record<string, string> }
|
||||
const addAuthToken = (config: { headers: Record<string, string> }) => {
|
||||
const storedToken = localStorage.getItem('admin_token')
|
||||
if (storedToken) {
|
||||
config.headers.Authorization = `Bearer ${storedToken}`
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
const result = addAuthToken(requestConfig)
|
||||
expect(result.headers.Authorization).toBe(`Bearer ${token}`)
|
||||
})
|
||||
|
||||
it('should not add authorization header when no token exists', () => {
|
||||
const requestConfig = { headers: {} as Record<string, string> }
|
||||
const addAuthToken = (config: { headers: Record<string, string> }) => {
|
||||
const storedToken = localStorage.getItem('admin_token')
|
||||
if (storedToken) {
|
||||
config.headers.Authorization = `Bearer ${storedToken}`
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
const result = addAuthToken(requestConfig)
|
||||
expect(result.headers.Authorization).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle 401 errors by clearing token and redirecting', async () => {
|
||||
// Mock window.location
|
||||
const mockLocation = { href: '' }
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const handleAuthError = (error: { response?: { status?: number } }) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('admin_token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const error = { response: { status: 401 } }
|
||||
|
||||
// Set a token first
|
||||
localStorage.setItem('admin_token', 'some-token')
|
||||
|
||||
await expect(handleAuthError(error)).rejects.toEqual(error)
|
||||
|
||||
expect(localStorage.getItem('admin_token')).toBeNull()
|
||||
expect(window.location.href).toBe('/login')
|
||||
})
|
||||
|
||||
it('should not redirect for non-401 errors', async () => {
|
||||
// Mock window.location
|
||||
const mockLocation = { href: '' }
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const handleAuthError = (error: { response?: { status?: number } }) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('admin_token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const error = { response: { status: 500 } }
|
||||
|
||||
await expect(handleAuthError(error)).rejects.toEqual(error)
|
||||
|
||||
expect(localStorage.getItem('admin_token')).toBeNull() // Should be cleared from beforeEach
|
||||
expect(window.location.href).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
200
admin/src/api/client.ts
Normal file
200
admin/src/api/client.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { authApi } from './auth'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
|
||||
|
||||
// Admin API client - same as regular API for sto_k_odnomu
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Export adminApiClient as alias for compatibility
|
||||
export const adminApiClient = apiClient
|
||||
|
||||
// Request interceptor to add auth token
|
||||
const addAuthToken = (config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
const isPublicAuthEndpoint =
|
||||
config.url?.includes('/admin/auth/request-code') ||
|
||||
config.url?.includes('/admin/auth/verify-code') ||
|
||||
config.url?.includes('/admin/auth/code-status')
|
||||
|
||||
if (token && token.trim()) {
|
||||
// Ensure headers object exists
|
||||
if (!config.headers) {
|
||||
config.headers = {} as InternalAxiosRequestConfig['headers']
|
||||
}
|
||||
const trimmedToken = token.trim()
|
||||
// Set Authorization header - axios will normalize it to lowercase for HTTP
|
||||
// but we use the standard capitalization
|
||||
config.headers['Authorization'] = `Bearer ${trimmedToken}`
|
||||
|
||||
// Debug logging for admin endpoints
|
||||
if (!isPublicAuthEndpoint && config.url?.includes('/admin/')) {
|
||||
console.log('[API Client] Adding auth token to request:', config.url)
|
||||
console.log('[API Client] Token exists, length:', trimmedToken.length)
|
||||
console.log('[API Client] Authorization header set:', config.headers['Authorization']?.substring(0, 30) + '...')
|
||||
}
|
||||
} else {
|
||||
// Log error if token is missing (except for public auth endpoints)
|
||||
if (!isPublicAuthEndpoint) {
|
||||
console.error('[API Client] No admin token found in localStorage for request:', config.url)
|
||||
console.error('[API Client] localStorage.getItem("admin_token"):', localStorage.getItem('admin_token'))
|
||||
console.error('[API Client] All localStorage keys:', Object.keys(localStorage))
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// Flag to prevent multiple logout calls
|
||||
let isLoggingOut = false
|
||||
// Flag to prevent multiple refresh calls
|
||||
let isRefreshing = false
|
||||
let refreshPromise: Promise<string> | null = null
|
||||
|
||||
// Function to refresh token
|
||||
const refreshAccessToken = async (): Promise<string> => {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('admin_refresh_token')
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
const response = await authApi.refreshToken(refreshToken)
|
||||
if (!response.success || !response.token) {
|
||||
throw new Error('Failed to refresh token')
|
||||
}
|
||||
|
||||
// Update store with new tokens
|
||||
useAuthStore.getState().updateToken(
|
||||
response.token,
|
||||
response.refreshToken,
|
||||
response.expiresIn
|
||||
)
|
||||
|
||||
return response.token
|
||||
} catch (error) {
|
||||
console.error('[API Client] Token refresh failed:', error)
|
||||
// If refresh fails, logout
|
||||
useAuthStore.getState().logout()
|
||||
throw error
|
||||
} finally {
|
||||
refreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
// Request interceptor to check and refresh token if needed
|
||||
const checkAndRefreshToken = async (config: InternalAxiosRequestConfig) => {
|
||||
const isPublicAuthEndpoint =
|
||||
config.url?.includes('/admin/auth/request-code') ||
|
||||
config.url?.includes('/admin/auth/verify-code') ||
|
||||
config.url?.includes('/admin/auth/code-status') ||
|
||||
config.url?.includes('/admin/auth/refresh')
|
||||
|
||||
// Skip token refresh for public endpoints
|
||||
if (isPublicAuthEndpoint) {
|
||||
return config
|
||||
}
|
||||
|
||||
// Check if token needs refresh
|
||||
if (useAuthStore.getState().shouldRefreshToken() && !isRefreshing) {
|
||||
isRefreshing = true
|
||||
try {
|
||||
const newToken = await refreshAccessToken()
|
||||
// Update token in config
|
||||
if (config.headers) {
|
||||
config.headers['Authorization'] = `Bearer ${newToken}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[API Client] Failed to refresh token before request:', error)
|
||||
// Will be handled by response interceptor
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// Response interceptor for error handling
|
||||
const handleAuthError = async (error: unknown) => {
|
||||
const axiosError = error as AxiosError
|
||||
const originalRequest = axiosError.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
if (axiosError.response?.status === 401) {
|
||||
// Check if we're already on login page
|
||||
const currentPath = window.location.pathname
|
||||
const isPublicAuthEndpoint =
|
||||
originalRequest?.url?.includes('/admin/auth/request-code') ||
|
||||
originalRequest?.url?.includes('/admin/auth/verify-code') ||
|
||||
originalRequest?.url?.includes('/admin/auth/code-status') ||
|
||||
originalRequest?.url?.includes('/admin/auth/refresh')
|
||||
|
||||
// Don't logout for public auth endpoints or if already on login page
|
||||
if (currentPath === '/login' || isPublicAuthEndpoint) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// Try to refresh token if we haven't retried yet
|
||||
if (!originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
|
||||
const refreshToken = localStorage.getItem('admin_refresh_token')
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const newToken = await refreshAccessToken()
|
||||
|
||||
// Retry original request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newToken}`
|
||||
}
|
||||
|
||||
return adminApiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
console.error('[API Client] Token refresh failed, logging out:', refreshError)
|
||||
// Refresh failed, proceed to logout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent multiple logout calls
|
||||
if (isLoggingOut) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
isLoggingOut = true
|
||||
|
||||
// Remove token and update auth store
|
||||
// React Router will automatically redirect to /login when isAuthenticated becomes false
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_refresh_token')
|
||||
useAuthStore.getState().logout()
|
||||
|
||||
// Reset flag after a short delay to allow React Router to handle redirect
|
||||
setTimeout(() => {
|
||||
isLoggingOut = false
|
||||
}, 1000)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// Apply interceptors to both clients
|
||||
apiClient.interceptors.request.use(checkAndRefreshToken)
|
||||
apiClient.interceptors.request.use(addAuthToken)
|
||||
apiClient.interceptors.response.use((response) => response, handleAuthError)
|
||||
|
||||
adminApiClient.interceptors.request.use(checkAndRefreshToken)
|
||||
adminApiClient.interceptors.request.use(addAuthToken)
|
||||
adminApiClient.interceptors.response.use((response) => response, handleAuthError)
|
||||
109
admin/src/api/media.ts
Normal file
109
admin/src/api/media.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { adminApiClient } from './client'
|
||||
|
||||
export interface UploadResponse {
|
||||
objectId: string
|
||||
url: string // Presigned URL for preview
|
||||
}
|
||||
|
||||
export interface PresignedUrlResponse {
|
||||
url: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export const mediaApi = {
|
||||
/**
|
||||
* Upload card image to MinIO
|
||||
* @param file Image file to upload
|
||||
* @returns Object ID and presigned URL
|
||||
*/
|
||||
async uploadCardImage(file: File): Promise<UploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await adminApiClient.post<UploadResponse>(
|
||||
'/api/v2/media/upload/card-image',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload test image to MinIO
|
||||
* @param file Image file to upload
|
||||
* @returns Object ID and presigned URL
|
||||
*/
|
||||
async uploadTestImage(file: File): Promise<UploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await adminApiClient.post<UploadResponse>(
|
||||
'/api/v2/media/upload/test-image',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload voice audio file to MinIO
|
||||
* @param file Audio file to upload
|
||||
* @returns Object ID and presigned URL
|
||||
*/
|
||||
async uploadVoice(file: File): Promise<UploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await adminApiClient.post<UploadResponse>(
|
||||
'/api/v2/media/upload/voice',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get presigned URL for an object
|
||||
* @param bucket Bucket name (card-images, test-images, voice-audio)
|
||||
* @param objectId Object ID (UUID)
|
||||
* @param expirySeconds Optional expiry time in seconds
|
||||
* @returns Presigned URL and expiration time
|
||||
*/
|
||||
async getPresignedUrl(
|
||||
bucket: string,
|
||||
objectId: string,
|
||||
expirySeconds?: number
|
||||
): Promise<PresignedUrlResponse> {
|
||||
const params = expirySeconds ? { expirySeconds: expirySeconds.toString() } : {}
|
||||
const response = await adminApiClient.get<PresignedUrlResponse>(
|
||||
`/api/v2/media/${bucket}/${objectId}/url`,
|
||||
{ params }
|
||||
)
|
||||
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a file from MinIO
|
||||
* @param bucket Bucket name
|
||||
* @param objectId Object ID to delete
|
||||
*/
|
||||
async deleteFile(bucket: string, objectId: string): Promise<void> {
|
||||
await adminApiClient.delete(`/api/v2/media/${bucket}/${objectId}`)
|
||||
},
|
||||
}
|
||||
215
admin/src/api/packs.ts
Normal file
215
admin/src/api/packs.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { adminApiClient } from './client'
|
||||
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export interface PacksApiError {
|
||||
message: string
|
||||
statusCode?: number
|
||||
field?: string
|
||||
originalError?: unknown
|
||||
name: 'PacksApiError'
|
||||
}
|
||||
|
||||
export function createPacksApiError(
|
||||
message: string,
|
||||
statusCode?: number,
|
||||
field?: string,
|
||||
originalError?: unknown
|
||||
): PacksApiError {
|
||||
return {
|
||||
message,
|
||||
statusCode,
|
||||
field,
|
||||
originalError,
|
||||
name: 'PacksApiError',
|
||||
}
|
||||
}
|
||||
|
||||
export function isPacksApiError(error: unknown): error is PacksApiError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
error.name === 'PacksApiError'
|
||||
)
|
||||
}
|
||||
|
||||
export const packsApi = {
|
||||
// Get all packs with pagination and search
|
||||
getPacks: async (params?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
showDisabled?: boolean
|
||||
}): Promise<PaginatedResponse<CardPackPreviewDto>> => {
|
||||
try {
|
||||
const response = await adminApiClient.get('/api/v2/admin/packs', {
|
||||
params: {
|
||||
page: params?.page || 1,
|
||||
limit: params?.limit || 20,
|
||||
search: params?.search,
|
||||
showDisabled: params?.showDisabled,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }>
|
||||
|
||||
// Check for validation errors (limit too high, etc.)
|
||||
if (axiosError.response?.status === 400) {
|
||||
const errorData = axiosError.response.data
|
||||
if (errorData?.message?.includes('Limit')) {
|
||||
throw createPacksApiError(
|
||||
`Invalid limit: ${errorData.message}. Maximum allowed limit is 100.`,
|
||||
axiosError.response.status,
|
||||
'limit',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
throw createPacksApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
'Failed to load packs',
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.field,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Get pack details by ID for editing
|
||||
getPack: async (packId: string): Promise<EditCardPackDto> => {
|
||||
try {
|
||||
const response = await adminApiClient.get(`/api/v2/admin/packs/${packId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
|
||||
if (axiosError.response?.status === 404) {
|
||||
throw createPacksApiError(
|
||||
`Pack not found: The pack with ID "${packId}" does not exist or has been deleted.`,
|
||||
axiosError.response.status,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
throw createPacksApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to load pack ${packId}`,
|
||||
axiosError.response?.status,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Create or update pack
|
||||
upsertPack: async (pack: EditCardPackDto): Promise<{ success: boolean; pack: EditCardPackDto }> => {
|
||||
try {
|
||||
const response = await adminApiClient.post('/api/v2/admin/packs', pack)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }>
|
||||
const isUpdate = pack.id && pack.id.length > 0
|
||||
const operation = isUpdate ? 'update pack' : 'create pack'
|
||||
const baseMessage = axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to ${operation}`
|
||||
|
||||
let fullMessage = baseMessage
|
||||
if (axiosError.response?.data?.details) {
|
||||
fullMessage += `. ${axiosError.response.data.details}`
|
||||
}
|
||||
|
||||
// Special handling for common errors
|
||||
if (axiosError.response?.status === 404 && isUpdate) {
|
||||
fullMessage = `Pack not found: The pack you're trying to update (ID: ${pack.id}) does not exist.`
|
||||
}
|
||||
|
||||
if (axiosError.response?.data?.field) {
|
||||
fullMessage += ` (Field: ${axiosError.response.data.field})`
|
||||
}
|
||||
|
||||
throw createPacksApiError(
|
||||
fullMessage,
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.field,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Link a test to a pack (requires sending pack title due to backend validation)
|
||||
linkTestToPack: async (
|
||||
packId: string,
|
||||
testId: string,
|
||||
): Promise<{ success: boolean; pack: EditCardPackDto }> => {
|
||||
const pack = await packsApi.getPack(packId)
|
||||
const title = pack.title?.trim()
|
||||
if (!title) {
|
||||
throw createPacksApiError(
|
||||
`Pack "${packId}" has no title; cannot link test`,
|
||||
400,
|
||||
'title',
|
||||
)
|
||||
}
|
||||
return packsApi.upsertPack({
|
||||
id: packId,
|
||||
title,
|
||||
addTestIds: [testId],
|
||||
})
|
||||
},
|
||||
|
||||
// Unlink a test from a pack (requires sending pack title due to backend validation)
|
||||
unlinkTestFromPack: async (
|
||||
packId: string,
|
||||
testId: string,
|
||||
): Promise<{ success: boolean; pack: EditCardPackDto }> => {
|
||||
const pack = await packsApi.getPack(packId)
|
||||
const title = pack.title?.trim()
|
||||
if (!title) {
|
||||
throw createPacksApiError(
|
||||
`Pack "${packId}" has no title; cannot unlink test`,
|
||||
400,
|
||||
'title',
|
||||
)
|
||||
}
|
||||
return packsApi.upsertPack({
|
||||
id: packId,
|
||||
title,
|
||||
removeTestIds: [testId],
|
||||
})
|
||||
},
|
||||
|
||||
// Delete pack
|
||||
deletePack: async (packId: string): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const response = await adminApiClient.delete(`/api/v2/admin/packs/${packId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
|
||||
if (axiosError.response?.status === 404) {
|
||||
throw createPacksApiError(
|
||||
`Pack not found: The pack with ID "${packId}" does not exist and cannot be deleted.`,
|
||||
axiosError.response.status,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
throw createPacksApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to delete pack ${packId}`,
|
||||
axiosError.response?.status,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
131
admin/src/api/tests.ts
Normal file
131
admin/src/api/tests.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { adminApiClient } from './client'
|
||||
import type { TestDto, PaginatedResponse } from '@/types/models'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export interface TestsApiError {
|
||||
message: string
|
||||
statusCode?: number
|
||||
field?: string
|
||||
originalError?: unknown
|
||||
name: 'TestsApiError'
|
||||
}
|
||||
|
||||
export function createTestsApiError(
|
||||
message: string,
|
||||
statusCode?: number,
|
||||
field?: string,
|
||||
originalError?: unknown
|
||||
): TestsApiError {
|
||||
return {
|
||||
message,
|
||||
statusCode,
|
||||
field,
|
||||
originalError,
|
||||
name: 'TestsApiError',
|
||||
}
|
||||
}
|
||||
|
||||
export function isTestsApiError(error: unknown): error is TestsApiError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
error.name === 'TestsApiError'
|
||||
)
|
||||
}
|
||||
|
||||
export const testsApi = {
|
||||
// Get all tests with pagination and search
|
||||
getTests: async (params?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
}): Promise<PaginatedResponse<TestDto>> => {
|
||||
try {
|
||||
const response = await adminApiClient.get('/api/v2/admin/tests', {
|
||||
params: {
|
||||
page: params?.page || 1,
|
||||
limit: params?.limit || 20,
|
||||
search: params?.search,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }>
|
||||
throw createTestsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
'Failed to load tests',
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.field,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Get a specific test by ID
|
||||
getTest: async (testId: string): Promise<TestDto> => {
|
||||
try {
|
||||
const response = await adminApiClient.get(`/api/v2/admin/tests/${testId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
throw createTestsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to load test ${testId}`,
|
||||
axiosError.response?.status,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Create or update a test
|
||||
upsertTest: async (test: TestDto): Promise<{ success: boolean; test: TestDto }> => {
|
||||
try {
|
||||
const response = await adminApiClient.post('/api/v2/admin/tests', test)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }>
|
||||
const isUpdate = test.id && test.id.length > 0
|
||||
const operation = isUpdate ? 'update test' : 'create test'
|
||||
const message = axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to ${operation}`
|
||||
|
||||
let fullMessage = message
|
||||
if (axiosError.response?.data?.details) {
|
||||
fullMessage += `. ${axiosError.response.data.details}`
|
||||
}
|
||||
if (axiosError.response?.data?.field) {
|
||||
fullMessage += ` (Field: ${axiosError.response.data.field})`
|
||||
}
|
||||
|
||||
throw createTestsApiError(
|
||||
fullMessage,
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.field,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a test by ID
|
||||
deleteTest: async (testId: string): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const response = await adminApiClient.delete(`/api/v2/admin/tests/${testId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
throw createTestsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to delete test ${testId}`,
|
||||
axiosError.response?.status,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
44
admin/src/api/users.ts
Normal file
44
admin/src/api/users.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { adminApiClient } from './client'
|
||||
import type { UserDto, PaginatedResponse, PaymentDto } from '@/types/models'
|
||||
|
||||
export const usersApi = {
|
||||
// Get all users with pagination
|
||||
getUsers: async (params?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
ids?: string[]
|
||||
}): Promise<PaginatedResponse<UserDto>> => {
|
||||
const response = await adminApiClient.get('/api/v2/admin/users', {
|
||||
params: {
|
||||
page: params?.page || 1,
|
||||
limit: params?.limit || 20,
|
||||
ids: params?.ids?.join(','),
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get user IDs list
|
||||
getUserIds: async (): Promise<string[]> => {
|
||||
const response = await adminApiClient.get('/api/v2/admin/users/ids')
|
||||
return response.data.ids
|
||||
},
|
||||
|
||||
// Get user purchases
|
||||
getUserPurchases: async (userId: string): Promise<PaymentDto[]> => {
|
||||
const response = await adminApiClient.get(`/api/v2/admin/users/${userId}/purchases`)
|
||||
return response.data.payments
|
||||
},
|
||||
|
||||
// Create or update user
|
||||
upsertUser: async (user: UserDto): Promise<{ result: boolean }> => {
|
||||
const response = await adminApiClient.post('/api/v2/admin/users', user)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Delete user
|
||||
deleteUser: async (userId: string): Promise<{ result: boolean }> => {
|
||||
const response = await adminApiClient.delete(`/api/v2/admin/users/${userId}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
45
admin/src/api/voices.ts
Normal file
45
admin/src/api/voices.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { adminApiClient } from './client'
|
||||
|
||||
export interface VoiceDto {
|
||||
id: string
|
||||
cardId: string
|
||||
voiceUrl: string
|
||||
language: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface VoiceResponse {
|
||||
items: VoiceDto[]
|
||||
}
|
||||
|
||||
export const voicesApi = {
|
||||
// Get all voices for a card
|
||||
getCardVoices: async (cardId: string): Promise<VoiceResponse> => {
|
||||
const response = await adminApiClient.get(`/api/v2/admin/cards/${cardId}/voices`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Add a voice to a card
|
||||
addCardVoice: async (
|
||||
cardId: string,
|
||||
voiceUrl: string,
|
||||
language: string = 'en'
|
||||
): Promise<{ success: boolean; voice: VoiceDto }> => {
|
||||
const response = await adminApiClient.post(`/api/v2/admin/cards/${cardId}/voices`, {
|
||||
voiceUrl,
|
||||
language,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Remove a voice from a card
|
||||
removeCardVoice: async (
|
||||
cardId: string,
|
||||
voiceId: string
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
const response = await adminApiClient.delete(
|
||||
`/api/v2/admin/cards/${cardId}/voices/${voiceId}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
1
admin/src/assets/react.svg
Normal file
1
admin/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
95
admin/src/components/BulkCardEditor.test.tsx
Normal file
95
admin/src/components/BulkCardEditor.test.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import { BulkCardEditor } from '@/components/BulkCardEditor'
|
||||
|
||||
vi.mock('@/api/packs', () => {
|
||||
return {
|
||||
packsApi: {
|
||||
getPacks: vi.fn(),
|
||||
},
|
||||
isPacksApiError: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
import { packsApi } from '@/api/packs'
|
||||
|
||||
function renderWithQuery(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('BulkCardEditor', () => {
|
||||
it('loads packs and shows their titles in the pack select', async () => {
|
||||
vi.mocked(packsApi.getPacks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'pack-1',
|
||||
title: 'Pack One',
|
||||
cards: 10,
|
||||
enabled: true,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: 'pack-empty-title',
|
||||
title: ' ',
|
||||
cards: 0,
|
||||
enabled: true,
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 100,
|
||||
totalPages: 1,
|
||||
})
|
||||
|
||||
const images = [
|
||||
{
|
||||
id: 'img-1',
|
||||
file: new File(['x'], 'img-1.png', { type: 'image/png' }),
|
||||
preview: 'data:image/png;base64,AA==',
|
||||
objectId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
},
|
||||
]
|
||||
|
||||
renderWithQuery(
|
||||
<BulkCardEditor
|
||||
images={images}
|
||||
onComplete={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(packsApi.getPacks).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 100,
|
||||
search: '',
|
||||
})
|
||||
})
|
||||
|
||||
const placeholder = screen.getByText('Select a pack (optional)')
|
||||
const trigger = placeholder.closest('button')
|
||||
expect(trigger).toBeTruthy()
|
||||
|
||||
trigger!.focus()
|
||||
fireEvent.keyDown(trigger!, { key: 'ArrowDown' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('None')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pack One')).toBeInTheDocument()
|
||||
expect(screen.getByText('pack-empty-title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
412
admin/src/components/BulkCardEditor.tsx
Normal file
412
admin/src/components/BulkCardEditor.tsx
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { cardsApi, isCardsApiError } from '@/api/cards'
|
||||
import { packsApi, isPacksApiError } from '@/api/packs'
|
||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||
import type { GameCardDto } from '@/types/models'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
import { Label } from './ui/label'
|
||||
import { Textarea } from './ui/textarea'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'
|
||||
import { Badge } from './ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
|
||||
import { ChevronLeft, ChevronRight, Save, X } from 'lucide-react'
|
||||
|
||||
interface UploadedImage {
|
||||
id: string
|
||||
file: File
|
||||
preview: string
|
||||
objectId: string // Object ID in MinIO (UUID)
|
||||
presignedUrl?: string // Presigned URL for preview
|
||||
}
|
||||
|
||||
interface CardData {
|
||||
imageId: string
|
||||
packId: string
|
||||
original: string
|
||||
translation: string
|
||||
mnemo: string
|
||||
transcription: string
|
||||
transcriptionMnemo: string
|
||||
back: string
|
||||
imageBack?: string
|
||||
isSaved: boolean
|
||||
cardId?: string
|
||||
}
|
||||
|
||||
interface BulkCardEditorProps {
|
||||
images: UploadedImage[]
|
||||
defaultPackId?: string
|
||||
onComplete: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function BulkCardEditor({ images, defaultPackId, onComplete, onCancel }: BulkCardEditorProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [cardsData, setCardsData] = useState<Map<string, CardData>>(() => {
|
||||
const initialData = new Map<string, CardData>()
|
||||
if (images.length > 0) {
|
||||
images.forEach((image) => {
|
||||
initialData.set(image.id, {
|
||||
imageId: image.id,
|
||||
packId: defaultPackId || '',
|
||||
original: '',
|
||||
translation: '',
|
||||
mnemo: '',
|
||||
transcription: '',
|
||||
transcriptionMnemo: '',
|
||||
back: '',
|
||||
imageBack: undefined,
|
||||
isSaved: false,
|
||||
})
|
||||
})
|
||||
}
|
||||
return initialData
|
||||
})
|
||||
const [packsData, setPacksData] = useState<{ id: string; title: string }[]>([])
|
||||
|
||||
// Load packs
|
||||
useEffect(() => {
|
||||
packsApi
|
||||
.getPacks({ page: 1, limit: 100, search: '' })
|
||||
.then((response) => {
|
||||
try {
|
||||
if (response && response.items && Array.isArray(response.items)) {
|
||||
const packs = response.items.map((pack) => {
|
||||
const id = String(pack.id)
|
||||
const title = pack.title?.trim() ? String(pack.title) : id
|
||||
return { id, title }
|
||||
})
|
||||
setPacksData(packs)
|
||||
} else {
|
||||
console.warn('Unexpected packs response format:', response)
|
||||
setPacksData([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing packs response:', error)
|
||||
setPacksData([])
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: formatApiError(error)
|
||||
console.error('Failed to load packs:', error)
|
||||
toast.error(`Failed to load packs: ${errorMessage}`)
|
||||
// Initialize with empty array so component doesn't crash
|
||||
setPacksData([])
|
||||
})
|
||||
}, [])
|
||||
|
||||
const currentImage = images[currentIndex]
|
||||
const currentCard = currentImage ? cardsData.get(currentImage.id) : undefined
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||
if (currentImage) {
|
||||
const imageId = currentImage.id
|
||||
setCardsData((prev) => {
|
||||
const newData = new Map(prev)
|
||||
const cardData = newData.get(imageId)
|
||||
if (cardData && response.card) {
|
||||
newData.set(imageId, {
|
||||
...cardData,
|
||||
isSaved: true,
|
||||
cardId: response.card.id || undefined,
|
||||
})
|
||||
}
|
||||
return newData
|
||||
})
|
||||
}
|
||||
toast.success('Card saved successfully')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'save', 'card')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error saving card:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const handleFieldChange = (field: keyof CardData, value: string) => {
|
||||
if (!currentCard || !currentImage) return
|
||||
|
||||
setCardsData((prev) => {
|
||||
const newData = new Map(prev)
|
||||
const cardData = newData.get(currentImage.id)
|
||||
if (cardData) {
|
||||
newData.set(currentImage.id, {
|
||||
...cardData,
|
||||
[field]: value,
|
||||
})
|
||||
}
|
||||
return newData
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!currentCard) return
|
||||
|
||||
if (!currentCard.original.trim() || !currentCard.translation.trim() || !currentCard.mnemo.trim()) {
|
||||
toast.error('Original, translation and mnemo are required')
|
||||
return
|
||||
}
|
||||
|
||||
const image = images.find((img) => img.id === currentCard.imageId)
|
||||
if (!image) return
|
||||
|
||||
const cardData: GameCardDto = {
|
||||
id: currentCard.cardId || null,
|
||||
packId: currentCard.packId || undefined,
|
||||
original: currentCard.original.trim(),
|
||||
translation: currentCard.translation.trim(),
|
||||
mnemo: currentCard.mnemo.trim(),
|
||||
transcription: currentCard.transcription.trim() || undefined,
|
||||
transcriptionMnemo: currentCard.transcriptionMnemo.trim() || undefined,
|
||||
back: currentCard.back.trim() || undefined,
|
||||
image: image.objectId, // Use objectId instead of base64
|
||||
imageBack: currentCard.imageBack || undefined,
|
||||
}
|
||||
|
||||
updateMutation.mutate(cardData)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentIndex < images.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const savedCount = Array.from(cardsData.values()).filter((card) => card.isSaved).length
|
||||
const totalCount = images.length
|
||||
|
||||
// Wait for images and cards data to be initialized
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p className="text-muted-foreground">No images uploaded</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!currentImage || !currentCard) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p className="text-muted-foreground">Initializing card data...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Fill Card Details</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Card {currentIndex + 1} of {totalCount} • {savedCount} saved
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={currentCard.isSaved ? 'default' : 'secondary'}>
|
||||
{currentCard.isSaved ? 'Saved' : 'Not Saved'}
|
||||
</Badge>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Image Preview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Image Preview</CardTitle>
|
||||
<CardDescription>{currentImage.file?.name || 'Unknown'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative aspect-square border rounded-lg overflow-hidden bg-muted">
|
||||
{currentImage.presignedUrl || currentImage.preview ? (
|
||||
<img
|
||||
src={currentImage.presignedUrl || currentImage.preview}
|
||||
alt={currentImage.file?.name || 'Card image'}
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
console.error('Failed to load image:', currentImage.presignedUrl || currentImage.preview)
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
Image preview not available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Card Information</CardTitle>
|
||||
<CardDescription>Fill in the details for this card</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="packId">Pack ID (optional)</Label>
|
||||
<Select
|
||||
value={currentCard.packId || undefined}
|
||||
onValueChange={(value: string) => {
|
||||
// If "none" is selected, clear the packId
|
||||
const packId = value === '__none__' ? '' : value
|
||||
handleFieldChange('packId', packId)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a pack (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{packsData && packsData.length > 0 ? (
|
||||
packsData.map((pack) => (
|
||||
<SelectItem key={pack.id} value={pack.id}>
|
||||
{pack.title || pack.id}
|
||||
</SelectItem>
|
||||
))
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="original">Original *</Label>
|
||||
<Input
|
||||
id="original"
|
||||
value={currentCard.original}
|
||||
onChange={(e) => handleFieldChange('original', e.target.value)}
|
||||
placeholder="e.g. cerdo"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="translation">Translation *</Label>
|
||||
<Input
|
||||
id="translation"
|
||||
value={currentCard.translation}
|
||||
onChange={(e) => handleFieldChange('translation', e.target.value)}
|
||||
placeholder="e.g. pig"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mnemo">Mnemo *</Label>
|
||||
<Input
|
||||
id="mnemo"
|
||||
value={currentCard.mnemo}
|
||||
onChange={(e) => handleFieldChange('mnemo', e.target.value)}
|
||||
placeholder="e.g. [pig] with heart"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="transcription">Transcription</Label>
|
||||
<Input
|
||||
id="transcription"
|
||||
value={currentCard.transcription}
|
||||
onChange={(e) => handleFieldChange('transcription', e.target.value)}
|
||||
placeholder="e.g. sɛrdo"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="transcriptionMnemo">Transcription Mnemo</Label>
|
||||
<Input
|
||||
id="transcriptionMnemo"
|
||||
value={currentCard.transcriptionMnemo}
|
||||
onChange={(e) => handleFieldChange('transcriptionMnemo', e.target.value)}
|
||||
placeholder="e.g. pig with heart"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="back">Back Side</Label>
|
||||
<Textarea
|
||||
id="back"
|
||||
value={currentCard.back}
|
||||
onChange={(e) => handleFieldChange('back', e.target.value)}
|
||||
placeholder="Additional information on the back of the card"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
disabled={currentIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{updateMutation.isPending ? 'Saving...' : currentCard.isSaved ? 'Update' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleNext}
|
||||
disabled={currentIndex === images.length - 1}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Progress</span>
|
||||
<span>{savedCount} / {totalCount} saved</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${(savedCount / totalCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{savedCount === totalCount && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button onClick={onComplete}>
|
||||
Complete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
372
admin/src/components/BulkCardUpload.tsx
Normal file
372
admin/src/components/BulkCardUpload.tsx
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import { useState, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button } from './ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'
|
||||
import { Label } from './ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
|
||||
import { Upload, X, Check, Loader2 } from 'lucide-react'
|
||||
import { packsApi } from '@/api/packs'
|
||||
import { mediaApi } from '@/api/media'
|
||||
|
||||
interface UploadedImage {
|
||||
id: string
|
||||
file: File
|
||||
preview: string
|
||||
objectId: string // Object ID in MinIO (UUID)
|
||||
presignedUrl?: string // Presigned URL for preview
|
||||
isUploading?: boolean
|
||||
uploadError?: string
|
||||
}
|
||||
|
||||
interface BulkCardUploadProps {
|
||||
onImagesUploaded: (images: UploadedImage[], packId?: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function BulkCardUpload({ onImagesUploaded, onClose }: BulkCardUploadProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [selectedPackId, setSelectedPackId] = useState<string>('')
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
// Fetch packs for pack selection
|
||||
const { data: packsData } = useQuery({
|
||||
queryKey: ['packs', 1, 100, ''],
|
||||
queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '' }),
|
||||
})
|
||||
|
||||
const handleFileSelect = async (files: FileList) => {
|
||||
const filesArray = Array.from(files)
|
||||
|
||||
// Filter valid files
|
||||
const validFiles = filesArray.filter((file) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return false
|
||||
}
|
||||
const fileSizeMB = file.size / (1024 * 1024)
|
||||
if (fileSizeMB > 10) { // Updated to match backend limit
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
alert('No valid image files selected. Please select PNG, JPG, WEBP, or GIF files up to 10MB each.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsUploading(true)
|
||||
|
||||
// Create placeholder entries with loading state
|
||||
const placeholders: UploadedImage[] = validFiles.map((file, i) => ({
|
||||
id: `${Date.now()}-${i}`,
|
||||
file,
|
||||
preview: URL.createObjectURL(file), // Temporary preview
|
||||
objectId: '', // Will be set after upload
|
||||
isUploading: true,
|
||||
}))
|
||||
|
||||
setUploadedImages((prev) => [...prev, ...placeholders])
|
||||
|
||||
// Upload files in parallel (limit to 5 concurrent uploads)
|
||||
const uploadPromises = validFiles.map(async (file, index) => {
|
||||
const placeholderId = placeholders[index].id
|
||||
|
||||
try {
|
||||
const response = await mediaApi.uploadCardImage(file)
|
||||
|
||||
// Update the placeholder with objectId and presigned URL
|
||||
setUploadedImages((prev) =>
|
||||
prev.map((img) =>
|
||||
img.id === placeholderId
|
||||
? {
|
||||
...img,
|
||||
objectId: response.objectId,
|
||||
presignedUrl: response.url,
|
||||
isUploading: false,
|
||||
}
|
||||
: img
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', file.name, error)
|
||||
|
||||
// Mark as error
|
||||
setUploadedImages((prev) =>
|
||||
prev.map((img) =>
|
||||
img.id === placeholderId
|
||||
? {
|
||||
...img,
|
||||
isUploading: false,
|
||||
uploadError: 'Upload failed',
|
||||
}
|
||||
: img
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Process in batches of 5
|
||||
const batchSize = 5
|
||||
for (let i = 0; i < uploadPromises.length; i += batchSize) {
|
||||
const batch = uploadPromises.slice(i, i + batchSize)
|
||||
await Promise.all(batch)
|
||||
}
|
||||
|
||||
setIsUploading(false)
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) {
|
||||
handleFileSelect(files)
|
||||
}
|
||||
// Reset input value to allow selecting the same files again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
const files = e.dataTransfer.files
|
||||
if (files && files.length > 0) {
|
||||
handleFileSelect(files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
setUploadedImages((prev) => {
|
||||
const image = prev.find((img) => img.id === id)
|
||||
if (image) {
|
||||
URL.revokeObjectURL(image.preview)
|
||||
}
|
||||
return prev.filter((img) => img.id !== id)
|
||||
})
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
uploadedImages.forEach((img) => URL.revokeObjectURL(img.preview))
|
||||
setUploadedImages([])
|
||||
}
|
||||
|
||||
const handleContinue = () => {
|
||||
// Filter out images that failed to upload or are still uploading
|
||||
const validImages = uploadedImages.filter(
|
||||
(img) => img.objectId && !img.isUploading && !img.uploadError
|
||||
)
|
||||
|
||||
if (validImages.length === 0) {
|
||||
alert('Please wait for all images to finish uploading, or remove failed uploads.')
|
||||
return
|
||||
}
|
||||
|
||||
if (validImages.length < uploadedImages.length) {
|
||||
const failedCount = uploadedImages.length - validImages.length
|
||||
if (
|
||||
!confirm(
|
||||
`${failedCount} image(s) failed to upload or are still uploading. Continue with ${validImages.length} successfully uploaded images?`
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
onImagesUploaded(validImages, selectedPackId || undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Bulk Card Upload</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload multiple images, then fill in card details one by one
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Upload Images</CardTitle>
|
||||
<CardDescription>
|
||||
Select multiple image files or drag and drop them here
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="packId">Pack (optional)</Label>
|
||||
<Select
|
||||
value={selectedPackId || undefined}
|
||||
onValueChange={(value: string) => {
|
||||
const packId = value === '__none__' ? '' : value
|
||||
setSelectedPackId(packId)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a pack (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{packsData?.items && packsData.items.length > 0 ? (
|
||||
packsData.items.map((pack) => (
|
||||
<SelectItem key={pack.id} value={pack.id}>
|
||||
{pack.title || pack.id}
|
||||
</SelectItem>
|
||||
))
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected pack will be applied to all uploaded cards
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-12 w-12 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Upload className="h-12 w-12 text-muted-foreground" />
|
||||
)}
|
||||
<div className="text-lg">
|
||||
{isUploading ? (
|
||||
<span className="text-muted-foreground">Uploading images...</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-primary font-medium">Click to upload</span> or drag and drop
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
PNG, JPG, WEBP, GIF up to 10MB each. Multiple files supported.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{uploadedImages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Uploaded Images ({uploadedImages.length})</CardTitle>
|
||||
<CardDescription>
|
||||
Review uploaded images and continue to fill in card details
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleClearAll}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 max-h-[500px] overflow-y-auto">
|
||||
{uploadedImages.map((image) => (
|
||||
<div key={image.id} className="relative group">
|
||||
<div className="relative aspect-square border rounded-lg overflow-hidden bg-muted">
|
||||
{image.isUploading ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : image.uploadError ? (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center p-2 text-center">
|
||||
<X className="h-6 w-6 text-destructive mb-1" />
|
||||
<p className="text-xs text-destructive">Upload failed</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={image.presignedUrl || image.preview}
|
||||
alt={image.file.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => {
|
||||
// Fallback to object URL if presigned URL fails
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove(image.id)
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 truncate" title={image.file.name}>
|
||||
{image.file.name}
|
||||
{image.isUploading && ' (uploading...)'}
|
||||
{image.uploadError && ' (failed)'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleContinue} disabled={isUploading}>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Continue to Fill Details (
|
||||
{uploadedImages.filter((img) => img.objectId && !img.isUploading && !img.uploadError).length}{' '}
|
||||
cards)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
358
admin/src/components/CardEditorPreview.tsx
Normal file
358
admin/src/components/CardEditorPreview.tsx
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ImageUpload } from '@/components/ui/image-upload'
|
||||
import { AudioUpload } from '@/components/ui/audio-upload'
|
||||
import { CardVoicesManager } from '@/components/CardVoicesManager'
|
||||
|
||||
interface CardEditorPreviewProps {
|
||||
formData: {
|
||||
packId: string
|
||||
original: string
|
||||
translation: string
|
||||
mnemo: string
|
||||
transcription: string
|
||||
transcriptionMnemo: string
|
||||
back: string
|
||||
image: string | undefined
|
||||
imageBack: string | undefined
|
||||
voice: string | undefined
|
||||
voiceLanguage: string
|
||||
}
|
||||
onFormDataChange: (updates: Partial<CardEditorPreviewProps['formData']>) => void
|
||||
cardId?: string
|
||||
disabled?: boolean
|
||||
packColor?: string
|
||||
}
|
||||
|
||||
// Helper to get image source
|
||||
// Note: For UUID (objectId), returns undefined - ImageUpload component will handle fetching presigned URL
|
||||
function getImageSrc(image?: string): string | undefined {
|
||||
if (!image) return undefined
|
||||
|
||||
// If it's already a data URL or http/https URL, return as is
|
||||
if (image.startsWith('data:') || image.startsWith('http://') || image.startsWith('https://')) {
|
||||
return image
|
||||
}
|
||||
|
||||
// If it's a relative API path, prepend the base URL
|
||||
if (image.startsWith('/api/')) {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'https://api.mnemo-cards.online'
|
||||
return `${baseUrl}${image}`
|
||||
}
|
||||
|
||||
// If it's a UUID (objectId), return undefined - ImageUpload will fetch presigned URL
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(image)
|
||||
if (isUuid) {
|
||||
return undefined // Will be handled by ImageUpload component's useEffect
|
||||
}
|
||||
|
||||
// Otherwise, assume it's base64 and add the data URL prefix
|
||||
return `data:image/png;base64,${image}`
|
||||
}
|
||||
|
||||
export function CardEditorPreview({
|
||||
formData,
|
||||
onFormDataChange,
|
||||
cardId,
|
||||
disabled = false,
|
||||
packColor = '#6b7280', // Default gray border color
|
||||
}: CardEditorPreviewProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{/* Cards Preview - Front and Back side by side */}
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Front side */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-center text-muted-foreground">Front Side</div>
|
||||
<div
|
||||
className="rounded-3xl shadow-lg"
|
||||
style={{
|
||||
borderColor: packColor,
|
||||
backgroundColor: 'white',
|
||||
borderWidth: '3px',
|
||||
borderStyle: 'solid',
|
||||
aspectRatio: '0.66',
|
||||
minHeight: '400px',
|
||||
maxHeight: '600px',
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full rounded-3xl overflow-hidden flex flex-col">
|
||||
{/* Top section: Original, Translation */}
|
||||
<div className="w-full px-6 pt-6 pb-4 flex-shrink-0">
|
||||
{/* Original - large, bold */}
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
value={formData.original}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ original: e.target.value })
|
||||
}
|
||||
placeholder="Original text"
|
||||
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Translation - smaller, gray */}
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
value={formData.translation}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ translation: e.target.value })
|
||||
}
|
||||
placeholder="Translation"
|
||||
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
fontSize: '22px',
|
||||
fontWeight: 400,
|
||||
color: formData.translation ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image in the middle */}
|
||||
<div className="flex-1 bg-gray-50 flex items-center justify-center relative min-h-0">
|
||||
{getImageSrc(formData.image) ? (
|
||||
<img
|
||||
src={getImageSrc(formData.image)}
|
||||
alt="Card"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-400">
|
||||
<svg
|
||||
className="w-24 h-24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mnemo phrase at the bottom */}
|
||||
<div className="w-full px-6 py-6 bg-white flex-shrink-0">
|
||||
<div className="text-center min-h-[60px] flex items-center justify-center">
|
||||
<Input
|
||||
value={formData.mnemo}
|
||||
onChange={(e) => onFormDataChange({ mnemo: e.target.value })}
|
||||
placeholder="Mnemo phrase"
|
||||
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent w-full"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back side */}
|
||||
<div
|
||||
className="rounded-3xl shadow-lg"
|
||||
style={{
|
||||
borderColor: packColor,
|
||||
backgroundColor: 'white',
|
||||
borderWidth: '3px',
|
||||
borderStyle: 'solid',
|
||||
aspectRatio: '0.66',
|
||||
minHeight: '400px',
|
||||
maxHeight: '600px',
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full rounded-3xl overflow-hidden flex flex-col">
|
||||
{formData.imageBack ? (
|
||||
<>
|
||||
<div className="flex-1 flex items-center justify-center bg-gray-50 min-h-0">
|
||||
<img
|
||||
src={getImageSrc(formData.imageBack)}
|
||||
alt="Back"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6 bg-white flex-shrink-0">
|
||||
<Textarea
|
||||
value={formData.back || formData.original}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ back: e.target.value })
|
||||
}
|
||||
placeholder="Back side text"
|
||||
className="text-center resize-none border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 min-h-[80px] w-full bg-transparent"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 700,
|
||||
lineHeight: '1.2',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<Textarea
|
||||
value={formData.back || formData.original}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ back: e.target.value })
|
||||
}
|
||||
placeholder="Back side text"
|
||||
className="text-center resize-none border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 min-h-[80px] w-full bg-transparent"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 700,
|
||||
lineHeight: '1.2',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit fields below the preview */}
|
||||
<div className="w-full max-w-5xl space-y-4 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Original *</label>
|
||||
<Input
|
||||
value={formData.original}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ original: e.target.value })
|
||||
}
|
||||
placeholder="e.g. cerdo"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Translation *</label>
|
||||
<Input
|
||||
value={formData.translation}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ translation: e.target.value })
|
||||
}
|
||||
placeholder="e.g. pig"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Mnemo *</label>
|
||||
<Input
|
||||
value={formData.mnemo}
|
||||
onChange={(e) => onFormDataChange({ mnemo: e.target.value })}
|
||||
placeholder="e.g. [pig] with heart"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Back Side</label>
|
||||
<Textarea
|
||||
value={formData.back}
|
||||
onChange={(e) => onFormDataChange({ back: e.target.value })}
|
||||
placeholder="Additional information on the back of the card"
|
||||
rows={3}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Front Image"
|
||||
value={formData.image}
|
||||
onChange={(value) => onFormDataChange({ image: value })}
|
||||
disabled={disabled}
|
||||
uploadType="card-image"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Back Image"
|
||||
value={formData.imageBack}
|
||||
onChange={(value) => onFormDataChange({ imageBack: value })}
|
||||
disabled={disabled}
|
||||
uploadType="card-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Transcription</label>
|
||||
<Input
|
||||
value={formData.transcription}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ transcription: e.target.value })
|
||||
}
|
||||
placeholder="e.g. sɛrdo"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Transcription Mnemo</label>
|
||||
<Input
|
||||
value={formData.transcriptionMnemo}
|
||||
onChange={(e) =>
|
||||
onFormDataChange({ transcriptionMnemo: e.target.value })
|
||||
}
|
||||
placeholder="e.g. pig with heart"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<AudioUpload
|
||||
label="Voice (will be added on Save)"
|
||||
value={formData.voice}
|
||||
onChange={(voice) => onFormDataChange({ voice })}
|
||||
language={formData.voiceLanguage}
|
||||
onLanguageChange={(voiceLanguage) =>
|
||||
onFormDataChange({ voiceLanguage })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выберите аудиофайл — он будет загружен и привязан к карточке при
|
||||
нажатии Create/Update.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Voice Controls - only show if cardId exists */}
|
||||
{cardId && (
|
||||
<div className="space-y-2">
|
||||
<CardVoicesManager cardId={cardId} disabled={disabled} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
311
admin/src/components/CardVoicesManager.tsx
Normal file
311
admin/src/components/CardVoicesManager.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { voicesApi, type VoiceDto } from '@/api/voices'
|
||||
import { AudioUpload } from '@/components/ui/audio-upload'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { X, Plus, Music, Play, Pause } from 'lucide-react'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
interface CardVoicesManagerProps {
|
||||
cardId: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManagerProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newAudio, setNewAudio] = useState<string | undefined>(undefined)
|
||||
const [newLanguage, setNewLanguage] = useState('en')
|
||||
|
||||
// Load voices for the card
|
||||
const { data: voicesData, isLoading, refetch } = useQuery({
|
||||
queryKey: ['cardVoices', cardId],
|
||||
queryFn: async () => {
|
||||
console.log('🔍 CardVoicesManager: Fetching voices for card', cardId)
|
||||
const result = await voicesApi.getCardVoices(cardId)
|
||||
console.log('✅ CardVoicesManager: Fetched voices', result)
|
||||
return result
|
||||
},
|
||||
// Voices should still load even if parent form is disabled (e.g. during save)
|
||||
enabled: !!cardId,
|
||||
})
|
||||
|
||||
// Add voice mutation
|
||||
const addVoiceMutation = useMutation({
|
||||
mutationFn: ({ voiceUrl, language }: { voiceUrl: string; language: string }) => {
|
||||
console.log('🔍 CardVoicesManager: addVoiceMutation.mutationFn called', {
|
||||
cardId,
|
||||
voiceUrl,
|
||||
language,
|
||||
})
|
||||
return voicesApi.addCardVoice(cardId, voiceUrl, language)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
console.log('✅ CardVoicesManager: addVoiceMutation.onSuccess')
|
||||
|
||||
// Invalidate and refetch to ensure we get the latest data
|
||||
await queryClient.invalidateQueries({ queryKey: ['cardVoices', cardId] })
|
||||
|
||||
// Force refetch to ensure the list is updated
|
||||
const refetchResult = await refetch()
|
||||
console.log('✅ CardVoicesManager: Refetched voices after add', refetchResult.data)
|
||||
|
||||
toast.success('Voice added successfully')
|
||||
|
||||
// Auto-close form and reset after successful addition
|
||||
setShowAddForm(false)
|
||||
setNewAudio(undefined)
|
||||
setNewLanguage('en')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('❌ CardVoicesManager: addVoiceMutation.onError', error)
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
toast.error(axiosError.response?.data?.message || 'Failed to add voice')
|
||||
},
|
||||
})
|
||||
|
||||
// Remove voice mutation
|
||||
const removeVoiceMutation = useMutation({
|
||||
mutationFn: (voiceId: string) => voicesApi.removeCardVoice(cardId, voiceId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cardVoices', cardId] })
|
||||
toast.success('Voice removed successfully')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
toast.error(axiosError.response?.data?.message || 'Failed to remove voice')
|
||||
},
|
||||
})
|
||||
|
||||
// handleAddVoice is no longer needed - voice is added automatically after upload
|
||||
|
||||
const handleRemoveVoice = (voiceId: string) => {
|
||||
if (confirm('Are you sure you want to remove this voice?')) {
|
||||
removeVoiceMutation.mutate(voiceId)
|
||||
}
|
||||
}
|
||||
|
||||
const voices = voicesData?.items || []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Card Voices ({voices.length})</Label>
|
||||
{!showAddForm && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Voice
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add voice form */}
|
||||
{showAddForm && (
|
||||
<div className="p-4 border rounded-lg bg-muted/50">
|
||||
<div className="space-y-4">
|
||||
<AudioUpload
|
||||
label="Audio File"
|
||||
value={newAudio}
|
||||
onChange={async (value) => {
|
||||
console.log('🔍 CardVoicesManager: AudioUpload onChange', { value, cardId })
|
||||
setNewAudio(value)
|
||||
|
||||
// Automatically add voice after upload
|
||||
if (value && cardId) {
|
||||
console.log('✅ CardVoicesManager: Auto-adding voice after upload', {
|
||||
cardId,
|
||||
voiceUrl: value,
|
||||
language: newLanguage,
|
||||
})
|
||||
|
||||
addVoiceMutation.mutate({
|
||||
voiceUrl: value,
|
||||
language: newLanguage,
|
||||
})
|
||||
}
|
||||
}}
|
||||
language={newLanguage}
|
||||
onLanguageChange={setNewLanguage}
|
||||
disabled={disabled || addVoiceMutation.isPending}
|
||||
/>
|
||||
{addVoiceMutation.isPending && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Adding voice...
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAddForm(false)
|
||||
setNewAudio(undefined)
|
||||
setNewLanguage('en')
|
||||
}}
|
||||
disabled={disabled || addVoiceMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voices list */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">Loading voices...</div>
|
||||
) : voices.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
No voices added yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{voices.map((voice) => (
|
||||
<VoiceItem
|
||||
key={voice.id}
|
||||
voice={voice}
|
||||
onRemove={() => handleRemoveVoice(voice.id)}
|
||||
disabled={disabled || removeVoiceMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface VoiceItemProps {
|
||||
voice: VoiceDto
|
||||
onRemove: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function VoiceItem({ voice, onRemove, disabled }: VoiceItemProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (!audioRef.current) {
|
||||
try {
|
||||
const audio = new Audio(`data:audio/mpeg;base64,${voice.voiceUrl}`)
|
||||
audioRef.current = audio
|
||||
|
||||
audio.onended = () => {
|
||||
setIsPlaying(false)
|
||||
audioRef.current = null
|
||||
}
|
||||
|
||||
audio.onerror = (e) => {
|
||||
console.error('Audio playback error:', e)
|
||||
toast.error('Failed to play audio. The file may be corrupted or in an unsupported format.')
|
||||
setIsPlaying(false)
|
||||
audioRef.current = null
|
||||
}
|
||||
|
||||
audio.onloadstart = () => {
|
||||
setIsPlaying(true)
|
||||
}
|
||||
|
||||
audio.play().catch((error) => {
|
||||
console.error('Audio play error:', error)
|
||||
toast.error('Failed to play audio. Please check your browser audio settings.')
|
||||
setIsPlaying(false)
|
||||
audioRef.current = null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating audio element:', error)
|
||||
toast.error('Failed to initialize audio player')
|
||||
setIsPlaying(false)
|
||||
}
|
||||
} else {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
audioRef.current.play().catch((error) => {
|
||||
console.error('Audio play error:', error)
|
||||
toast.error('Failed to resume audio playback')
|
||||
})
|
||||
setIsPlaying(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-between p-3 border rounded-lg transition-colors ${
|
||||
isPlaying ? 'bg-primary/5 border-primary/20' : ''
|
||||
}`}>
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<Music className={`h-5 w-5 flex-shrink-0 ${
|
||||
isPlaying ? 'text-primary' : 'text-muted-foreground'
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-medium">Voice</span>
|
||||
<Badge variant="outline">{voice.language}</Badge>
|
||||
{isPlaying && (
|
||||
<Badge variant="secondary" className="animate-pulse">
|
||||
Playing
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Added {new Date(voice.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isPlaying ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={handlePlayPause}
|
||||
disabled={disabled}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Pause</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Play</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
disabled={disabled}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
182
admin/src/components/JSONQuestionEditor.tsx
Normal file
182
admin/src/components/JSONQuestionEditor.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import type { Question } from '@/types/questions'
|
||||
import { questionToJson, questionFromJson } from '@/types/questions'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
interface JSONQuestionEditorProps {
|
||||
question: Partial<Question> | null
|
||||
onChange: (question: Partial<Question> | null, isValid: boolean) => void
|
||||
}
|
||||
|
||||
export function JSONQuestionEditor({
|
||||
question,
|
||||
onChange,
|
||||
}: JSONQuestionEditorProps) {
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isValid, setIsValid] = useState(false)
|
||||
|
||||
// Инициализация JSON из вопроса
|
||||
useEffect(() => {
|
||||
if (question) {
|
||||
try {
|
||||
const json = questionToJson(question as Question)
|
||||
setJsonText(JSON.stringify(json, null, 2))
|
||||
setError(null)
|
||||
setIsValid(true)
|
||||
} catch (e) {
|
||||
setError(`Failed to serialize question: ${e}`)
|
||||
setIsValid(false)
|
||||
}
|
||||
} else {
|
||||
setJsonText('')
|
||||
setError(null)
|
||||
setIsValid(false)
|
||||
}
|
||||
}, [question])
|
||||
|
||||
// Валидация и парсинг JSON
|
||||
const handleJsonChange = (value: string) => {
|
||||
setJsonText(value)
|
||||
|
||||
if (!value.trim()) {
|
||||
setError(null)
|
||||
setIsValid(false)
|
||||
onChange(null, false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('JSON must be an object')
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>
|
||||
|
||||
// Базовая валидация структуры
|
||||
if (typeof obj.questionType !== 'string') {
|
||||
throw new Error('Missing required field: questionType')
|
||||
}
|
||||
const isMatrix = obj.questionType === 'matrix'
|
||||
|
||||
if (!isMatrix && !obj.word) {
|
||||
throw new Error('Missing required field: word')
|
||||
}
|
||||
if (!isMatrix && !obj.answer) {
|
||||
throw new Error('Missing required field: answer')
|
||||
}
|
||||
if (!isMatrix && (!obj.buttons || !Array.isArray(obj.buttons))) {
|
||||
throw new Error('Missing or invalid field: buttons (must be an array)')
|
||||
}
|
||||
if (isMatrix && obj.matrixSize == null) {
|
||||
throw new Error('Missing required field for matrix: matrixSize')
|
||||
}
|
||||
|
||||
// Проверка что answer существует в buttons
|
||||
if (!isMatrix) {
|
||||
const buttons = Array.isArray(obj.buttons) ? obj.buttons : []
|
||||
const buttonIds = buttons
|
||||
.map((b) => {
|
||||
const bObj: Record<string, unknown> =
|
||||
b && typeof b === 'object' ? (b as Record<string, unknown>) : {}
|
||||
return typeof bObj.id === 'string' ? bObj.id : undefined
|
||||
})
|
||||
.filter((id): id is string => typeof id === 'string')
|
||||
|
||||
if (typeof obj.answer === 'string' && !buttonIds.includes(obj.answer)) {
|
||||
throw new Error(
|
||||
`Answer "${obj.answer}" not found in buttons. Available IDs: ${buttonIds.join(', ')}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка для input_buttons
|
||||
if (obj.questionType === 'input_buttons' && !obj.template) {
|
||||
throw new Error('Missing required field for input_buttons: template')
|
||||
}
|
||||
|
||||
// Парсинг в Question объект
|
||||
const questionObj = questionFromJson(obj)
|
||||
setError(null)
|
||||
setIsValid(true)
|
||||
onChange(questionObj, true)
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Invalid JSON'
|
||||
setError(errorMessage)
|
||||
setIsValid(false)
|
||||
onChange(null, false)
|
||||
}
|
||||
}
|
||||
|
||||
// Форматирование JSON
|
||||
const formatJson = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText)
|
||||
setJsonText(JSON.stringify(parsed, null, 2))
|
||||
handleJsonChange(JSON.stringify(parsed, null, 2))
|
||||
} catch {
|
||||
// Если невалидный JSON, просто показываем ошибку
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>JSON Editor</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={formatJson}>
|
||||
Format JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
placeholder='{"questionType": "simple", "word": "...", "answer": "...", "buttons": [...]}'
|
||||
rows={20}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isValid && !error && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>JSON is valid</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-semibold mb-1">Required fields:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<code>questionType</code>: "simple" or "input_buttons"
|
||||
</li>
|
||||
<li>
|
||||
<code>questionType</code>: "matrix" (requires <code>matrixSize</code>)
|
||||
</li>
|
||||
<li>
|
||||
<code>word</code>: Word or phrase to learn
|
||||
</li>
|
||||
<li>
|
||||
<code>answer</code>: ID of the correct button
|
||||
</li>
|
||||
<li>
|
||||
<code>buttons</code>: Array of button objects with id, text/image
|
||||
</li>
|
||||
<li>
|
||||
<code>template</code>: Required for input_buttons type
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
209
admin/src/components/PackCardsManager.tsx
Normal file
209
admin/src/components/PackCardsManager.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { cardsApi } from '@/api/cards'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Search, Check } from 'lucide-react'
|
||||
|
||||
interface PackCardsManagerProps {
|
||||
currentCardIds: string[]
|
||||
onCardsChange: (addIds: string[], removeIds: string[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function PackCardsManager({
|
||||
currentCardIds,
|
||||
onCardsChange,
|
||||
disabled = false,
|
||||
}: PackCardsManagerProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
|
||||
const [removedCards, setRemovedCards] = useState<Set<string>>(new Set())
|
||||
|
||||
// Load all cards with search
|
||||
const { data: cardsData, isLoading } = useQuery({
|
||||
queryKey: ['cards', 1, 100, search], // Limit to 100 (backend max)
|
||||
queryFn: () => cardsApi.getCards({ page: 1, limit: 100, search }),
|
||||
enabled: !disabled,
|
||||
})
|
||||
|
||||
// Initialize selected cards from currentCardIds when it changes
|
||||
useEffect(() => {
|
||||
const initialSelected = new Set<string>(
|
||||
currentCardIds.filter((id) => !removedCards.has(id.toString()))
|
||||
)
|
||||
setSelectedCards(initialSelected)
|
||||
// Reset removed cards when currentCardIds changes (e.g., when opening dialog)
|
||||
setRemovedCards(new Set())
|
||||
}, [currentCardIds.join(',')]) // Use join to detect array changes
|
||||
|
||||
// Calculate which cards to add/remove when selection changes
|
||||
useEffect(() => {
|
||||
const currentlySelected = Array.from(selectedCards)
|
||||
const removed = Array.from(removedCards)
|
||||
|
||||
// Cards to add: selected but not in currentCardIds and not removed
|
||||
const toAdd = currentlySelected.filter(
|
||||
(id) => !currentCardIds.includes(id) && !removed.includes(id)
|
||||
)
|
||||
// Cards to remove: in removedCards
|
||||
const toRemove = removed.filter((id) => currentCardIds.includes(id))
|
||||
|
||||
if (toAdd.length > 0 || toRemove.length > 0) {
|
||||
onCardsChange(toAdd, toRemove)
|
||||
} else if (selectedCards.size > 0 || removedCards.size > 0) {
|
||||
// Also notify if selection was cleared
|
||||
onCardsChange([], [])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedCards.size, removedCards.size, currentCardIds.join(',')])
|
||||
|
||||
const handleToggleCard = (cardId: string | number) => {
|
||||
if (disabled) return
|
||||
|
||||
const cardIdStr = String(cardId)
|
||||
const isCurrentlySelected = selectedCards.has(cardIdStr) && !removedCards.has(cardIdStr)
|
||||
const isInCurrentPack = currentCardIds.includes(cardIdStr)
|
||||
|
||||
if (isCurrentlySelected) {
|
||||
// Deselect card
|
||||
const newSelected = new Set(selectedCards)
|
||||
newSelected.delete(cardIdStr)
|
||||
setSelectedCards(newSelected)
|
||||
|
||||
// If it was in current pack, mark as removed
|
||||
if (isInCurrentPack) {
|
||||
setRemovedCards((prev) => new Set([...prev, cardIdStr]))
|
||||
}
|
||||
} else {
|
||||
// Select card
|
||||
const newSelected = new Set(selectedCards)
|
||||
newSelected.add(cardIdStr)
|
||||
setSelectedCards(newSelected)
|
||||
|
||||
// If it was marked as removed, unmark it
|
||||
if (removedCards.has(cardIdStr)) {
|
||||
setRemovedCards((prev) => {
|
||||
const newRemoved = new Set(prev)
|
||||
newRemoved.delete(cardIdStr)
|
||||
return newRemoved
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isCardSelected = (cardId: string | number) => {
|
||||
const cardIdStr = String(cardId)
|
||||
return selectedCards.has(cardIdStr) && !removedCards.has(cardIdStr)
|
||||
}
|
||||
|
||||
const isCardInCurrentPack = (cardId: string | number) => {
|
||||
return currentCardIds.includes(String(cardId))
|
||||
}
|
||||
|
||||
const allCards = cardsData?.items || []
|
||||
const filteredCards = search
|
||||
? allCards.filter(
|
||||
(card) =>
|
||||
card.original?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
card.translation?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
card.mnemo?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: allCards
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Manage Cards in Pack</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search cards by original, translation, or mnemo..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selected cards: {selectedCards.size - removedCards.size} / {allCards.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4">Loading cards...</div>
|
||||
) : (
|
||||
<div className="border rounded-lg max-h-[400px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Original</TableHead>
|
||||
<TableHead>Translation</TableHead>
|
||||
<TableHead>Mnemo</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCards.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No cards found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredCards.map((card) => {
|
||||
const cardIdStr = card.id ? String(card.id) : `temp-${card.original}`
|
||||
const selected = isCardSelected(cardIdStr)
|
||||
const inCurrentPack = isCardInCurrentPack(cardIdStr)
|
||||
const newlyRemoved = removedCards.has(cardIdStr)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={card.id || `temp-${card.original}`}
|
||||
className={selected ? 'bg-muted/50' : ''}
|
||||
onClick={() => handleToggleCard(cardIdStr)}
|
||||
>
|
||||
<TableCell>
|
||||
{selected ? (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<div className="h-4 w-4 border rounded" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{card.id || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{card.original}</TableCell>
|
||||
<TableCell>{card.translation}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{card.mnemo}</TableCell>
|
||||
<TableCell>
|
||||
{newlyRemoved ? (
|
||||
<Badge variant="destructive">Removed</Badge>
|
||||
) : selected && inCurrentPack ? (
|
||||
<Badge variant="default">In Pack</Badge>
|
||||
) : selected ? (
|
||||
<Badge variant="secondary">Selected</Badge>
|
||||
) : inCurrentPack ? (
|
||||
<Badge variant="outline">In Pack</Badge>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
214
admin/src/components/PackTestsManager.tsx
Normal file
214
admin/src/components/PackTestsManager.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { testsApi } from '@/api/tests'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Search, Check } from 'lucide-react'
|
||||
|
||||
interface PackTestsManagerProps {
|
||||
currentTestIds: string[]
|
||||
onTestsChange: (addIds: string[], removeIds: string[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function PackTestsManager({
|
||||
currentTestIds,
|
||||
onTestsChange,
|
||||
disabled = false,
|
||||
}: PackTestsManagerProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedTests, setSelectedTests] = useState<Set<string>>(new Set())
|
||||
const [removedTests, setRemovedTests] = useState<Set<string>>(new Set())
|
||||
|
||||
// Load all tests with search
|
||||
const { data: testsData, isLoading } = useQuery({
|
||||
queryKey: ['tests', 1, 100, search], // Limit to 100 (backend max)
|
||||
queryFn: () => testsApi.getTests({ page: 1, limit: 100, search }),
|
||||
enabled: !disabled,
|
||||
})
|
||||
|
||||
// Initialize selected tests from currentTestIds when it changes
|
||||
useEffect(() => {
|
||||
const initialSelected = new Set<string>(
|
||||
currentTestIds.filter((id) => !removedTests.has(id.toString()))
|
||||
)
|
||||
setSelectedTests(initialSelected)
|
||||
// Reset removed tests when currentTestIds changes (e.g., when opening dialog)
|
||||
setRemovedTests(new Set())
|
||||
}, [currentTestIds.join(',')]) // Use join to detect array changes
|
||||
|
||||
// Calculate which tests to add/remove when selection changes
|
||||
useEffect(() => {
|
||||
const currentlySelected = Array.from(selectedTests)
|
||||
const removed = Array.from(removedTests)
|
||||
|
||||
// Tests to add: selected but not in currentTestIds and not removed
|
||||
const toAdd = currentlySelected.filter(
|
||||
(id) => !currentTestIds.includes(id) && !removed.includes(id)
|
||||
)
|
||||
// Tests to remove: in removedTests
|
||||
const toRemove = removed.filter((id) => currentTestIds.includes(id))
|
||||
|
||||
if (toAdd.length > 0 || toRemove.length > 0) {
|
||||
onTestsChange(toAdd, toRemove)
|
||||
} else if (selectedTests.size > 0 || removedTests.size > 0) {
|
||||
// Also notify if selection was cleared
|
||||
onTestsChange([], [])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTests.size, removedTests.size, currentTestIds.join(',')])
|
||||
|
||||
const handleToggleTest = (testId: string) => {
|
||||
if (disabled || !testId) return
|
||||
|
||||
const testIdStr = String(testId)
|
||||
const isCurrentlySelected = selectedTests.has(testIdStr) && !removedTests.has(testIdStr)
|
||||
const isInCurrentPack = currentTestIds.includes(testIdStr)
|
||||
|
||||
if (isCurrentlySelected) {
|
||||
// Deselect test
|
||||
const newSelected = new Set(selectedTests)
|
||||
newSelected.delete(testIdStr)
|
||||
setSelectedTests(newSelected)
|
||||
|
||||
// If it was in current pack, mark as removed
|
||||
if (isInCurrentPack) {
|
||||
setRemovedTests((prev) => new Set([...prev, testIdStr]))
|
||||
}
|
||||
} else {
|
||||
// Select test
|
||||
const newSelected = new Set(selectedTests)
|
||||
newSelected.add(testIdStr)
|
||||
setSelectedTests(newSelected)
|
||||
|
||||
// If it was marked as removed, unmark it
|
||||
if (removedTests.has(testIdStr)) {
|
||||
setRemovedTests((prev) => {
|
||||
const newRemoved = new Set(prev)
|
||||
newRemoved.delete(testIdStr)
|
||||
return newRemoved
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isTestSelected = (testId: string | number) => {
|
||||
const testIdStr = String(testId)
|
||||
return selectedTests.has(testIdStr) && !removedTests.has(testIdStr)
|
||||
}
|
||||
|
||||
const isTestInCurrentPack = (testId: string | number) => {
|
||||
return currentTestIds.includes(String(testId))
|
||||
}
|
||||
|
||||
const allTests = testsData?.items || []
|
||||
const filteredTests = search
|
||||
? allTests.filter(
|
||||
(test) =>
|
||||
test.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
test.id?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: allTests
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Manage Tests in Pack</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tests by name or ID..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selected tests: {selectedTests.size - removedTests.size} / {allTests.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4">Loading tests...</div>
|
||||
) : (
|
||||
<div className="border rounded-lg max-h-[400px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Questions</TableHead>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTests.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No tests found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredTests
|
||||
.filter((test) => test.id) // Only show tests with IDs
|
||||
.map((test) => {
|
||||
const testIdStr = String(test.id!)
|
||||
const selected = isTestSelected(testIdStr)
|
||||
const inCurrentPack = isTestInCurrentPack(testIdStr)
|
||||
const newlyRemoved = removedTests.has(testIdStr)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={test.id}
|
||||
className={selected ? 'bg-muted/50' : ''}
|
||||
onClick={() => handleToggleTest(testIdStr)}
|
||||
>
|
||||
<TableCell>
|
||||
{selected ? (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<div className="h-4 w-4 border rounded" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{test.id || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{test.name}</TableCell>
|
||||
<TableCell>
|
||||
{typeof test.questions === 'number'
|
||||
? test.questions
|
||||
: test.questions?.length || 0}
|
||||
</TableCell>
|
||||
<TableCell>{test.version || 'N/A'}</TableCell>
|
||||
<TableCell>
|
||||
{newlyRemoved ? (
|
||||
<Badge variant="destructive">Removed</Badge>
|
||||
) : selected && inCurrentPack ? (
|
||||
<Badge variant="default">In Pack</Badge>
|
||||
) : selected ? (
|
||||
<Badge variant="secondary">Selected</Badge>
|
||||
) : inCurrentPack ? (
|
||||
<Badge variant="outline">In Pack</Badge>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
300
admin/src/components/QuestionEditorDialog.tsx
Normal file
300
admin/src/components/QuestionEditorDialog.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import type {
|
||||
Question,
|
||||
SimpleQuestion,
|
||||
InputButtonsQuestion,
|
||||
MatrixQuestion,
|
||||
} from '@/types/questions'
|
||||
import { QuestionType, isInputButtonsQuestion, isMatrixQuestion } from '@/types/questions'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { SimpleQuestionForm } from './forms/SimpleQuestionForm'
|
||||
import { InputButtonsQuestionForm } from './forms/InputButtonsQuestionForm'
|
||||
import { MatrixQuestionForm } from './forms/MatrixQuestionForm'
|
||||
import { JSONQuestionEditor } from './JSONQuestionEditor'
|
||||
import { Code, FileText } from 'lucide-react'
|
||||
|
||||
type EditorMode = 'visual' | 'json'
|
||||
|
||||
interface QuestionEditorDialogProps {
|
||||
open: boolean
|
||||
question: Question | null
|
||||
onSave: (question: Question) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function QuestionEditorDialog({
|
||||
open,
|
||||
question,
|
||||
onSave,
|
||||
onClose,
|
||||
}: QuestionEditorDialogProps) {
|
||||
// Force internal state reset when reopening dialog or switching to another question.
|
||||
const dialogKey = `${open ? 'open' : 'closed'}_${question?.id ?? 'new'}`
|
||||
|
||||
return (
|
||||
<QuestionEditorDialogInner
|
||||
key={dialogKey}
|
||||
open={open}
|
||||
question={question}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionEditorDialogInner({
|
||||
open,
|
||||
question,
|
||||
onSave,
|
||||
onClose,
|
||||
}: QuestionEditorDialogProps) {
|
||||
const [mode, setMode] = useState<EditorMode>('visual')
|
||||
const [questionType, setQuestionType] = useState<QuestionType>(
|
||||
question?.questionType || QuestionType.SIMPLE,
|
||||
)
|
||||
const [currentQuestion, setCurrentQuestion] = useState<Partial<Question> | null>(
|
||||
question || {
|
||||
questionType: QuestionType.SIMPLE,
|
||||
word: '',
|
||||
answer: '',
|
||||
options: [],
|
||||
},
|
||||
)
|
||||
const [isValid, setIsValid] = useState(false)
|
||||
|
||||
const handleQuestionChange = useCallback((
|
||||
updated: Partial<Question> | null,
|
||||
valid: boolean,
|
||||
) => {
|
||||
setCurrentQuestion(updated)
|
||||
setIsValid(valid)
|
||||
}, [])
|
||||
|
||||
const handleSimpleQuestionChange = useCallback((updated: Partial<SimpleQuestion>) => {
|
||||
setCurrentQuestion({
|
||||
...updated,
|
||||
questionType: QuestionType.SIMPLE,
|
||||
})
|
||||
setIsValid(
|
||||
!!(updated.word && updated.answer && updated.options?.length),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleInputButtonsQuestionChange = useCallback((updated: Partial<InputButtonsQuestion>) => {
|
||||
setCurrentQuestion({
|
||||
...updated,
|
||||
questionType: QuestionType.INPUT_BUTTONS,
|
||||
})
|
||||
setIsValid(
|
||||
!!(
|
||||
updated.word &&
|
||||
updated.answer &&
|
||||
updated.options?.length &&
|
||||
updated.template
|
||||
),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleMatrixQuestionChange = useCallback((updated: Partial<MatrixQuestion>) => {
|
||||
setCurrentQuestion({
|
||||
...updated,
|
||||
questionType: QuestionType.MATRIX,
|
||||
// Keep required base fields present (backend can handle empty strings).
|
||||
word: updated.word ?? '',
|
||||
answer: updated.answer ?? '',
|
||||
options: updated.options ?? [],
|
||||
})
|
||||
setIsValid(!!updated.matrixSize && updated.matrixSize >= 2)
|
||||
}, [])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!currentQuestion || !isValid) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validation differs by question type.
|
||||
// Matrix question is config-only: backend generates cards from pack pool.
|
||||
if (isMatrixQuestion(currentQuestion as Question)) {
|
||||
const matrixSize = (currentQuestion as MatrixQuestion).matrixSize
|
||||
if (!matrixSize || matrixSize < 2) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Валидация обязательных полей
|
||||
if (!currentQuestion.word || !currentQuestion.answer) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentQuestion.options || currentQuestion.options.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isInputButtonsQuestion(currentQuestion as Question) &&
|
||||
!(currentQuestion as InputButtonsQuestion).template
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
onSave(currentQuestion as Question)
|
||||
}
|
||||
|
||||
const handleTypeChange = (newType: QuestionType) => {
|
||||
setQuestionType(newType)
|
||||
if (currentQuestion) {
|
||||
let updated: Partial<Question> = {
|
||||
...currentQuestion,
|
||||
questionType: newType,
|
||||
}
|
||||
|
||||
// Если переключаемся на input_buttons и нет template, добавляем пустой
|
||||
if (newType === QuestionType.INPUT_BUTTONS) {
|
||||
const u = updated as Partial<InputButtonsQuestion>
|
||||
if (!u.template) {
|
||||
u.template = ''
|
||||
}
|
||||
updated = u
|
||||
}
|
||||
|
||||
// Если переключаемся на matrix и нет matrixSize, добавляем дефолт
|
||||
if (newType === QuestionType.MATRIX) {
|
||||
const u = updated as Partial<MatrixQuestion>
|
||||
if (!u.matrixSize) {
|
||||
u.matrixSize = 3
|
||||
}
|
||||
if (!u.word) u.word = ''
|
||||
if (!u.answer) u.answer = ''
|
||||
if (!u.options) u.options = []
|
||||
updated = u
|
||||
}
|
||||
|
||||
setCurrentQuestion(updated)
|
||||
setIsValid(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{question ? 'Edit Question' : 'Create New Question'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{question
|
||||
? 'Update the question information'
|
||||
: 'Add a new question to the test'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Режим редактирования */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Label>Editor Mode:</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={mode === 'visual' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setMode('visual')}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Visual
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={mode === 'json' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setMode('json')}
|
||||
>
|
||||
<Code className="h-4 w-4 mr-2" />
|
||||
JSON
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Тип вопроса (только в visual режиме) */}
|
||||
{mode === 'visual' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="question-type">Type:</Label>
|
||||
<Select
|
||||
value={questionType}
|
||||
onValueChange={(value) => handleTypeChange(value as QuestionType)}
|
||||
>
|
||||
<SelectTrigger id="question-type" className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={QuestionType.SIMPLE}>Simple</SelectItem>
|
||||
<SelectItem value={QuestionType.INPUT_BUTTONS}>
|
||||
Input Buttons
|
||||
</SelectItem>
|
||||
<SelectItem value={QuestionType.MATRIX}>Matrix</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Контент редактора */}
|
||||
{mode === 'visual' ? (
|
||||
<div>
|
||||
{questionType === QuestionType.SIMPLE ? (
|
||||
<SimpleQuestionForm
|
||||
question={(currentQuestion as Partial<SimpleQuestion>) || {}}
|
||||
onChange={handleSimpleQuestionChange}
|
||||
/>
|
||||
) : questionType === QuestionType.INPUT_BUTTONS ? (
|
||||
<InputButtonsQuestionForm
|
||||
question={(currentQuestion as Partial<InputButtonsQuestion>) || {}}
|
||||
onChange={handleInputButtonsQuestionChange}
|
||||
/>
|
||||
) : (
|
||||
<MatrixQuestionForm
|
||||
question={(currentQuestion as Partial<MatrixQuestion>) || {}}
|
||||
onChange={handleMatrixQuestionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<JSONQuestionEditor
|
||||
question={currentQuestion}
|
||||
onChange={handleQuestionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!isValid || !currentQuestion}
|
||||
>
|
||||
{question ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
89
admin/src/components/TestPacksManager.test.tsx
Normal file
89
admin/src/components/TestPacksManager.test.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import { TestPacksManager } from '@/components/TestPacksManager'
|
||||
|
||||
vi.mock('@/api/packs', () => {
|
||||
return {
|
||||
packsApi: {
|
||||
getPacks: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { packsApi } from '@/api/packs'
|
||||
|
||||
function renderWithQuery(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('TestPacksManager', () => {
|
||||
it('loads packs and emits add/remove IDs on toggle', async () => {
|
||||
const onPacksChange = vi.fn()
|
||||
|
||||
vi.mocked(packsApi.getPacks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'pack-1',
|
||||
title: 'Pack One',
|
||||
cards: 10,
|
||||
enabled: true,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: 'pack-2',
|
||||
title: 'Pack Two',
|
||||
cards: 5,
|
||||
enabled: true,
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 100,
|
||||
totalPages: 1,
|
||||
})
|
||||
|
||||
renderWithQuery(
|
||||
<TestPacksManager
|
||||
currentPackIds={['pack-1']}
|
||||
onPacksChange={onPacksChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Pack One')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pack Two')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Select pack-2 -> should be add
|
||||
fireEvent.click(screen.getByText('Pack Two'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPacksChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const lastCall1 = onPacksChange.mock.calls.at(-1)
|
||||
expect(lastCall1?.[0]).toEqual(['pack-2'])
|
||||
expect(lastCall1?.[1]).toEqual([])
|
||||
|
||||
// Deselect pack-1 (already linked) -> should be remove
|
||||
fireEvent.click(screen.getByText('Pack One'))
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall2 = onPacksChange.mock.calls.at(-1)
|
||||
expect(lastCall2?.[1]).toEqual(['pack-1'])
|
||||
})
|
||||
})
|
||||
})
|
||||
260
admin/src/components/TestPacksManager.tsx
Normal file
260
admin/src/components/TestPacksManager.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { packsApi } from '@/api/packs'
|
||||
import type { CardPackPreviewDto } from '@/types/models'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Search, Check } from 'lucide-react'
|
||||
|
||||
interface TestPacksManagerProps {
|
||||
currentPackIds: string[]
|
||||
onPacksChange: (addIds: string[], removeIds: string[]) => void
|
||||
onSelectedPackColorChange?: (color: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function TestPacksManager({
|
||||
currentPackIds,
|
||||
onPacksChange,
|
||||
onSelectedPackColorChange,
|
||||
disabled = false,
|
||||
}: TestPacksManagerProps) {
|
||||
// Reset internal state when switching tests / pack list.
|
||||
const stateKey = currentPackIds.join(',')
|
||||
|
||||
return (
|
||||
<TestPacksManagerInner
|
||||
key={stateKey}
|
||||
currentPackIds={currentPackIds}
|
||||
onPacksChange={onPacksChange}
|
||||
onSelectedPackColorChange={onSelectedPackColorChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TestPacksManagerInner({
|
||||
currentPackIds,
|
||||
onPacksChange,
|
||||
onSelectedPackColorChange,
|
||||
disabled = false,
|
||||
}: TestPacksManagerProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedPacks, setSelectedPacks] = useState<Set<string>>(
|
||||
() => new Set(currentPackIds.map(String)),
|
||||
)
|
||||
const [removedPacks, setRemovedPacks] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const selectedList = useMemo(
|
||||
() => Array.from(selectedPacks).sort(),
|
||||
[selectedPacks],
|
||||
)
|
||||
const removedList = useMemo(
|
||||
() => Array.from(removedPacks).sort(),
|
||||
[removedPacks],
|
||||
)
|
||||
|
||||
const { data: packsData, isLoading } = useQuery({
|
||||
queryKey: ['packs', 1, 100, search, true],
|
||||
queryFn: () =>
|
||||
packsApi.getPacks({
|
||||
page: 1,
|
||||
limit: 100,
|
||||
search,
|
||||
showDisabled: true,
|
||||
}),
|
||||
enabled: !disabled,
|
||||
})
|
||||
|
||||
const allPacks: CardPackPreviewDto[] = packsData?.items || []
|
||||
const packsById = useMemo(() => {
|
||||
return new Map(allPacks.map((pack) => [String(pack.id), pack]))
|
||||
}, [allPacks])
|
||||
|
||||
useEffect(() => {
|
||||
const toAdd = selectedList.filter(
|
||||
(id) => !currentPackIds.includes(id) && !removedList.includes(id),
|
||||
)
|
||||
|
||||
const toRemove = removedList.filter((id) => currentPackIds.includes(id))
|
||||
|
||||
if (toAdd.length > 0 || toRemove.length > 0) {
|
||||
onPacksChange(toAdd, toRemove)
|
||||
} else if (selectedPacks.size > 0 || removedPacks.size > 0) {
|
||||
onPacksChange([], [])
|
||||
}
|
||||
}, [
|
||||
currentPackIds,
|
||||
onPacksChange,
|
||||
removedList,
|
||||
selectedList,
|
||||
removedPacks.size,
|
||||
selectedPacks.size,
|
||||
])
|
||||
|
||||
const handleTogglePack = (pack: CardPackPreviewDto) => {
|
||||
if (disabled || !pack?.id) return
|
||||
|
||||
const packIdStr = String(pack.id)
|
||||
const isCurrentlySelected =
|
||||
selectedPacks.has(packIdStr) && !removedPacks.has(packIdStr)
|
||||
const isInCurrentTest = currentPackIds.includes(packIdStr)
|
||||
|
||||
if (isCurrentlySelected) {
|
||||
const nextSelected = new Set(selectedPacks)
|
||||
nextSelected.delete(packIdStr)
|
||||
setSelectedPacks(nextSelected)
|
||||
|
||||
if (isInCurrentTest) {
|
||||
setRemovedPacks((prev) => new Set([...prev, packIdStr]))
|
||||
}
|
||||
|
||||
if (onSelectedPackColorChange) {
|
||||
const fallbackId = Array.from(nextSelected)[0]
|
||||
const fallback = fallbackId ? packsById.get(fallbackId) : undefined
|
||||
if (fallback?.color) onSelectedPackColorChange(fallback.color)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const nextSelected = new Set(selectedPacks)
|
||||
nextSelected.add(packIdStr)
|
||||
setSelectedPacks(nextSelected)
|
||||
|
||||
if (removedPacks.has(packIdStr)) {
|
||||
setRemovedPacks((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(packIdStr)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (onSelectedPackColorChange) {
|
||||
onSelectedPackColorChange(pack.color ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
const isPackSelected = (packId: string | number) => {
|
||||
const id = String(packId)
|
||||
return selectedPacks.has(id) && !removedPacks.has(id)
|
||||
}
|
||||
|
||||
const isPackInCurrentTest = (packId: string | number) => {
|
||||
return currentPackIds.includes(String(packId))
|
||||
}
|
||||
|
||||
const filteredPacks = search
|
||||
? allPacks.filter(
|
||||
(pack) =>
|
||||
pack.title?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
pack.id?.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: allPacks
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Link test to packs</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search packs by title or ID..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selected packs: {selectedPacks.size} / {allPacks.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4">Loading packs...</div>
|
||||
) : (
|
||||
<div className="border rounded-lg max-h-[320px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Cards</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPacks.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
No packs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredPacks.map((pack) => {
|
||||
const packIdStr = String(pack.id)
|
||||
const selected = isPackSelected(packIdStr)
|
||||
const inCurrentTest = isPackInCurrentTest(packIdStr)
|
||||
const newlyRemoved = removedPacks.has(packIdStr)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={pack.id}
|
||||
className={selected ? 'bg-muted/50' : ''}
|
||||
onClick={() => handleTogglePack(pack)}
|
||||
>
|
||||
<TableCell>
|
||||
{selected ? (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<div className="h-4 w-4 border rounded" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{pack.id}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{pack.title}
|
||||
</TableCell>
|
||||
<TableCell>{pack.cards}</TableCell>
|
||||
<TableCell>
|
||||
{newlyRemoved ? (
|
||||
<Badge variant="destructive">Removed</Badge>
|
||||
) : selected && inCurrentTest ? (
|
||||
<Badge variant="default">Linked</Badge>
|
||||
) : selected ? (
|
||||
<Badge variant="secondary">Selected</Badge>
|
||||
) : inCurrentTest ? (
|
||||
<Badge variant="outline">Linked</Badge>
|
||||
) : pack.enabled ? (
|
||||
<Badge variant="outline">Enabled</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Disabled</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
admin/src/components/TestQuestionsManager.tsx
Normal file
183
admin/src/components/TestQuestionsManager.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { useState } from 'react'
|
||||
import type { Question } from '@/types/questions'
|
||||
import { QuestionType, isInputButtonsQuestion } from '@/types/questions'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Edit, Trash2, FileText, Type } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
interface TestQuestionsManagerProps {
|
||||
questions: Question[]
|
||||
onChange: (questions: Question[]) => void
|
||||
onEdit: (question: Question, index: number) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function TestQuestionsManager({
|
||||
questions,
|
||||
onChange,
|
||||
onEdit,
|
||||
disabled = false,
|
||||
}: TestQuestionsManagerProps) {
|
||||
const [questionToDelete, setQuestionToDelete] = useState<{
|
||||
question: Question
|
||||
index: number
|
||||
} | null>(null)
|
||||
|
||||
const handleDelete = (question: Question, index: number) => {
|
||||
setQuestionToDelete({ question, index })
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (questionToDelete) {
|
||||
const newQuestions = questions.filter((_, i) => i !== questionToDelete.index)
|
||||
onChange(newQuestions)
|
||||
setQuestionToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getQuestionPreview = (question: Question): string => {
|
||||
if (question.text) return question.text
|
||||
if (question.image) return '📷 Image question'
|
||||
return `Word: ${question.word}`
|
||||
}
|
||||
|
||||
const getQuestionTypeLabel = (type: QuestionType): string => {
|
||||
switch (type) {
|
||||
case QuestionType.SIMPLE:
|
||||
return 'Simple'
|
||||
case QuestionType.INPUT_BUTTONS:
|
||||
return 'Input Buttons'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
const getQuestionTypeColor = (type: QuestionType): string => {
|
||||
switch (type) {
|
||||
case QuestionType.SIMPLE:
|
||||
return 'bg-blue-500'
|
||||
case QuestionType.INPUT_BUTTONS:
|
||||
return 'bg-green-500'
|
||||
default:
|
||||
return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Questions</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{questions.length} question{questions.length !== 1 ? 's' : ''} in this test
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{questions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<FileText className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No questions yet. Add your first question to get started.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{questions.map((question, index) => (
|
||||
<Card key={question.id || index}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className={`${getQuestionTypeColor(
|
||||
question.questionType,
|
||||
)} text-white border-transparent`}
|
||||
>
|
||||
<Type className="h-3 w-3 mr-1" />
|
||||
{getQuestionTypeLabel(question.questionType)}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
#{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{question.word}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getQuestionPreview(question)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{question.options.length} option(s)</span>
|
||||
{isInputButtonsQuestion(question) && (
|
||||
<span>• Template: {question.template}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(question, index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(question, index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={questionToDelete !== null}
|
||||
onOpenChange={(open) => !open && setQuestionToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Question</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this question? This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
admin/src/components/TokenRefreshProvider.tsx
Normal file
52
admin/src/components/TokenRefreshProvider.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
/**
|
||||
* Component that periodically checks and refreshes the access token
|
||||
* before it expires. This ensures seamless user experience without
|
||||
* unexpected logouts.
|
||||
*/
|
||||
export function TokenRefreshProvider({ children }: { children: React.ReactNode }) {
|
||||
const { shouldRefreshToken, refreshToken, updateToken, logout } = useAuthStore()
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Check every minute if token needs refresh
|
||||
const checkAndRefresh = async () => {
|
||||
if (!shouldRefreshToken() || !refreshToken) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[TokenRefreshProvider] Refreshing token...')
|
||||
const response = await authApi.refreshToken(refreshToken)
|
||||
|
||||
if (response.success && response.token) {
|
||||
updateToken(response.token, response.refreshToken, response.expiresIn)
|
||||
console.log('[TokenRefreshProvider] Token refreshed successfully')
|
||||
} else {
|
||||
throw new Error('Failed to refresh token')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TokenRefreshProvider] Token refresh failed:', error)
|
||||
// If refresh token is expired, logout
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
// Check immediately on mount
|
||||
checkAndRefresh()
|
||||
|
||||
// Set up interval to check every minute
|
||||
intervalRef.current = setInterval(checkAndRefresh, 60000) // 1 minute
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [shouldRefreshToken, refreshToken, updateToken, logout])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
251
admin/src/components/forms/InputButtonsQuestionForm.tsx
Normal file
251
admin/src/components/forms/InputButtonsQuestionForm.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import type { InputButtonsQuestion, TestButton } from '@/types/questions'
|
||||
import { QuestionType } from '@/types/questions'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ImageUpload } from '@/components/ui/image-upload'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
||||
interface InputButtonsQuestionFormProps {
|
||||
question: Partial<InputButtonsQuestion>
|
||||
onChange: (question: Partial<InputButtonsQuestion>) => void
|
||||
}
|
||||
|
||||
export function InputButtonsQuestionForm({
|
||||
question,
|
||||
onChange,
|
||||
}: InputButtonsQuestionFormProps) {
|
||||
const [word, setWord] = useState(question.word || '')
|
||||
const [text, setText] = useState(question.text || '')
|
||||
const [image, setImage] = useState(question.image || '')
|
||||
const [audio, setAudio] = useState(question.audio || '')
|
||||
const [buttons, setButtons] = useState<TestButton[]>(question.options || [])
|
||||
const [answer, setAnswer] = useState(question.answer || '')
|
||||
const [template, setTemplate] = useState(question.template || '')
|
||||
const onChangeRef = useRef(onChange)
|
||||
|
||||
// Keep onChange ref up to date
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
// Notify parent of changes
|
||||
useEffect(() => {
|
||||
onChangeRef.current({
|
||||
questionType: QuestionType.INPUT_BUTTONS,
|
||||
word,
|
||||
text: text || undefined,
|
||||
image: image || undefined,
|
||||
audio: audio || undefined,
|
||||
options: buttons,
|
||||
answer,
|
||||
template,
|
||||
})
|
||||
}, [word, text, image, audio, buttons, answer, template])
|
||||
|
||||
const addButton = () => {
|
||||
const newButton: TestButton = {
|
||||
id: `btn_${Date.now()}`,
|
||||
text: '',
|
||||
}
|
||||
setButtons([...buttons, newButton])
|
||||
}
|
||||
|
||||
const removeButton = (index: number) => {
|
||||
setButtons(buttons.filter((_, i) => i !== index))
|
||||
if (answer === buttons[index]?.id) {
|
||||
setAnswer('')
|
||||
}
|
||||
}
|
||||
|
||||
const updateButton = (index: number, updates: Partial<TestButton>) => {
|
||||
const newButtons = [...buttons]
|
||||
newButtons[index] = { ...newButtons[index], ...updates }
|
||||
setButtons(newButtons)
|
||||
}
|
||||
|
||||
// Генерация template из answer
|
||||
const generateTemplate = () => {
|
||||
if (answer) {
|
||||
setTemplate(answer.replace(/[^ ]/g, '_'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="word">
|
||||
Word <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="word"
|
||||
value={word}
|
||||
onChange={(e) => setWord(e.target.value)}
|
||||
placeholder="Word or phrase to learn"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">Question Text</Label>
|
||||
<Textarea
|
||||
id="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Optional question text"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Question Image"
|
||||
value={image}
|
||||
onChange={(value) => setImage(value || '')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audio">Audio URL</Label>
|
||||
<Input
|
||||
id="audio"
|
||||
value={audio}
|
||||
onChange={(e) => setAudio(e.target.value)}
|
||||
placeholder="https://..."
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Answer Options <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addButton}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Button
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{buttons.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No buttons yet. Add at least one button.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{buttons.map((button, index) => (
|
||||
<Card key={button.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
Button {index + 1} (ID: {button.id})
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeButton(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`button-text-${index}`}>Text</Label>
|
||||
<Input
|
||||
id={`button-text-${index}`}
|
||||
value={button.text || ''}
|
||||
onChange={(e) =>
|
||||
updateButton(index, { text: e.target.value })
|
||||
}
|
||||
placeholder="Button text"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Image"
|
||||
value={button.image || ''}
|
||||
onChange={(value) =>
|
||||
updateButton(index, { image: value || undefined })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="answer">
|
||||
Correct Answer <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={answer} onValueChange={setAnswer} required>
|
||||
<SelectTrigger id="answer">
|
||||
<SelectValue placeholder="Select correct answer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{buttons.map((button) => (
|
||||
<SelectItem key={button.id} value={button.id}>
|
||||
{button.text || button.image || button.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{buttons.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add at least one button to select an answer
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="template">
|
||||
Template <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{answer && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={generateTemplate}
|
||||
>
|
||||
Generate from answer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="template"
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
placeholder="e.g., a___e (underscores for letters to input)"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Template defines which letters the user needs to input. Use underscores
|
||||
(_) for letters to input, spaces for word separators.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
admin/src/components/forms/MatrixQuestionForm.tsx
Normal file
75
admin/src/components/forms/MatrixQuestionForm.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { MatrixQuestion } from '@/types/questions'
|
||||
import { QuestionType } from '@/types/questions'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
interface MatrixQuestionFormProps {
|
||||
question: Partial<MatrixQuestion>
|
||||
onChange: (question: Partial<MatrixQuestion>) => void
|
||||
}
|
||||
|
||||
export function MatrixQuestionForm({ question, onChange }: MatrixQuestionFormProps) {
|
||||
const [matrixSize, setMatrixSize] = useState<number>(question.matrixSize ?? 3)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const initialQuestionRef = useRef<string | undefined>(question.id)
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialQuestionRef.current !== question.id) {
|
||||
initialQuestionRef.current = question.id
|
||||
setMatrixSize(question.matrixSize ?? 3)
|
||||
}
|
||||
}, [question.id, question.matrixSize])
|
||||
|
||||
useEffect(() => {
|
||||
// Matrix question is auto-generated from pack cards on the backend
|
||||
// when `buttons` (options) are empty.
|
||||
onChangeRef.current({
|
||||
questionType: QuestionType.MATRIX,
|
||||
matrixSize,
|
||||
// Keep required base fields present (backend can handle empty strings).
|
||||
word: question.word ?? '',
|
||||
answer: question.answer ?? '',
|
||||
options: question.options ?? [],
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [matrixSize])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Matrix size</Label>
|
||||
<Select
|
||||
value={String(matrixSize)}
|
||||
onValueChange={(v) => setMatrixSize(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Choose size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2×2</SelectItem>
|
||||
<SelectItem value="3">3×3</SelectItem>
|
||||
<SelectItem value="4">4×4</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cards are auto-picked from the pack pool. In the app, users will see a
|
||||
matrix of images and must click the card matching the shown{' '}
|
||||
<code>original</code>. Correct cards flip (showing translation) and
|
||||
disappear.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
213
admin/src/components/forms/SimpleQuestionForm.tsx
Normal file
213
admin/src/components/forms/SimpleQuestionForm.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import type { SimpleQuestion, TestButton } from '@/types/questions'
|
||||
import { QuestionType } from '@/types/questions'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ImageUpload } from '@/components/ui/image-upload'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
||||
interface SimpleQuestionFormProps {
|
||||
question: Partial<SimpleQuestion>
|
||||
onChange: (question: Partial<SimpleQuestion>) => void
|
||||
}
|
||||
|
||||
export function SimpleQuestionForm({
|
||||
question,
|
||||
onChange,
|
||||
}: SimpleQuestionFormProps) {
|
||||
const [word, setWord] = useState(question.word || '')
|
||||
const [text, setText] = useState(question.text || '')
|
||||
const [image, setImage] = useState(question.image || '')
|
||||
const [audio, setAudio] = useState(question.audio || '')
|
||||
const [buttons, setButtons] = useState<TestButton[]>(question.options || [])
|
||||
const [answer, setAnswer] = useState(question.answer || '')
|
||||
const onChangeRef = useRef(onChange)
|
||||
|
||||
// Keep onChange ref up to date
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
// Notify parent of changes
|
||||
useEffect(() => {
|
||||
onChangeRef.current({
|
||||
questionType: QuestionType.SIMPLE,
|
||||
word,
|
||||
text: text || undefined,
|
||||
image: image || undefined,
|
||||
audio: audio || undefined,
|
||||
options: buttons,
|
||||
answer,
|
||||
})
|
||||
}, [word, text, image, audio, buttons, answer])
|
||||
|
||||
const addButton = () => {
|
||||
const newButton: TestButton = {
|
||||
id: `btn_${Date.now()}`,
|
||||
text: '',
|
||||
}
|
||||
setButtons([...buttons, newButton])
|
||||
}
|
||||
|
||||
const removeButton = (index: number) => {
|
||||
setButtons(buttons.filter((_, i) => i !== index))
|
||||
if (answer === buttons[index]?.id) {
|
||||
setAnswer('')
|
||||
}
|
||||
}
|
||||
|
||||
const updateButton = (index: number, updates: Partial<TestButton>) => {
|
||||
const newButtons = [...buttons]
|
||||
newButtons[index] = { ...newButtons[index], ...updates }
|
||||
setButtons(newButtons)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="word">
|
||||
Word <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="word"
|
||||
value={word}
|
||||
onChange={(e) => setWord(e.target.value)}
|
||||
placeholder="Word or phrase to learn"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">Question Text</Label>
|
||||
<Textarea
|
||||
id="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Optional question text"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Question Image"
|
||||
value={image}
|
||||
onChange={(value) => setImage(value || '')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audio">Audio URL</Label>
|
||||
<Input
|
||||
id="audio"
|
||||
value={audio}
|
||||
onChange={(e) => setAudio(e.target.value)}
|
||||
placeholder="https://..."
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Answer Options <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addButton}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Button
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{buttons.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No buttons yet. Add at least one button.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{buttons.map((button, index) => (
|
||||
<Card key={button.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
Button {index + 1} (ID: {button.id})
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeButton(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`button-text-${index}`}>Text</Label>
|
||||
<Input
|
||||
id={`button-text-${index}`}
|
||||
value={button.text || ''}
|
||||
onChange={(e) =>
|
||||
updateButton(index, { text: e.target.value })
|
||||
}
|
||||
placeholder="Button text"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Image"
|
||||
value={button.image || ''}
|
||||
onChange={(value) =>
|
||||
updateButton(index, { image: value || undefined })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="answer">
|
||||
Correct Answer <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={answer} onValueChange={setAnswer} required>
|
||||
<SelectTrigger id="answer">
|
||||
<SelectValue placeholder="Select correct answer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{buttons.map((button) => (
|
||||
<SelectItem key={button.id} value={button.id}>
|
||||
{button.text || button.image || button.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{buttons.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add at least one button to select an answer
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
admin/src/components/layout/Layout.tsx
Normal file
131
admin/src/components/layout/Layout.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Package,
|
||||
Users,
|
||||
ClipboardList,
|
||||
LogOut,
|
||||
Menu,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Cards', href: '/cards', icon: FileText },
|
||||
{ name: 'Packs', href: '/packs', icon: Package },
|
||||
{ name: 'Tests', href: '/tests', icon: ClipboardList },
|
||||
{ name: 'Users', href: '/users', icon: Users },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { user, logout } = useAuthStore()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
{/* Mobile sidebar overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-gray-900">Mnemo Admin</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-4 space-y-2">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = location.pathname === item.href
|
||||
return (
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
navigate(item.href)
|
||||
setSidebarOpen(false)
|
||||
}}
|
||||
>
|
||||
<Icon className="mr-3 h-5 w-5" />
|
||||
{item.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User info and logout */}
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-gray-900">{user?.name || 'Admin'}</p>
|
||||
<p className="text-gray-500">{user?.email}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Top bar */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200 lg:hidden">
|
||||
<div className="flex items-center justify-between h-16 px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Mnemo Cards Admin</h1>
|
||||
<div className="w-10" /> {/* Spacer */}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
admin/src/components/ui/alert-dialog.tsx
Normal file
138
admin/src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
59
admin/src/components/ui/alert.tsx
Normal file
59
admin/src/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
335
admin/src/components/ui/audio-upload.tsx
Normal file
335
admin/src/components/ui/audio-upload.tsx
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import { useRef, useState, useEffect } from 'react'
|
||||
import { Button } from './button'
|
||||
import { Label } from './label'
|
||||
import { Input } from './input'
|
||||
import { X, Music, Play, Pause, Loader2 } from 'lucide-react'
|
||||
import { mediaApi } from '@/api/media'
|
||||
|
||||
interface AudioUploadProps {
|
||||
label?: string
|
||||
value?: string // Object ID (UUID) or legacy base64
|
||||
onChange: (value: string | undefined) => void
|
||||
language?: string
|
||||
onLanguageChange?: (language: string) => void
|
||||
accept?: string
|
||||
maxSizeMB?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function AudioUpload({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
language = 'en',
|
||||
onLanguageChange,
|
||||
accept = 'audio/*',
|
||||
maxSizeMB = 20, // Updated to match backend limit
|
||||
disabled = false,
|
||||
}: AudioUploadProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [presignedUrl, setPresignedUrl] = useState<string | null>(null)
|
||||
|
||||
// Load presigned URL for existing objectId
|
||||
useEffect(() => {
|
||||
if (value && !presignedUrl) {
|
||||
// Check if value is a UUID (objectId) or legacy format
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
||||
|
||||
if (isUuid) {
|
||||
// It's an objectId, fetch presigned URL
|
||||
mediaApi
|
||||
.getPresignedUrl('voice-audio', value)
|
||||
.then((response) => {
|
||||
setPresignedUrl(response.url)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load presigned URL:', error)
|
||||
// Keep presignedUrl as null
|
||||
})
|
||||
} else {
|
||||
// Legacy base64 format
|
||||
setPresignedUrl(`data:audio/mpeg;base64,${value}`)
|
||||
}
|
||||
} else if (!value) {
|
||||
setPresignedUrl(null)
|
||||
}
|
||||
}, [value, presignedUrl])
|
||||
|
||||
const handleFileSelect = async (file: File) => {
|
||||
// Validate file size
|
||||
const fileSizeMB = file.size / (1024 * 1024)
|
||||
if (fileSizeMB > maxSizeMB) {
|
||||
alert(`File size must be less than ${maxSizeMB}MB`)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('audio/')) {
|
||||
alert('Please select an audio file')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true)
|
||||
|
||||
// Upload to MinIO via Media API
|
||||
const response = await mediaApi.uploadVoice(file)
|
||||
|
||||
// Save objectId (not presigned URL!)
|
||||
onChange(response.objectId)
|
||||
|
||||
// Use presigned URL for playback
|
||||
setPresignedUrl(response.url)
|
||||
} catch (error) {
|
||||
console.error('Error uploading audio:', error)
|
||||
alert('Failed to upload audio. Please try again.')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleFileSelect(file)
|
||||
}
|
||||
// Reset input value to allow selecting the same file again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
if (disabled) return
|
||||
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
handleFileSelect(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange(undefined)
|
||||
setPresignedUrl(null)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
setIsPlaying(false)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled && !isUploading) {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup audio on unmount or value change
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}, [value, presignedUrl])
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (!presignedUrl) return
|
||||
|
||||
if (!audioRef.current) {
|
||||
try {
|
||||
const audio = new Audio(presignedUrl)
|
||||
audioRef.current = audio
|
||||
|
||||
audio.onended = () => {
|
||||
setIsPlaying(false)
|
||||
audioRef.current = null
|
||||
}
|
||||
|
||||
audio.onerror = (e) => {
|
||||
console.error('Audio playback error:', e)
|
||||
alert('Failed to play audio. The file may be corrupted or in an unsupported format.')
|
||||
setIsPlaying(false)
|
||||
audioRef.current = null
|
||||
}
|
||||
|
||||
audio.onloadstart = () => {
|
||||
setIsPlaying(true)
|
||||
}
|
||||
|
||||
audio.play().catch((error) => {
|
||||
console.error('Audio play error:', error)
|
||||
alert('Failed to play audio. Please check your browser audio settings.')
|
||||
setIsPlaying(false)
|
||||
audioRef.current = null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating audio element:', error)
|
||||
alert('Failed to initialize audio player')
|
||||
setIsPlaying(false)
|
||||
}
|
||||
} else {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
audioRef.current.play().catch((error) => {
|
||||
console.error('Audio play error:', error)
|
||||
alert('Failed to resume audio playback')
|
||||
})
|
||||
setIsPlaying(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && <Label>{label}</Label>}
|
||||
|
||||
{value || isUploading ? (
|
||||
<div className="relative">
|
||||
<div className={`flex items-center justify-between p-4 border rounded-lg transition-colors ${
|
||||
isPlaying ? 'bg-primary/5 border-primary/20' : 'bg-muted'
|
||||
}`}>
|
||||
{isUploading ? (
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Uploading audio...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isPlaying ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handlePlayPause}
|
||||
disabled={disabled || !presignedUrl}
|
||||
className="flex items-center space-x-1 flex-shrink-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Pause</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Play</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Music className={`h-5 w-5 flex-shrink-0 ${
|
||||
isPlaying ? 'text-primary' : 'text-muted-foreground'
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">Audio file loaded</span>
|
||||
{isPlaying && (
|
||||
<span className="ml-2 text-xs text-primary animate-pulse">Playing...</span>
|
||||
)}
|
||||
{!presignedUrl && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">(Loading preview...)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
disabled={disabled}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{onLanguageChange && (
|
||||
<div className="mt-2">
|
||||
<Label htmlFor="language" className="text-xs">Language</Label>
|
||||
<Input
|
||||
id="language"
|
||||
value={language}
|
||||
onChange={(e) => onLanguageChange(e.target.value)}
|
||||
placeholder="en"
|
||||
className="mt-1"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{isUploading ? 'Uploading...' : 'Click to change audio file'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
} ${disabled || isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
disabled={disabled || isUploading}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center space-y-2">
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Music className="h-10 w-10 text-muted-foreground" />
|
||||
)}
|
||||
<div className="text-sm">
|
||||
{isUploading ? (
|
||||
<span className="text-muted-foreground">Uploading...</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-primary font-medium">Click to upload</span> or drag and drop
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
MP3, WAV, OGG, FLAC up to {maxSizeMB}MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
admin/src/components/ui/badge.tsx
Normal file
37
admin/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Badge, badgeVariants }
|
||||
59
admin/src/components/ui/button.tsx
Normal file
59
admin/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
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 buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
variant?: VariantProps<typeof buttonVariants>['variant']
|
||||
size?: VariantProps<typeof buttonVariants>['size']
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants }
|
||||
79
admin/src/components/ui/card.tsx
Normal file
79
admin/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
28
admin/src/components/ui/checkbox.tsx
Normal file
28
admin/src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
72
admin/src/components/ui/color-palette-input.test.tsx
Normal file
72
admin/src/components/ui/color-palette-input.test.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import { ColorPaletteInput } from '@/components/ui/color-palette-input'
|
||||
|
||||
describe('ColorPaletteInput', () => {
|
||||
it('renders current value in text input', () => {
|
||||
render(
|
||||
<>
|
||||
<label htmlFor="color">Color</label>
|
||||
<ColorPaletteInput
|
||||
id="color"
|
||||
value="#FF0000"
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('Color')).toHaveValue('#FF0000')
|
||||
})
|
||||
|
||||
it('calls onChange when text input changes', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<>
|
||||
<label htmlFor="color">Color</label>
|
||||
<ColorPaletteInput id="color" value="" onChange={onChange} />
|
||||
</>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Color'), {
|
||||
target: { value: '#ABCDEF' },
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('#ABCDEF')
|
||||
})
|
||||
|
||||
it('calls onChange when native picker changes', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ColorPaletteInput
|
||||
id="color"
|
||||
value="#000000"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Pick a color'), {
|
||||
target: { value: '#00ff00' },
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('#00ff00')
|
||||
})
|
||||
|
||||
it('can clear value', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ColorPaletteInput
|
||||
id="color"
|
||||
value="#123456"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
85
admin/src/components/ui/color-palette-input.tsx
Normal file
85
admin/src/components/ui/color-palette-input.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const isHexColor = (value: string): boolean => {
|
||||
const trimmed = value.trim()
|
||||
return /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(trimmed)
|
||||
}
|
||||
|
||||
export type ColorPaletteInputProps = {
|
||||
id: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export const ColorPaletteInput = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder = '#FF0000',
|
||||
}: ColorPaletteInputProps) => {
|
||||
const trimmed = value.trim()
|
||||
const hasValidHex = trimmed.length > 0 && isHexColor(trimmed)
|
||||
|
||||
// Native color input requires a valid 7-char hex (#RRGGBB)
|
||||
const nativePickerValue = hasValidHex && trimmed.length === 7 ? trimmed : '#000000'
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
aria-invalid={trimmed.length > 0 && !hasValidHex}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
aria-label="Pick a color"
|
||||
type="color"
|
||||
value={nativePickerValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'h-10 w-10 cursor-pointer rounded-md border border-input bg-background p-0',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-label={hasValidHex ? `Selected color ${trimmed}` : 'Selected color preview'}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-md border border-input',
|
||||
trimmed.length > 0 && !hasValidHex && 'border-dashed border-red-500',
|
||||
)}
|
||||
style={hasValidHex ? { backgroundColor: trimmed } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange('')}
|
||||
disabled={disabled || value.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
|
||||
{trimmed.length > 0 && !hasValidHex && (
|
||||
<span className="text-xs text-red-500">Invalid hex color</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
admin/src/components/ui/dialog.tsx
Normal file
120
admin/src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
242
admin/src/components/ui/image-upload.tsx
Normal file
242
admin/src/components/ui/image-upload.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { useRef, useState, useEffect } from 'react'
|
||||
import { Button } from './button'
|
||||
import { Label } from './label'
|
||||
import { X, Image as ImageIcon, Loader2 } from 'lucide-react'
|
||||
import { mediaApi } from '@/api/media'
|
||||
|
||||
interface ImageUploadProps {
|
||||
label?: string
|
||||
value?: string // Object ID (UUID) or legacy base64/URL
|
||||
onChange: (value: string | undefined) => void
|
||||
accept?: string
|
||||
maxSizeMB?: number
|
||||
disabled?: boolean
|
||||
uploadType?: 'card-image' | 'test-image' // Type of upload endpoint
|
||||
}
|
||||
|
||||
export function ImageUpload({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
accept = 'image/*',
|
||||
maxSizeMB = 10, // Updated to match backend limit
|
||||
disabled = false,
|
||||
uploadType = 'card-image',
|
||||
}: ImageUploadProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
|
||||
// Load presigned URL for existing objectId
|
||||
useEffect(() => {
|
||||
if (value && !previewUrl) {
|
||||
// Check if value is a UUID (objectId) or legacy format
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
||||
|
||||
if (isUuid) {
|
||||
// It's an objectId, fetch presigned URL
|
||||
const bucket = uploadType === 'card-image' ? 'card-images' : 'test-images'
|
||||
mediaApi
|
||||
.getPresignedUrl(bucket, value)
|
||||
.then((response) => {
|
||||
setPreviewUrl(response.url)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load preview URL:', error)
|
||||
// Keep previewUrl as null, will show placeholder
|
||||
})
|
||||
} else if (
|
||||
value.startsWith('http://') ||
|
||||
value.startsWith('https://') ||
|
||||
value.startsWith('/api/')
|
||||
) {
|
||||
// Legacy URL format
|
||||
setPreviewUrl(value)
|
||||
} else {
|
||||
// Legacy base64 format
|
||||
setPreviewUrl(`data:image/png;base64,${value}`)
|
||||
}
|
||||
} else if (!value) {
|
||||
setPreviewUrl(null)
|
||||
}
|
||||
}, [value, uploadType, previewUrl])
|
||||
|
||||
const handleFileSelect = async (file: File) => {
|
||||
// Validate file size
|
||||
const fileSizeMB = file.size / (1024 * 1024)
|
||||
if (fileSizeMB > maxSizeMB) {
|
||||
alert(`File size must be less than ${maxSizeMB}MB`)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true)
|
||||
|
||||
// Upload to MinIO via Media API
|
||||
const uploadEndpoint = uploadType === 'card-image'
|
||||
? mediaApi.uploadCardImage(file)
|
||||
: mediaApi.uploadTestImage(file)
|
||||
|
||||
const response = await uploadEndpoint
|
||||
|
||||
// Save objectId (not presigned URL!)
|
||||
onChange(response.objectId)
|
||||
|
||||
// Use presigned URL for preview
|
||||
setPreviewUrl(response.url)
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error)
|
||||
alert('Failed to upload image. Please try again.')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleFileSelect(file)
|
||||
}
|
||||
// Reset input value to allow selecting the same file again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
if (disabled) return
|
||||
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
handleFileSelect(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange(undefined)
|
||||
setPreviewUrl(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled && !isUploading) {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && <Label>{label}</Label>}
|
||||
|
||||
{value || previewUrl ? (
|
||||
<div className="relative">
|
||||
<div className="relative w-full border rounded-lg overflow-hidden bg-muted">
|
||||
{isUploading ? (
|
||||
<div className="w-full h-48 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="w-full h-48 object-contain cursor-pointer"
|
||||
onClick={handleClick}
|
||||
onError={() => {
|
||||
// If presigned URL fails, clear preview
|
||||
setPreviewUrl(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-48 flex items-center justify-center text-muted-foreground">
|
||||
Image loaded (preview unavailable)
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove()
|
||||
}}
|
||||
disabled={disabled || isUploading}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{isUploading ? 'Uploading...' : 'Click image to change'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
} ${disabled || isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
disabled={disabled || isUploading}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center space-y-2">
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ImageIcon className="h-10 w-10 text-muted-foreground" />
|
||||
)}
|
||||
<div className="text-sm">
|
||||
{isUploading ? (
|
||||
<span className="text-muted-foreground">Uploading...</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-primary font-medium">Click to upload</span> or drag and drop
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PNG, JPG, WEBP, GIF up to {maxSizeMB}MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
admin/src/components/ui/input.tsx
Normal file
24
admin/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
24
admin/src/components/ui/label.tsx
Normal file
24
admin/src/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 "@/lib/utils"
|
||||
|
||||
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 }
|
||||
160
admin/src/components/ui/select.tsx
Normal file
160
admin/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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 origin-[--radix-select-content-transform-origin]",
|
||||
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}
|
||||
{...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)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
25
admin/src/components/ui/sonner.tsx
Normal file
25
admin/src/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
117
admin/src/components/ui/table.tsx
Normal file
117
admin/src/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
23
admin/src/components/ui/textarea.tsx
Normal file
23
admin/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
59
admin/src/index.css
Normal file
59
admin/src/index.css
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
20
admin/src/main.tsx
Normal file
20
admin/src/main.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
721
admin/src/pages/CardsPage.tsx
Normal file
721
admin/src/pages/CardsPage.tsx
Normal file
|
|
@ -0,0 +1,721 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { cardsApi, isCardsApiError } from '@/api/cards'
|
||||
import { upsertCardWithOptionalVoice } from '@/api/cardsWithVoice'
|
||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||
import type { GameCardDto, PaginatedResponse } from '@/types/models'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { BulkCardUpload } from '@/components/BulkCardUpload'
|
||||
import { BulkCardEditor } from '@/components/BulkCardEditor'
|
||||
import { CardEditorPreview } from '@/components/CardEditorPreview'
|
||||
import { packsApi } from '@/api/packs'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload, List, Grid } from 'lucide-react'
|
||||
|
||||
type ViewMode = 'list' | 'grid'
|
||||
|
||||
export default function CardsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
||||
const [selectedCard, setSelectedCard] = useState<GameCardDto | null>(null)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [cardToDelete, setCardToDelete] = useState<GameCardDto | null>(null)
|
||||
const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false)
|
||||
const [isBulkEditorOpen, setIsBulkEditorOpen] = useState(false)
|
||||
const [uploadedImages, setUploadedImages] = useState<Array<{
|
||||
id: string
|
||||
file: File
|
||||
preview: string
|
||||
objectId: string // Object ID in MinIO (UUID)
|
||||
presignedUrl?: string // Presigned URL for preview
|
||||
}>>([])
|
||||
const [bulkUploadPackId, setBulkUploadPackId] = useState<string | undefined>(undefined)
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
packId: '',
|
||||
original: '',
|
||||
translation: '',
|
||||
mnemo: '',
|
||||
transcription: '',
|
||||
transcriptionMnemo: '',
|
||||
back: '',
|
||||
image: undefined as string | undefined,
|
||||
imageBack: undefined as string | undefined,
|
||||
voice: undefined as string | undefined,
|
||||
voiceLanguage: 'en',
|
||||
})
|
||||
|
||||
const limit = 20
|
||||
|
||||
// Fetch cards
|
||||
const { data, isLoading, error } = useQuery<PaginatedResponse<GameCardDto>>({
|
||||
queryKey: ['cards', page, search],
|
||||
queryFn: () => cardsApi.getCards({ page, limit, search }),
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on client errors (4xx)
|
||||
if (isCardsApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||
return false
|
||||
}
|
||||
return failureCount < 2
|
||||
},
|
||||
})
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: { card: GameCardDto; voice?: { voiceUrl: string; language: string } }) =>
|
||||
upsertCardWithOptionalVoice(payload.card, payload.voice),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||
toast.success('Card created successfully')
|
||||
closeDialog()
|
||||
// Update selectedCard with the new ID from response
|
||||
if (response?.card?.id) {
|
||||
setSelectedCard(response.card)
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'create', 'card')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error creating card:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: { card: GameCardDto; voice?: { voiceUrl: string; language: string } }) =>
|
||||
upsertCardWithOptionalVoice(payload.card, payload.voice),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||
toast.success('Card updated successfully')
|
||||
closeDialog()
|
||||
// Update selectedCard with the updated card from response
|
||||
if (response?.card) {
|
||||
setSelectedCard(response.card)
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'update', 'card')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error updating card:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (cardId: string) => cardsApi.deleteCard(cardId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||
toast.success('Card deleted successfully')
|
||||
setIsDeleteDialogOpen(false)
|
||||
setCardToDelete(null)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'delete', 'card')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error deleting card:', error)
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch packs for packId selection
|
||||
const { data: packsData } = useQuery({
|
||||
queryKey: ['packs', 1, 100, ''],
|
||||
queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '' }),
|
||||
})
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setSelectedCard(null)
|
||||
setFormData({
|
||||
packId: '',
|
||||
original: '',
|
||||
translation: '',
|
||||
mnemo: '',
|
||||
transcription: '',
|
||||
transcriptionMnemo: '',
|
||||
back: '',
|
||||
image: undefined,
|
||||
imageBack: undefined,
|
||||
voice: undefined,
|
||||
voiceLanguage: 'en',
|
||||
})
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (card: GameCardDto) => {
|
||||
setSelectedCard(card)
|
||||
setFormData({
|
||||
packId: card.packId ?? '',
|
||||
original: card.original || '',
|
||||
translation: card.translation || '',
|
||||
mnemo: card.mnemo || '',
|
||||
transcription: card.transcription || '',
|
||||
transcriptionMnemo: card.transcriptionMnemo || '',
|
||||
back: card.back || '',
|
||||
image: card.image,
|
||||
imageBack: card.imageBack,
|
||||
voice: undefined,
|
||||
voiceLanguage: 'en',
|
||||
})
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsDialogOpen(false)
|
||||
setSelectedCard(null)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.original.trim() || !formData.translation.trim() || !formData.mnemo.trim()) {
|
||||
toast.error('Original, translation and mnemo are required')
|
||||
return
|
||||
}
|
||||
|
||||
// Determine if this is an update or create
|
||||
// Check if id exists and is not empty (handle both string and number)
|
||||
const idStr = selectedCard?.id != null ? String(selectedCard.id) : ''
|
||||
const hasValidId = idStr !== '' && idStr.trim() !== '' && idStr !== '0'
|
||||
const isUpdate = selectedCard !== null && hasValidId
|
||||
|
||||
const cardData: GameCardDto = {
|
||||
id: isUpdate ? String(selectedCard.id) : null,
|
||||
packId: formData.packId.trim() || undefined,
|
||||
original: formData.original.trim(),
|
||||
translation: formData.translation.trim(),
|
||||
mnemo: formData.mnemo.trim(),
|
||||
transcription: formData.transcription.trim() || undefined,
|
||||
transcriptionMnemo: formData.transcriptionMnemo.trim() || undefined,
|
||||
back: formData.back.trim() || undefined,
|
||||
image: formData.image || undefined,
|
||||
imageBack: formData.imageBack || undefined,
|
||||
}
|
||||
|
||||
const voice =
|
||||
formData.voice && formData.voice.trim()
|
||||
? { voiceUrl: formData.voice, language: formData.voiceLanguage || 'en' }
|
||||
: undefined
|
||||
|
||||
if (isUpdate) {
|
||||
updateMutation.mutate({ card: cardData, voice })
|
||||
} else {
|
||||
createMutation.mutate({ card: cardData, voice })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (card: GameCardDto) => {
|
||||
setCardToDelete(card)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (cardToDelete && cardToDelete.id != null && cardToDelete.id !== '') {
|
||||
// Ensure id is a string
|
||||
const cardId = String(cardToDelete.id)
|
||||
deleteMutation.mutate(cardId)
|
||||
} else {
|
||||
toast.error('Invalid card ID')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value)
|
||||
setPage(1) // Reset to first page when searching
|
||||
}
|
||||
|
||||
// Group cards by pack
|
||||
const groupedCards = data?.items.reduce((acc, card) => {
|
||||
const packId = card.packId || '__no_pack__'
|
||||
if (!acc[packId]) {
|
||||
acc[packId] = []
|
||||
}
|
||||
acc[packId].push(card)
|
||||
return acc
|
||||
}, {} as Record<string, GameCardDto[]>) || {}
|
||||
|
||||
// Get pack name by ID
|
||||
const getPackName = (packId: string): string => {
|
||||
if (packId === '__no_pack__') {
|
||||
return 'No Pack'
|
||||
}
|
||||
const pack = packsData?.items.find(p => p.id === packId)
|
||||
return pack?.title || packId
|
||||
}
|
||||
|
||||
// Get pack color by ID
|
||||
const getPackColor = (packId: string): string | undefined => {
|
||||
if (!packId || packId === '__no_pack__') {
|
||||
return undefined
|
||||
}
|
||||
const pack = packsData?.items.find(p => p.id === packId)
|
||||
return pack?.color
|
||||
}
|
||||
|
||||
// Get image source - handles presigned URLs, base64, and legacy URLs
|
||||
const getImageSrc = (card: GameCardDto, isBack = false): string | undefined => {
|
||||
// Prefer presigned URL from backend if available
|
||||
const presignedUrl = isBack ? card.imageBackUrl : card.imageUrl
|
||||
if (presignedUrl) {
|
||||
return presignedUrl
|
||||
}
|
||||
|
||||
// Fallback to objectId/image field
|
||||
const image = isBack ? card.imageBack : card.image
|
||||
if (!image) return undefined
|
||||
|
||||
// If it's already a data URL or http/https URL, return as is
|
||||
if (image.startsWith('data:') || image.startsWith('http://') || image.startsWith('https://')) {
|
||||
return image
|
||||
}
|
||||
|
||||
// If it's a relative API path, prepend the base URL
|
||||
if (image.startsWith('/api/')) {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'https://api.mnemo-cards.online'
|
||||
return `${baseUrl}${image}`
|
||||
}
|
||||
|
||||
// If it's a UUID (objectId), we need to fetch presigned URL
|
||||
// For now, return undefined - ImageUpload component will handle fetching
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(image)
|
||||
if (isUuid) {
|
||||
return undefined // Will be handled by ImageUpload component
|
||||
}
|
||||
|
||||
// Otherwise, assume it's base64 and add the data URL prefix
|
||||
return `data:image/png;base64,${image}`
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: formatApiError(error)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Cards Management</h1>
|
||||
<p className="text-muted-foreground">Error loading cards</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-red-500 font-medium">Failed to load cards</p>
|
||||
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['cards'] })}
|
||||
className="mt-2"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Cards Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View, create, edit and delete game cards
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" onClick={() => setIsBulkUploadOpen(true)}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Bulk Upload
|
||||
</Button>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Card
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search cards..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cards Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>All Cards ({data?.total || 0})</CardTitle>
|
||||
<CardDescription>
|
||||
Manage game cards in the system
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Loading cards...</div>
|
||||
) : (
|
||||
<>
|
||||
{viewMode === 'list' ? (
|
||||
<>
|
||||
{Object.entries(groupedCards).map(([packId, cards]) => (
|
||||
<div key={packId} className="mb-8">
|
||||
<div className="bg-muted/50 px-4 py-3 rounded-t-lg border-b-2 border-primary/20 mb-0">
|
||||
<h3 className="text-xl font-bold">
|
||||
{getPackName(packId)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{cards.length} {cards.length === 1 ? 'card' : 'cards'}
|
||||
</p>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Original</TableHead>
|
||||
<TableHead>Translation</TableHead>
|
||||
<TableHead>Mnemo</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cards.map((card) => (
|
||||
<TableRow key={card.id || `temp-${card.original}`}>
|
||||
<TableCell>{card.id || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{card.original}</TableCell>
|
||||
<TableCell>{card.translation}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{card.mnemo}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(card)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(card)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{Object.entries(groupedCards).map(([packId, cards]) => (
|
||||
<div key={packId} className="mb-8">
|
||||
<div className="bg-muted/50 px-4 py-3 rounded-lg border-b-2 border-primary/20 mb-4">
|
||||
<h3 className="text-xl font-bold">
|
||||
{getPackName(packId)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{cards.length} {cards.length === 1 ? 'card' : 'cards'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.id || `temp-${card.original}`}
|
||||
className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow cursor-pointer group"
|
||||
onClick={() => openEditDialog(card)}
|
||||
>
|
||||
<div className="aspect-square bg-muted relative">
|
||||
{getImageSrc(card) ? (
|
||||
<img
|
||||
src={getImageSrc(card)}
|
||||
alt={card.original}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex space-x-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openEditDialog(card)
|
||||
}}
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(card)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p className="font-medium text-sm truncate" title={card.original}>
|
||||
{card.original}
|
||||
</p>
|
||||
{card.translation && (
|
||||
<p className="text-xs text-muted-foreground truncate" title={card.translation}>
|
||||
{card.translation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} cards
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[95vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedCard ? 'Edit Card' : 'Create New Card'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedCard ? 'Update the card information' : 'Add a new card to the system'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="packId">Pack ID (optional)</Label>
|
||||
<Select
|
||||
value={formData.packId || undefined}
|
||||
onValueChange={(value: string) => {
|
||||
// If "none" is selected, clear the packId
|
||||
const packId = value === '__none__' ? '' : value
|
||||
setFormData(prev => ({ ...prev, packId }))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a pack (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{packsData?.items && packsData.items.length > 0 ? (
|
||||
packsData.items.map((pack) => (
|
||||
<SelectItem key={pack.id} value={pack.id}>
|
||||
{pack.title || pack.id}
|
||||
</SelectItem>
|
||||
))
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<CardEditorPreview
|
||||
formData={formData}
|
||||
onFormDataChange={(updates) => setFormData(prev => ({ ...prev, ...updates }))}
|
||||
cardId={selectedCard?.id ? String(selectedCard.id) : undefined}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
packColor={getPackColor(formData.packId)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{createMutation.isPending || updateMutation.isPending ? 'Saving...' : (selectedCard ? 'Update' : 'Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Card</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the card "{cardToDelete?.original}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Bulk Upload Dialog */}
|
||||
{isBulkUploadOpen && !isBulkEditorOpen && (
|
||||
<Dialog open={isBulkUploadOpen} onOpenChange={setIsBulkUploadOpen}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||||
<BulkCardUpload
|
||||
onImagesUploaded={(images, packId) => {
|
||||
setUploadedImages(images)
|
||||
setBulkUploadPackId(packId)
|
||||
setIsBulkUploadOpen(false)
|
||||
setIsBulkEditorOpen(true)
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsBulkUploadOpen(false)
|
||||
setUploadedImages([])
|
||||
setBulkUploadPackId(undefined)
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Bulk Editor Dialog */}
|
||||
{isBulkEditorOpen && (
|
||||
<Dialog open={isBulkEditorOpen} onOpenChange={setIsBulkEditorOpen}>
|
||||
<DialogContent className="sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
|
||||
<BulkCardEditor
|
||||
images={uploadedImages}
|
||||
defaultPackId={bulkUploadPackId}
|
||||
onComplete={() => {
|
||||
setIsBulkEditorOpen(false)
|
||||
setUploadedImages([])
|
||||
setBulkUploadPackId(undefined)
|
||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||
toast.success('All cards saved successfully!')
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkEditorOpen(false)
|
||||
setUploadedImages([])
|
||||
setBulkUploadPackId(undefined)
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
249
admin/src/pages/DashboardPage.tsx
Normal file
249
admin/src/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Users, FileText, Package, DollarSign, TrendingUp, Clock } from 'lucide-react'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { analyticsApi, type DashboardData, type ChartDataPoint } from '@/api/analytics'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { isAuthenticated, token } = useAuthStore()
|
||||
|
||||
// Only make requests if authenticated and token exists
|
||||
const isReady = isAuthenticated && !!token && !!localStorage.getItem('admin_token')
|
||||
|
||||
const { data: dashboardData, isLoading: dashboardLoading } = useQuery<DashboardData>({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: analyticsApi.getDashboard,
|
||||
enabled: isReady,
|
||||
})
|
||||
|
||||
const { data: usersChartData } = useQuery<{ data: ChartDataPoint[] }>({
|
||||
queryKey: ['users-chart'],
|
||||
queryFn: analyticsApi.getUsersChart,
|
||||
enabled: isReady,
|
||||
})
|
||||
|
||||
const { data: revenueChartData } = useQuery<{ data: ChartDataPoint[] }>({
|
||||
queryKey: ['revenue-chart'],
|
||||
queryFn: analyticsApi.getRevenueChart,
|
||||
enabled: isReady,
|
||||
})
|
||||
|
||||
if (dashboardLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome to Mnemo Cards Admin Panel
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome to Mnemo Cards Admin Panel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{dashboardData?.stats.users || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Registered users
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Cards</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{dashboardData?.stats.cards || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Game cards created
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Packs</CardTitle>
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{dashboardData?.stats.enabledPacks || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
of {dashboardData?.stats.packs || 0} total packs
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Payments</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{dashboardData?.stats.payments || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Successful transactions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Registrations</CardTitle>
|
||||
<CardDescription>
|
||||
Daily user registrations for the last 30 days
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{usersChartData?.data && (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={usersChartData.data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(value) => new Date(value).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||
formatter={(value: number) => [value, 'Registrations']}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="registrations"
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#8884d8' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenue Trend</CardTitle>
|
||||
<CardDescription>
|
||||
Daily revenue for the last 30 days
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{revenueChartData?.data && (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={revenueChartData.data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(value) => new Date(value).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||
formatter={(value: number) => [`$${value}`, 'Revenue']}
|
||||
/>
|
||||
<Bar dataKey="revenue" fill="#82ca9d" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Users and Top Packs */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Recent Users
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Latest user registrations
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{dashboardData?.recentUsers.slice(0, 5).map((user) => (
|
||||
<div key={user.id} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{user.name || 'No name'}</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email || 'No email'}</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
)) || (
|
||||
<p className="text-muted-foreground">No recent users</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Popular Packs
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Most popular card packs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{dashboardData?.topPacks.map((pack) => (
|
||||
<div key={pack.id} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{pack.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{pack.cards} cards</p>
|
||||
</div>
|
||||
<Badge variant={pack.enabled ? "default" : "secondary"}>
|
||||
{pack.enabled ? 'Active' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
)) || (
|
||||
<p className="text-muted-foreground">No packs available</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
438
admin/src/pages/LoginPage.tsx
Normal file
438
admin/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { CodeStatusResponse } from '@/types/models'
|
||||
|
||||
const TELEGRAM_BOT_USERNAME = 'mnemo_cards_bot'
|
||||
const TELEGRAM_BOT_DEEP_LINK_BASE = `https://t.me/${TELEGRAM_BOT_USERNAME}`
|
||||
|
||||
function telegramBotDeepLink(code: string): string {
|
||||
return `${TELEGRAM_BOT_DEEP_LINK_BASE}?start=${code}`
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuthStore()
|
||||
const [code, setCode] = useState('')
|
||||
const [codeStatus, setCodeStatus] = useState<CodeStatusResponse | null>(null)
|
||||
const [countdown, setCountdown] = useState<number>(0)
|
||||
const [isVerifying, setIsVerifying] = useState(false)
|
||||
const statusPollIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Request code mutation
|
||||
const requestCodeMutation = useMutation({
|
||||
mutationFn: () => authApi.requestCode(),
|
||||
onSuccess: (data) => {
|
||||
if (data.success && data.code) {
|
||||
setCode(data.code)
|
||||
setCodeStatus({
|
||||
success: true,
|
||||
code: data.code,
|
||||
status: data.status || 'pending',
|
||||
expiresAt: data.expiresAt,
|
||||
remainingSeconds: data.remainingSeconds || 600,
|
||||
isClaimed: false,
|
||||
isUsed: false,
|
||||
})
|
||||
toast.success('Code generated! Send it to the Telegram bot.')
|
||||
|
||||
// Open Telegram bot
|
||||
window.open(telegramBotDeepLink(data.code), '_blank')
|
||||
|
||||
// Start polling for status
|
||||
startStatusPolling(data.code)
|
||||
startCountdown(data.remainingSeconds || 600)
|
||||
} else {
|
||||
toast.error(data.message || 'Failed to generate code')
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
(error as { message?: string })?.message ||
|
||||
'Failed to generate code'
|
||||
toast.error(errorMessage)
|
||||
},
|
||||
})
|
||||
|
||||
// Verify code mutation
|
||||
const verifyCodeMutation = useMutation({
|
||||
mutationFn: (code: string) => authApi.verifyCode(code),
|
||||
onSuccess: (data) => {
|
||||
console.log('Verify code response:', data)
|
||||
|
||||
// Check if response has success field and it's false
|
||||
if (data.success === false) {
|
||||
const errorMsg = data.message || 'Verification failed'
|
||||
console.error('Verification failed:', errorMsg)
|
||||
toast.error(errorMsg)
|
||||
setIsVerifying(false)
|
||||
// Don't restart polling if verification failed - user needs to generate new code
|
||||
return
|
||||
}
|
||||
|
||||
// Validate response structure
|
||||
if (!data.token || !data.user) {
|
||||
console.error('Invalid response structure:', data)
|
||||
toast.error('Invalid response from server')
|
||||
setIsVerifying(false)
|
||||
// Don't restart polling - this is a server error
|
||||
return
|
||||
}
|
||||
|
||||
// Stop polling
|
||||
stopStatusPolling()
|
||||
stopCountdown()
|
||||
setIsVerifying(false)
|
||||
|
||||
console.log('Login successful, setting token and user')
|
||||
console.log('Token received:', data.token ? `Token exists, length: ${data.token.length}` : 'NO TOKEN!')
|
||||
console.log('RefreshToken received:', data.refreshToken ? `Token exists, length: ${data.refreshToken.length}` : 'NO REFRESH TOKEN!')
|
||||
console.log('ExpiresIn:', data.expiresIn)
|
||||
console.log('User received:', data.user)
|
||||
|
||||
// Save token with refresh token and expiration
|
||||
login(data.token, data.refreshToken ?? null, data.expiresIn ?? null, data.user)
|
||||
|
||||
// Verify token was saved
|
||||
const savedToken = localStorage.getItem('admin_token')
|
||||
const savedRefreshToken = localStorage.getItem('admin_refresh_token')
|
||||
console.log('Token saved verification:', savedToken ? `Token exists, length: ${savedToken.length}` : 'Token NOT saved!')
|
||||
console.log('RefreshToken saved verification:', savedRefreshToken ? `Token exists, length: ${savedRefreshToken.length}` : 'RefreshToken NOT saved!')
|
||||
|
||||
if (!savedToken) {
|
||||
console.error('CRITICAL: Token was not saved to localStorage!')
|
||||
toast.error('Failed to save authentication token')
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Welcome back!')
|
||||
|
||||
// Small delay to ensure token is available for subsequent requests
|
||||
setTimeout(() => {
|
||||
navigate('/')
|
||||
}, 100)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('Verify code error:', error)
|
||||
setIsVerifying(false)
|
||||
const errorResponse = error as { response?: { data?: { message?: string }; status?: number } }
|
||||
const errorMessage =
|
||||
errorResponse?.response?.data?.message ||
|
||||
(error as { message?: string })?.message ||
|
||||
'Invalid code'
|
||||
|
||||
// If 403, it means code was claimed but user is not admin
|
||||
if (errorResponse?.response?.status === 403) {
|
||||
toast.error('Access denied: You are not an admin. Code was claimed but verification failed.')
|
||||
stopStatusPolling()
|
||||
stopCountdown()
|
||||
} else {
|
||||
toast.error(errorMessage)
|
||||
// Restart polling in case of other errors
|
||||
if (code) {
|
||||
startStatusPolling(code)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const startStatusPolling = (code: string) => {
|
||||
stopStatusPolling()
|
||||
|
||||
// Poll immediately
|
||||
checkCodeStatus(code)
|
||||
|
||||
// Then poll every 3 seconds
|
||||
statusPollIntervalRef.current = setInterval(() => {
|
||||
checkCodeStatus(code)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const stopStatusPolling = () => {
|
||||
if (statusPollIntervalRef.current) {
|
||||
clearInterval(statusPollIntervalRef.current)
|
||||
statusPollIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const checkCodeStatus = async (codeToCheck: string) => {
|
||||
try {
|
||||
const status = await authApi.getCodeStatus(codeToCheck)
|
||||
|
||||
// Check if response is successful
|
||||
if (!status.success) {
|
||||
console.error('Code status check failed:', status.message || status.error)
|
||||
if (status.error === 'NotFound') {
|
||||
stopStatusPolling()
|
||||
stopCountdown()
|
||||
toast.error('Code not found. Please generate a new one.')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setCodeStatus(status)
|
||||
|
||||
console.log('Code status:', status.status, 'isClaimed:', status.isClaimed, 'isUsed:', status.isUsed)
|
||||
|
||||
if (status.status === 'claimed' && !status.isUsed && !isVerifying) {
|
||||
// Code is claimed, automatically verify and login
|
||||
console.log('Code claimed, attempting login...')
|
||||
setIsVerifying(true)
|
||||
stopStatusPolling()
|
||||
verifyCodeMutation.mutate(codeToCheck)
|
||||
} else if (status.status === 'expired' || (status.remainingSeconds !== undefined && status.remainingSeconds <= 0)) {
|
||||
stopStatusPolling()
|
||||
stopCountdown()
|
||||
toast.error('Code expired. Please generate a new one.')
|
||||
} else if (status.isUsed) {
|
||||
stopStatusPolling()
|
||||
stopCountdown()
|
||||
toast.info('Code already used')
|
||||
} else {
|
||||
// Update countdown
|
||||
if (status.remainingSeconds !== undefined && status.remainingSeconds > 0) {
|
||||
setCountdown(status.remainingSeconds)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check code status:', error)
|
||||
// Don't stop polling on network errors, just log
|
||||
}
|
||||
}
|
||||
|
||||
const startCountdown = (initialSeconds: number) => {
|
||||
stopCountdown()
|
||||
setCountdown(initialSeconds)
|
||||
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
stopCountdown()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const stopCountdown = () => {
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current)
|
||||
countdownIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopStatusPolling()
|
||||
stopCountdown()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRequestCode = () => {
|
||||
requestCodeMutation.mutate()
|
||||
}
|
||||
|
||||
const handleVerifyCode = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const trimmedCode = code.trim()
|
||||
|
||||
if (!trimmedCode) {
|
||||
toast.error('Please enter verification code')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate code format: should be 6 digits
|
||||
if (!/^\d{6}$/.test(trimmedCode)) {
|
||||
toast.error('Code must be 6 digits')
|
||||
return
|
||||
}
|
||||
|
||||
verifyCodeMutation.mutate(trimmedCode)
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
stopStatusPolling()
|
||||
stopCountdown()
|
||||
setIsVerifying(false)
|
||||
setCode('')
|
||||
setCodeStatus(null)
|
||||
setCountdown(0)
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'claimed':
|
||||
return 'Code confirmed. Logging in...'
|
||||
case 'used':
|
||||
return 'Code already used'
|
||||
case 'expired':
|
||||
return 'Code expired'
|
||||
case 'pending':
|
||||
default:
|
||||
return 'Waiting for code to be sent in bot...'
|
||||
}
|
||||
}
|
||||
|
||||
const isCodeActive = codeStatus &&
|
||||
codeStatus.status !== 'expired' &&
|
||||
codeStatus.status !== 'used' &&
|
||||
countdown > 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
Mnemo Cards Admin
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{codeStatus
|
||||
? 'Send the code to the Telegram bot to continue'
|
||||
: 'Click the button below to generate a verification code'
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!codeStatus ? (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
onClick={handleRequestCode}
|
||||
className="w-full"
|
||||
disabled={requestCodeMutation.isPending}
|
||||
>
|
||||
{requestCodeMutation.isPending ? 'Generating...' : 'Generate Code'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Code Display */}
|
||||
<div className="space-y-2">
|
||||
<Label>Verification Code</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={code}
|
||||
readOnly
|
||||
className="font-mono text-lg font-bold text-center"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(code)
|
||||
toast.success('Code copied to clipboard!')
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Card */}
|
||||
{codeStatus && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg border">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Status:</span>
|
||||
<span className={`text-sm font-semibold ${
|
||||
codeStatus.status === 'claimed' ? (isVerifying ? 'text-blue-600' : 'text-green-600') :
|
||||
codeStatus.status === 'expired' || codeStatus.status === 'used' ? 'text-red-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{isVerifying ? 'Verifying and logging in...' : getStatusLabel(codeStatus.status)}
|
||||
</span>
|
||||
</div>
|
||||
{isCodeActive && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Time remaining:</span>
|
||||
<span className="text-sm font-mono font-semibold">
|
||||
{formatTime(countdown)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Debug info */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="mt-2 pt-2 border-t text-xs text-gray-500">
|
||||
<div>Status: {codeStatus.status}</div>
|
||||
<div>Claimed: {codeStatus.isClaimed ? 'Yes' : 'No'}</div>
|
||||
<div>Used: {codeStatus.isUsed ? 'Yes' : 'No'}</div>
|
||||
<div>Verifying: {isVerifying ? 'Yes' : 'No'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Code Input (fallback) */}
|
||||
<form onSubmit={handleVerifyCode} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">Or enter code manually</Label>
|
||||
<Input
|
||||
id="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder="Enter 6-digit code"
|
||||
value={code}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.replace(/\D/g, '')
|
||||
setCode(value)
|
||||
}}
|
||||
maxLength={6}
|
||||
disabled={verifyCodeMutation.isPending}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
disabled={verifyCodeMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
Generate New Code
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={verifyCodeMutation.isPending || !code.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
{verifyCodeMutation.isPending ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Open Bot Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => window.open(telegramBotDeepLink(code), '_blank')}
|
||||
className="w-full"
|
||||
>
|
||||
Open Telegram Bot
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
673
admin/src/pages/PacksPage.tsx
Normal file
673
admin/src/pages/PacksPage.tsx
Normal file
|
|
@ -0,0 +1,673 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { packsApi, isPacksApiError } from '@/api/packs'
|
||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { ImageUpload } from '@/components/ui/image-upload'
|
||||
import { ColorPaletteInput } from '@/components/ui/color-palette-input'
|
||||
import { PackCardsManager } from '@/components/PackCardsManager'
|
||||
import { PackTestsManager } from '@/components/PackTestsManager'
|
||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
export default function PacksPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [showDisabled, setShowDisabled] = useState(false)
|
||||
const [selectedPack, setSelectedPack] = useState<EditCardPackDto | null>(null)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [packToDelete, setPackToDelete] = useState<CardPackPreviewDto | null>(null)
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
color: '',
|
||||
enabled: true,
|
||||
googlePlayId: '',
|
||||
rustoreId: '',
|
||||
appStoreId: '',
|
||||
price: '',
|
||||
order: 0,
|
||||
version: '',
|
||||
size: 0,
|
||||
cover: undefined as string | undefined,
|
||||
})
|
||||
|
||||
// Card management state
|
||||
const [currentCardIds, setCurrentCardIds] = useState<string[]>([])
|
||||
const [cardsToAdd, setCardsToAdd] = useState<string[]>([])
|
||||
const [cardsToRemove, setCardsToRemove] = useState<string[]>([])
|
||||
|
||||
// Test management state
|
||||
const [currentTestIds, setCurrentTestIds] = useState<string[]>([])
|
||||
const [testsToAdd, setTestsToAdd] = useState<string[]>([])
|
||||
const [testsToRemove, setTestsToRemove] = useState<string[]>([])
|
||||
|
||||
const limit = 20
|
||||
|
||||
// Fetch packs
|
||||
const { data, isLoading, error } = useQuery<PaginatedResponse<CardPackPreviewDto>>({
|
||||
queryKey: ['packs', page, search, showDisabled],
|
||||
queryFn: () => packsApi.getPacks({ page, limit, search, showDisabled }),
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on client errors (4xx)
|
||||
if (isPacksApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||
return false
|
||||
}
|
||||
return failureCount < 2
|
||||
},
|
||||
})
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (pack: EditCardPackDto) => packsApi.upsertPack(pack),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['packs'] })
|
||||
toast.success('Pack created successfully')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'create', 'pack')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error creating pack:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (pack: EditCardPackDto) => packsApi.upsertPack(pack),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['packs'] })
|
||||
toast.success('Pack updated successfully')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'update', 'pack')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error updating pack:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (packId: string) => packsApi.deletePack(packId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['packs'] })
|
||||
toast.success('Pack deleted successfully')
|
||||
setIsDeleteDialogOpen(false)
|
||||
setPackToDelete(null)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'delete', 'pack')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error deleting pack:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setSelectedPack(null)
|
||||
setFormData({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
color: '',
|
||||
enabled: true,
|
||||
googlePlayId: '',
|
||||
rustoreId: '',
|
||||
appStoreId: '',
|
||||
price: '',
|
||||
order: 0,
|
||||
version: '',
|
||||
size: 0,
|
||||
cover: undefined,
|
||||
})
|
||||
setCurrentCardIds([])
|
||||
setCardsToAdd([])
|
||||
setCardsToRemove([])
|
||||
setCurrentTestIds([])
|
||||
setTestsToAdd([])
|
||||
setTestsToRemove([])
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = async (pack: CardPackPreviewDto) => {
|
||||
try {
|
||||
const fullPack = await packsApi.getPack(pack.id)
|
||||
setSelectedPack(fullPack)
|
||||
setFormData({
|
||||
title: fullPack.title || '',
|
||||
subtitle: fullPack.subtitle || '',
|
||||
description: fullPack.description || '',
|
||||
color: fullPack.color || '',
|
||||
enabled: fullPack.enabled ?? true,
|
||||
googlePlayId: fullPack.googlePlayId || '',
|
||||
rustoreId: fullPack.rustoreId || '',
|
||||
appStoreId: fullPack.appStoreId || '',
|
||||
price: fullPack.price || '',
|
||||
order: fullPack.order || 0,
|
||||
version: fullPack.version || '',
|
||||
size: fullPack.size || 0,
|
||||
cover: fullPack.cover,
|
||||
})
|
||||
// Initialize current card IDs from addCardIds (which contains all cards in pack)
|
||||
const cardIds = fullPack.addCardIds?.map((id) => id.toString()) || []
|
||||
setCurrentCardIds(cardIds)
|
||||
setCardsToAdd([])
|
||||
setCardsToRemove([])
|
||||
|
||||
// Initialize current test IDs from addTestIds (which contains all tests in pack)
|
||||
const testIds = fullPack.addTestIds?.map((id) => id.toString()) || []
|
||||
setCurrentTestIds(testIds)
|
||||
setTestsToAdd([])
|
||||
setTestsToRemove([])
|
||||
|
||||
setIsDialogOpen(true)
|
||||
} catch (error) {
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'load', `pack "${pack.id}"`)
|
||||
toast.error(errorMessage)
|
||||
console.error('Error loading pack details:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsDialogOpen(false)
|
||||
setSelectedPack(null)
|
||||
setCurrentCardIds([])
|
||||
setCardsToAdd([])
|
||||
setCardsToRemove([])
|
||||
setCurrentTestIds([])
|
||||
setTestsToAdd([])
|
||||
setTestsToRemove([])
|
||||
}
|
||||
|
||||
const handleCardsChange = (addIds: string[], removeIds: string[]) => {
|
||||
setCardsToAdd(addIds)
|
||||
setCardsToRemove(removeIds)
|
||||
}
|
||||
|
||||
const handleTestsChange = (addIds: string[], removeIds: string[]) => {
|
||||
setTestsToAdd(addIds)
|
||||
setTestsToRemove(removeIds)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
toast.error('Title is required')
|
||||
return
|
||||
}
|
||||
|
||||
const packData: EditCardPackDto = {
|
||||
// Only include id for updates, not for new packs
|
||||
...(selectedPack && { id: selectedPack.id }),
|
||||
title: formData.title.trim(),
|
||||
subtitle: formData.subtitle.trim() || undefined,
|
||||
description: formData.description.trim() || undefined,
|
||||
color: formData.color.trim() || undefined,
|
||||
enabled: formData.enabled,
|
||||
googlePlayId: formData.googlePlayId.trim() || undefined,
|
||||
rustoreId: formData.rustoreId.trim() || undefined,
|
||||
appStoreId: formData.appStoreId.trim() || undefined,
|
||||
price: formData.price.trim() || undefined,
|
||||
order: formData.order,
|
||||
version: formData.version.trim() || undefined,
|
||||
size: formData.size > 0 ? formData.size : undefined,
|
||||
cover: formData.cover || undefined,
|
||||
addCardIds: cardsToAdd.length > 0 ? cardsToAdd : undefined,
|
||||
removeCardIds: cardsToRemove.length > 0 ? cardsToRemove : undefined,
|
||||
addTestIds: testsToAdd.length > 0 ? testsToAdd : undefined,
|
||||
removeTestIds: testsToRemove.length > 0 ? testsToRemove : undefined,
|
||||
}
|
||||
|
||||
if (selectedPack) {
|
||||
updateMutation.mutate(packData)
|
||||
} else {
|
||||
createMutation.mutate(packData)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (pack: CardPackPreviewDto) => {
|
||||
setPackToDelete(pack)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (packToDelete) {
|
||||
deleteMutation.mutate(packToDelete.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value)
|
||||
setPage(1) // Reset to first page when searching
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: formatApiError(error)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Packs Management</h1>
|
||||
<p className="text-muted-foreground">Error loading packs</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-red-500 font-medium">Failed to load packs</p>
|
||||
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['packs'] })}
|
||||
className="mt-2"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Packs Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View, create, edit and delete card packs
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Pack
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search packs..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showDisabled"
|
||||
checked={showDisabled}
|
||||
onCheckedChange={(checked) => setShowDisabled(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="showDisabled">Show disabled packs</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Packs Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Packs ({data?.total || 0})</CardTitle>
|
||||
<CardDescription>
|
||||
Manage card packs and their contents
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Loading packs...</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Cards</TableHead>
|
||||
<TableHead>Enabled</TableHead>
|
||||
<TableHead>Order</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.items.map((pack) => (
|
||||
<TableRow key={pack.id}>
|
||||
<TableCell className="font-mono text-sm">{pack.id}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{pack.title}</div>
|
||||
{pack.subtitle && (
|
||||
<div className="text-sm text-muted-foreground">{pack.subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{pack.cards}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
pack.enabled
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{pack.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{pack.order ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(pack)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(pack)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} packs
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedPack ? 'Edit Pack' : 'Create New Pack'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedPack ? 'Update the pack information' : 'Add a new pack to the system'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="Pack title"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subtitle">Subtitle</Label>
|
||||
<Input
|
||||
id="subtitle"
|
||||
value={formData.subtitle}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, subtitle: e.target.value }))}
|
||||
placeholder="Pack subtitle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Pack description"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">Color</Label>
|
||||
<ColorPaletteInput
|
||||
id="color"
|
||||
value={formData.color}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, color: value }))}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
placeholder="#FF0000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input
|
||||
id="order"
|
||||
type="number"
|
||||
value={formData.order}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, order: parseInt(e.target.value) || 0 }))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enabled"
|
||||
checked={formData.enabled}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, enabled: checked as boolean }))}
|
||||
/>
|
||||
<Label htmlFor="enabled">Enabled</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Store IDs</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="googlePlayId" className="text-xs">Google Play</Label>
|
||||
<Input
|
||||
id="googlePlayId"
|
||||
value={formData.googlePlayId}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, googlePlayId: e.target.value }))}
|
||||
placeholder="com.example.app"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rustoreId" className="text-xs">RuStore</Label>
|
||||
<Input
|
||||
id="rustoreId"
|
||||
value={formData.rustoreId}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, rustoreId: e.target.value }))}
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="appStoreId" className="text-xs">App Store</Label>
|
||||
<Input
|
||||
id="appStoreId"
|
||||
value={formData.appStoreId}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, appStoreId: e.target.value }))}
|
||||
placeholder="1234567890"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price">Price</Label>
|
||||
<Input
|
||||
id="price"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, price: e.target.value }))}
|
||||
placeholder="Free, $1.99, etc."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="version">Version</Label>
|
||||
<Input
|
||||
id="version"
|
||||
value={formData.version}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, version: e.target.value }))}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="size">Size</Label>
|
||||
<Input
|
||||
id="size"
|
||||
type="number"
|
||||
value={formData.size}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, size: parseInt(e.target.value) || 0 }))}
|
||||
placeholder="0"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Number of cards in the pack (auto-calculated if not set)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Cover Image"
|
||||
value={formData.cover}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, cover: value }))}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedPack && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<PackCardsManager
|
||||
currentCardIds={currentCardIds}
|
||||
onCardsChange={handleCardsChange}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<PackTestsManager
|
||||
currentTestIds={currentTestIds}
|
||||
onTestsChange={handleTestsChange}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{createMutation.isPending || updateMutation.isPending ? 'Saving...' : (selectedPack ? 'Update' : 'Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Pack</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the pack "{packToDelete?.title}"? This action cannot be undone and will remove all associated cards.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
639
admin/src/pages/TestsPage.tsx
Normal file
639
admin/src/pages/TestsPage.tsx
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { testsApi, isTestsApiError } from '@/api/tests'
|
||||
import { packsApi, isPacksApiError } from '@/api/packs'
|
||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||
import type { TestDto, PaginatedResponse } from '@/types/models'
|
||||
import type { Question } from '@/types/questions'
|
||||
import { questionFromJson, questionToJson } from '@/types/questions'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ColorPaletteInput } from '@/components/ui/color-palette-input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ImageUpload } from '@/components/ui/image-upload'
|
||||
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { TestQuestionsManager } from '@/components/TestQuestionsManager'
|
||||
import { QuestionEditorDialog } from '@/components/QuestionEditorDialog'
|
||||
import { TestPacksManager } from '@/components/TestPacksManager'
|
||||
|
||||
export default function TestsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedTest, setSelectedTest] = useState<TestDto | null>(null)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [testToDelete, setTestToDelete] = useState<TestDto | null>(null)
|
||||
const [isQuestionDialogOpen, setIsQuestionDialogOpen] = useState(false)
|
||||
const [editingQuestion, setEditingQuestion] = useState<{
|
||||
question: Question | null
|
||||
index: number
|
||||
} | null>(null)
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
color: '',
|
||||
cover: undefined as string | undefined,
|
||||
version: '',
|
||||
time: '',
|
||||
timeSubtitle: '',
|
||||
questions: [] as Question[],
|
||||
})
|
||||
|
||||
// Pack-linking state (test ↔ packs)
|
||||
const [currentPackIds, setCurrentPackIds] = useState<string[]>([])
|
||||
const [packsToAdd, setPacksToAdd] = useState<string[]>([])
|
||||
const [packsToRemove, setPacksToRemove] = useState<string[]>([])
|
||||
|
||||
const limit = 20
|
||||
|
||||
// Fetch tests
|
||||
const { data, isLoading, error } = useQuery<PaginatedResponse<TestDto>>({
|
||||
queryKey: ['tests', page, search],
|
||||
queryFn: () => testsApi.getTests({ page, limit, search }),
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on client errors (4xx)
|
||||
if (isTestsApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||
return false
|
||||
}
|
||||
return failureCount < 2
|
||||
},
|
||||
})
|
||||
|
||||
// Mutations
|
||||
const upsertMutation = useMutation({
|
||||
mutationFn: (test: TestDto) => testsApi.upsertTest(test),
|
||||
})
|
||||
|
||||
const syncPacksMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
testId,
|
||||
addPackIds,
|
||||
removePackIds,
|
||||
}: {
|
||||
testId: string
|
||||
addPackIds: string[]
|
||||
removePackIds: string[]
|
||||
}) => {
|
||||
await Promise.all([
|
||||
...addPackIds.map((packId) => packsApi.linkTestToPack(packId, testId)),
|
||||
...removePackIds.map((packId) =>
|
||||
packsApi.unlinkTestFromPack(packId, testId),
|
||||
),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (testId: string) => testsApi.deleteTest(testId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tests'] })
|
||||
toast.success('Test deleted successfully')
|
||||
setIsDeleteDialogOpen(false)
|
||||
setTestToDelete(null)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = isTestsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'delete', 'test')
|
||||
toast.error(errorMessage)
|
||||
console.error('Error deleting test:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setSelectedTest(null)
|
||||
setFormData({
|
||||
name: '',
|
||||
color: '',
|
||||
cover: undefined,
|
||||
version: '',
|
||||
time: '',
|
||||
timeSubtitle: '',
|
||||
questions: [],
|
||||
})
|
||||
setCurrentPackIds([])
|
||||
setPacksToAdd([])
|
||||
setPacksToRemove([])
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = async (test: TestDto) => {
|
||||
try {
|
||||
// Load full test data if we only have preview
|
||||
const fullTest = test.id ? await testsApi.getTest(test.id) : test
|
||||
setSelectedTest(fullTest)
|
||||
|
||||
const packIds = fullTest.packs?.map((p) => String(p.id)) || []
|
||||
setCurrentPackIds(packIds)
|
||||
setPacksToAdd([])
|
||||
setPacksToRemove([])
|
||||
|
||||
// Преобразуем вопросы из JSON в Question объекты
|
||||
const rawQuestions = Array.isArray(fullTest.questions)
|
||||
? fullTest.questions
|
||||
: []
|
||||
|
||||
const questions: Question[] = rawQuestions.map((q: unknown) => {
|
||||
try {
|
||||
return questionFromJson(q)
|
||||
} catch (e) {
|
||||
console.error('Error parsing question:', e, q)
|
||||
return null
|
||||
}
|
||||
}).filter((q): q is Question => q !== null)
|
||||
|
||||
setFormData({
|
||||
name: fullTest.name || '',
|
||||
color: fullTest.color || '',
|
||||
cover: fullTest.cover,
|
||||
version: fullTest.version || '',
|
||||
time: fullTest.time || '',
|
||||
timeSubtitle: fullTest.timeSubtitle || '',
|
||||
questions,
|
||||
})
|
||||
setIsDialogOpen(true)
|
||||
} catch (error) {
|
||||
const errorMessage = isTestsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'load', `test "${test.id}"`)
|
||||
toast.error(errorMessage)
|
||||
console.error('Error loading test details:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsDialogOpen(false)
|
||||
setSelectedTest(null)
|
||||
setCurrentPackIds([])
|
||||
setPacksToAdd([])
|
||||
setPacksToRemove([])
|
||||
}
|
||||
|
||||
const handlePacksChange = useCallback((addIds: string[], removeIds: string[]) => {
|
||||
setPacksToAdd(addIds)
|
||||
setPacksToRemove(removeIds)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Name is required')
|
||||
return
|
||||
}
|
||||
|
||||
// Преобразуем вопросы в JSON формат для отправки на бэкенд
|
||||
const questionsJson = formData.questions.map((q) => questionToJson(q))
|
||||
|
||||
const testData: TestDto = {
|
||||
id: selectedTest?.id,
|
||||
name: formData.name.trim(),
|
||||
color: formData.color.trim() || undefined,
|
||||
cover: formData.cover || undefined,
|
||||
version: formData.version.trim() || undefined,
|
||||
time: formData.time.trim() || undefined,
|
||||
timeSubtitle: formData.timeSubtitle.trim() || undefined,
|
||||
questions: questionsJson,
|
||||
}
|
||||
|
||||
const isUpdate = Boolean(selectedTest?.id)
|
||||
try {
|
||||
const response = await upsertMutation.mutateAsync(testData)
|
||||
const savedTestId = response.test.id ?? testData.id
|
||||
|
||||
if (!savedTestId) {
|
||||
throw new Error('Test was saved but no ID was returned')
|
||||
}
|
||||
|
||||
if (packsToAdd.length > 0 || packsToRemove.length > 0) {
|
||||
await syncPacksMutation.mutateAsync({
|
||||
testId: savedTestId,
|
||||
addPackIds: packsToAdd,
|
||||
removePackIds: packsToRemove,
|
||||
})
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['tests'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['packs'] })
|
||||
|
||||
toast.success(isUpdate ? 'Test updated successfully' : 'Test created successfully')
|
||||
closeDialog()
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = isTestsApiError(error)
|
||||
? error.message
|
||||
: isPacksApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, isUpdate ? 'update' : 'create', 'test')
|
||||
|
||||
toast.error(errorMessage)
|
||||
console.error('Error saving test:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (test: TestDto) => {
|
||||
if (!test.id) {
|
||||
toast.error('Test ID is required for deletion')
|
||||
return
|
||||
}
|
||||
setTestToDelete(test)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (testToDelete?.id) {
|
||||
deleteMutation.mutate(testToDelete.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value)
|
||||
setPage(1) // Reset to first page when searching
|
||||
}
|
||||
|
||||
const isSaving = upsertMutation.isPending || syncPacksMutation.isPending
|
||||
|
||||
if (error) {
|
||||
const errorMessage = isTestsApiError(error)
|
||||
? error.message
|
||||
: formatApiError(error)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tests Management</h1>
|
||||
<p className="text-muted-foreground">Error loading tests</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-red-500 font-medium">Failed to load tests</p>
|
||||
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['tests'] })}
|
||||
className="mt-2"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tests Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View, create, edit and delete game tests
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tests..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tests Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Tests ({data?.total || 0})</CardTitle>
|
||||
<CardDescription>
|
||||
Manage game tests in the system
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Loading tests...</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Packs</TableHead>
|
||||
<TableHead>Questions</TableHead>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.items.map((test) => (
|
||||
<TableRow key={test.id || Math.random()}>
|
||||
<TableCell className="font-mono text-sm">{test.id || 'N/A'}</TableCell>
|
||||
<TableCell className="font-medium">{test.name}</TableCell>
|
||||
<TableCell>
|
||||
{test.packs && test.packs.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{test.packs.map((pack) => (
|
||||
<span
|
||||
key={pack.id}
|
||||
className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
>
|
||||
{pack.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">No packs</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{typeof test.questions === 'number'
|
||||
? test.questions
|
||||
: test.questions?.length || 0}
|
||||
</TableCell>
|
||||
<TableCell>{test.version || 'N/A'}</TableCell>
|
||||
<TableCell>{test.time || 'N/A'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(test)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(test)}
|
||||
disabled={!test.id}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} tests
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-6xl max-h-[95vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedTest ? 'Edit Test' : 'Create New Test'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedTest ? 'Update the test information' : 'Add a new test to the system'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Test name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">Color</Label>
|
||||
<ColorPaletteInput
|
||||
id="color"
|
||||
value={formData.color}
|
||||
onChange={(value) =>
|
||||
setFormData((prev) => ({ ...prev, color: value }))
|
||||
}
|
||||
disabled={isSaving}
|
||||
placeholder="#FF0000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="version">Version</Label>
|
||||
<Input
|
||||
id="version"
|
||||
value={formData.version}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, version: e.target.value }))}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="time">Time</Label>
|
||||
<Input
|
||||
id="time"
|
||||
value={formData.time}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, time: e.target.value }))}
|
||||
placeholder="e.g. 5 min"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeSubtitle">Time Subtitle</Label>
|
||||
<Input
|
||||
id="timeSubtitle"
|
||||
value={formData.timeSubtitle}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, timeSubtitle: e.target.value }))}
|
||||
placeholder="e.g. per question"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ImageUpload
|
||||
label="Cover Image"
|
||||
value={formData.cover}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, cover: value }))}
|
||||
disabled={isSaving}
|
||||
uploadType="test-image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<TestPacksManager
|
||||
currentPackIds={currentPackIds}
|
||||
onPacksChange={handlePacksChange}
|
||||
onSelectedPackColorChange={(color) => {
|
||||
const trimmed = color.trim()
|
||||
if (!trimmed) return
|
||||
setFormData((prev) => ({ ...prev, color: trimmed }))
|
||||
}}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pack links are applied on Save.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<TestQuestionsManager
|
||||
questions={formData.questions}
|
||||
onChange={(questions) =>
|
||||
setFormData((prev) => ({ ...prev, questions }))
|
||||
}
|
||||
onEdit={(question, index) => {
|
||||
setEditingQuestion({ question, index })
|
||||
setIsQuestionDialogOpen(true)
|
||||
}}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingQuestion({ question: null, index: -1 })
|
||||
setIsQuestionDialogOpen(true)
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Question
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : (selectedTest ? 'Update' : 'Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Test</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the test "{testToDelete?.name}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Question Editor Dialog */}
|
||||
<QuestionEditorDialog
|
||||
open={isQuestionDialogOpen}
|
||||
question={editingQuestion?.question || null}
|
||||
onSave={(question) => {
|
||||
if (editingQuestion) {
|
||||
const newQuestions = [...formData.questions]
|
||||
if (editingQuestion.index >= 0) {
|
||||
// Редактирование существующего вопроса
|
||||
newQuestions[editingQuestion.index] = question
|
||||
} else {
|
||||
// Добавление нового вопроса
|
||||
newQuestions.push(question)
|
||||
}
|
||||
setFormData((prev) => ({ ...prev, questions: newQuestions }))
|
||||
}
|
||||
setIsQuestionDialogOpen(false)
|
||||
setEditingQuestion(null)
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuestionDialogOpen(false)
|
||||
setEditingQuestion(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
548
admin/src/pages/UsersPage.tsx
Normal file
548
admin/src/pages/UsersPage.tsx
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { usersApi } from '@/api/users'
|
||||
import type { UserDto, PaginatedResponse, PaymentDto } from '@/types/models'
|
||||
import type { AxiosError } from 'axios'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { packsApi } from '@/api/packs'
|
||||
import { Search, Edit, Trash2, ChevronLeft, ChevronRight, CreditCard } from 'lucide-react'
|
||||
|
||||
export default function UsersPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedUser, setSelectedUser] = useState<UserDto | null>(null)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [isPurchasesDialogOpen, setIsPurchasesDialogOpen] = useState(false)
|
||||
const [userToDelete, setUserToDelete] = useState<UserDto | null>(null)
|
||||
const [userPurchases, setUserPurchases] = useState<PaymentDto[]>([])
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
admin: false,
|
||||
subscription: false,
|
||||
packs: [] as string[],
|
||||
subscriptionFeatures: [] as string[],
|
||||
})
|
||||
|
||||
const limit = 20
|
||||
|
||||
// Fetch users
|
||||
const { data, isLoading, error } = useQuery<PaginatedResponse<UserDto>>({
|
||||
queryKey: ['users', page],
|
||||
queryFn: () => usersApi.getUsers({ page, limit }),
|
||||
})
|
||||
|
||||
// Fetch packs for selection
|
||||
const { data: packsData } = useQuery({
|
||||
queryKey: ['packs', 1, 100, ''],
|
||||
queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '', showDisabled: true }),
|
||||
})
|
||||
|
||||
// Mutations
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (user: UserDto) => usersApi.upsertUser(user),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
toast.success('User updated successfully')
|
||||
closeEditDialog()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
toast.error(axiosError.response?.data?.message || 'Failed to update user')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (userId: string) => usersApi.deleteUser(userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
toast.success('User deleted successfully')
|
||||
setIsDeleteDialogOpen(false)
|
||||
setUserToDelete(null)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const axiosError = error as AxiosError<{ message?: string }>
|
||||
toast.error(axiosError.response?.data?.message || 'Failed to delete user')
|
||||
},
|
||||
})
|
||||
|
||||
const openEditDialog = (user: UserDto) => {
|
||||
setSelectedUser(user)
|
||||
setFormData({
|
||||
name: user.name || '',
|
||||
email: user.email || '',
|
||||
admin: user.admin,
|
||||
subscription: user.subscription || false,
|
||||
packs: [...user.packs],
|
||||
subscriptionFeatures: [...user.subscriptionFeatures],
|
||||
})
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const closeEditDialog = () => {
|
||||
setIsEditDialogOpen(false)
|
||||
setSelectedUser(null)
|
||||
}
|
||||
|
||||
const openPurchasesDialog = async (user: UserDto) => {
|
||||
if (!user.id) return
|
||||
|
||||
try {
|
||||
const purchases = await usersApi.getUserPurchases(user.id.toString())
|
||||
setUserPurchases(purchases)
|
||||
setSelectedUser(user)
|
||||
setIsPurchasesDialogOpen(true)
|
||||
} catch {
|
||||
toast.error('Failed to load user purchases')
|
||||
}
|
||||
}
|
||||
|
||||
const closePurchasesDialog = () => {
|
||||
setIsPurchasesDialogOpen(false)
|
||||
setSelectedUser(null)
|
||||
setUserPurchases([])
|
||||
}
|
||||
|
||||
const handleDelete = (user: UserDto) => {
|
||||
setUserToDelete(user)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (userToDelete?.id) {
|
||||
deleteMutation.mutate(userToDelete.id.toString())
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!selectedUser?.id) {
|
||||
toast.error('User ID is required')
|
||||
return
|
||||
}
|
||||
|
||||
const userData: UserDto = {
|
||||
id: selectedUser.id,
|
||||
name: formData.name.trim() || undefined,
|
||||
email: formData.email.trim() || undefined,
|
||||
admin: formData.admin,
|
||||
subscription: formData.subscription,
|
||||
packs: formData.packs,
|
||||
purchases: selectedUser.purchases,
|
||||
subscriptionFeatures: formData.subscriptionFeatures,
|
||||
}
|
||||
|
||||
updateMutation.mutate(userData)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Users Management</h1>
|
||||
<p className="text-muted-foreground">Error loading users</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-red-500">Failed to load users. Please try again later.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Users Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage user accounts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users by name or ID..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Users Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Users ({data?.total || 0})</CardTitle>
|
||||
<CardDescription>
|
||||
Manage user accounts and permissions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Loading users...</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Admin</TableHead>
|
||||
<TableHead>Packs</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.items
|
||||
.filter(user =>
|
||||
search === '' ||
|
||||
user.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
user.id?.toString().includes(search)
|
||||
)
|
||||
.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-mono text-sm">{user.id}</TableCell>
|
||||
<TableCell className="font-medium">{user.name || 'No name'}</TableCell>
|
||||
<TableCell>{user.email || 'No email'}</TableCell>
|
||||
<TableCell>
|
||||
{user.admin ? (
|
||||
<Badge variant="destructive">Admin</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">User</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{user.packs.length} packs</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(user)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openPurchasesDialog(user)}
|
||||
>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(user)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} users
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update user information and permissions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userId">User ID</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
value={selectedUser?.id || ''}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="User name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="admin"
|
||||
checked={formData.admin}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, admin: checked as boolean }))}
|
||||
/>
|
||||
<Label htmlFor="admin">Administrator</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="subscription"
|
||||
checked={formData.subscription}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, subscription: checked as boolean }))}
|
||||
/>
|
||||
<Label htmlFor="subscription">Subscription</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Packs Owned</Label>
|
||||
<div className="border rounded-lg max-h-[200px] overflow-y-auto p-2">
|
||||
{!packsData?.items || packsData.items.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No packs available</div>
|
||||
) : (
|
||||
packsData.items.map((pack) => (
|
||||
<div key={pack.id} className="flex items-center space-x-2 py-1">
|
||||
<Checkbox
|
||||
id={`pack-${pack.id}`}
|
||||
checked={formData.packs.includes(pack.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
packs: [...prev.packs, pack.id],
|
||||
}))
|
||||
} else {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
packs: prev.packs.filter(id => id !== pack.id),
|
||||
}))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`pack-${pack.id}`} className="text-sm font-normal cursor-pointer">
|
||||
{pack.title}
|
||||
</Label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected: {formData.packs.length} pack(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Subscription Features</Label>
|
||||
<div className="space-y-2">
|
||||
{['premium', 'unlimited', 'ad_free', 'early_access', 'priority_support'].map((feature) => (
|
||||
<div key={feature} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`feature-${feature}`}
|
||||
checked={formData.subscriptionFeatures.includes(feature)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
subscriptionFeatures: [...prev.subscriptionFeatures, feature],
|
||||
}))
|
||||
} else {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
subscriptionFeatures: prev.subscriptionFeatures.filter(f => f !== feature),
|
||||
}))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`feature-${feature}`} className="text-sm font-normal cursor-pointer capitalize">
|
||||
{feature.replace(/_/g, ' ')}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected: {formData.subscriptionFeatures.length} feature(s)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={closeEditDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* User Purchases Dialog */}
|
||||
<Dialog open={isPurchasesDialogOpen} onOpenChange={setIsPurchasesDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Purchase History</DialogTitle>
|
||||
<DialogDescription>
|
||||
Payment history for user: {selectedUser?.name || selectedUser?.id}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{userPurchases.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No purchases found for this user
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Currency</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{userPurchases.map((payment) => (
|
||||
<TableRow key={payment.id}>
|
||||
<TableCell className="font-mono text-sm">{payment.id}</TableCell>
|
||||
<TableCell>{payment.amount}</TableCell>
|
||||
<TableCell>{payment.currency}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
payment.status === 'completed' ? 'default' :
|
||||
payment.status === 'pending' ? 'secondary' : 'destructive'
|
||||
}
|
||||
>
|
||||
{payment.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(payment.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={closePurchasesDialog}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete User</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the user "{userToDelete?.name || userToDelete?.id}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
admin/src/stores/authStore.ts
Normal file
146
admin/src/stores/authStore.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { UserDto } from '@/types/models'
|
||||
|
||||
interface AuthState {
|
||||
user: UserDto | null
|
||||
token: string | null
|
||||
refreshToken: string | null
|
||||
expiresIn: number | null
|
||||
tokenExpiresAt: number | null // timestamp when token expires
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface AuthActions {
|
||||
login: (token: string, refreshToken: string | null, expiresIn: number | null, user: UserDto) => void
|
||||
updateToken: (token: string, refreshToken: string, expiresIn: number) => void
|
||||
logout: () => void
|
||||
setLoading: (loading: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
shouldRefreshToken: () => boolean
|
||||
}
|
||||
|
||||
type AuthStore = AuthState & AuthActions
|
||||
|
||||
export const useAuthStore = create<AuthStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// State
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
expiresIn: null,
|
||||
tokenExpiresAt: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// Actions
|
||||
login: (token: string, refreshToken: string | null, expiresIn: number | null, user: UserDto) => {
|
||||
console.log('[AuthStore] Saving token to localStorage, length:', token.length)
|
||||
localStorage.setItem('admin_token', token)
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('admin_refresh_token', refreshToken)
|
||||
}
|
||||
|
||||
// Calculate expiration time (refresh 5 minutes before expiration)
|
||||
const tokenExpiresAt = expiresIn
|
||||
? Date.now() + (expiresIn - 300) * 1000 // 5 minutes buffer
|
||||
: null
|
||||
|
||||
// Verify it was saved
|
||||
const savedToken = localStorage.getItem('admin_token')
|
||||
console.log('[AuthStore] Token saved, verification:', savedToken ? `Token exists, length: ${savedToken.length}` : 'Token NOT found!')
|
||||
set({
|
||||
user,
|
||||
token,
|
||||
refreshToken: refreshToken ?? null,
|
||||
expiresIn: expiresIn ?? null,
|
||||
tokenExpiresAt,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
|
||||
updateToken: (token: string, refreshToken: string, expiresIn: number) => {
|
||||
console.log('[AuthStore] Updating token')
|
||||
localStorage.setItem('admin_token', token)
|
||||
localStorage.setItem('admin_refresh_token', refreshToken)
|
||||
|
||||
const tokenExpiresAt = Date.now() + (expiresIn - 300) * 1000 // 5 minutes buffer
|
||||
|
||||
set({
|
||||
token,
|
||||
refreshToken,
|
||||
expiresIn,
|
||||
tokenExpiresAt,
|
||||
})
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_refresh_token')
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
expiresIn: null,
|
||||
tokenExpiresAt: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
|
||||
setLoading: (isLoading: boolean) => {
|
||||
set({ isLoading })
|
||||
},
|
||||
|
||||
setError: (error: string | null) => {
|
||||
set({ error, isLoading: false })
|
||||
},
|
||||
|
||||
shouldRefreshToken: () => {
|
||||
const state = get()
|
||||
if (!state.refreshToken || !state.token) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we have expiresIn but no tokenExpiresAt, calculate it
|
||||
if (state.expiresIn && !state.tokenExpiresAt) {
|
||||
const calculatedExpiresAt = Date.now() + (state.expiresIn - 300) * 1000
|
||||
set({ tokenExpiresAt: calculatedExpiresAt })
|
||||
return Date.now() >= calculatedExpiresAt
|
||||
}
|
||||
|
||||
if (!state.tokenExpiresAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Refresh if token expires in less than 5 minutes
|
||||
return Date.now() >= state.tokenExpiresAt
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'admin-auth',
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
expiresIn: state.expiresIn,
|
||||
tokenExpiresAt: state.tokenExpiresAt,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// After rehydration, recalculate tokenExpiresAt if needed
|
||||
if (state && state.expiresIn && !state.tokenExpiresAt) {
|
||||
const tokenExpiresAt = Date.now() + (state.expiresIn - 300) * 1000
|
||||
state.tokenExpiresAt = tokenExpiresAt
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
21
admin/src/test/setup.ts
Normal file
21
admin/src/test/setup.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import '@testing-library/jest-dom'
|
||||
|
||||
// Radix UI uses Pointer Events APIs that are not fully implemented in JSDOM.
|
||||
// Polyfill the minimal surface to prevent runtime errors in tests.
|
||||
if (!Element.prototype.hasPointerCapture) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
Element.prototype.hasPointerCapture = () => false
|
||||
}
|
||||
if (!Element.prototype.setPointerCapture) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
Element.prototype.setPointerCapture = () => {}
|
||||
}
|
||||
if (!Element.prototype.releasePointerCapture) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
Element.prototype.releasePointerCapture = () => {}
|
||||
}
|
||||
|
||||
if (!HTMLElement.prototype.scrollIntoView) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
HTMLElement.prototype.scrollIntoView = () => {}
|
||||
}
|
||||
198
admin/src/types/models.ts
Normal file
198
admin/src/types/models.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// Types based on backend DTOs
|
||||
|
||||
export interface GameCardDto {
|
||||
id: string | null
|
||||
packId?: string
|
||||
image?: string // Object ID in MinIO (for admin)
|
||||
imageUrl?: string // Presigned URL (for display)
|
||||
mnemo?: string
|
||||
original?: string
|
||||
translation?: string
|
||||
transcription?: string
|
||||
transcriptionMnemo?: string
|
||||
imageBack?: string // Object ID in MinIO (for admin)
|
||||
imageBackUrl?: string // Presigned URL (for display)
|
||||
back?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface EditCardPackDto {
|
||||
id?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
color?: string
|
||||
cover?: string
|
||||
size?: number
|
||||
googlePlayId?: string
|
||||
rustoreId?: string
|
||||
appStoreId?: string
|
||||
price?: string
|
||||
description?: string
|
||||
enabled?: boolean
|
||||
version?: string
|
||||
addCardIds?: string[]
|
||||
addTestIds?: string[]
|
||||
removeCardIds?: string[]
|
||||
removeTestIds?: string[]
|
||||
previewCards?: string[]
|
||||
order?: number
|
||||
cardsOrder?: string[]
|
||||
}
|
||||
|
||||
export interface CardPackPreviewDto {
|
||||
id: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
color?: string
|
||||
cover?: string
|
||||
cards: number
|
||||
enabled: boolean
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface UserDto {
|
||||
id?: number
|
||||
name?: string
|
||||
email?: string
|
||||
admin: boolean
|
||||
packs: string[]
|
||||
purchases: string[]
|
||||
subscription?: boolean
|
||||
subscriptionFeatures: string[]
|
||||
userDataDto?: unknown
|
||||
userSettingsDto?: unknown
|
||||
}
|
||||
|
||||
export interface PaymentDto {
|
||||
id: string
|
||||
userId: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SubscriptionPlanAdminDto {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
price: number
|
||||
currency: string
|
||||
features: string[]
|
||||
enabled: boolean
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface PromoCodesCampaignDto {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
codes: string[]
|
||||
discountPercent: number
|
||||
validUntil: string
|
||||
maxUses?: number
|
||||
usedCount: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface DiscountCampaignDto {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
discountPercent: number
|
||||
validFrom: string
|
||||
validUntil: string
|
||||
enabled: boolean
|
||||
packIds?: string[]
|
||||
userIds?: string[]
|
||||
maxUses?: number
|
||||
usedCount: number
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface ApiResponse<T> {
|
||||
data: T
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface AuthRequest {
|
||||
telegramUserId: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
success?: boolean
|
||||
token: string
|
||||
refreshToken?: string
|
||||
expiresIn?: number
|
||||
user: UserDto
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
success: boolean
|
||||
token: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface CodeRequest {
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface RequestCodeResponse {
|
||||
success: boolean
|
||||
code?: string
|
||||
status?: string
|
||||
expiresAt?: string
|
||||
remainingSeconds?: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CodeStatusResponse {
|
||||
success: boolean
|
||||
code: string
|
||||
status: string
|
||||
expiresAt?: string
|
||||
remainingSeconds: number
|
||||
isClaimed: boolean
|
||||
isUsed: boolean
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
// Test types
|
||||
import type { Question } from './questions'
|
||||
|
||||
export type TestQuestion = Question
|
||||
|
||||
export interface TestPackInfo {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface TestDto {
|
||||
id?: string
|
||||
name: string
|
||||
color?: string
|
||||
cover?: string // Object ID in MinIO (for admin)
|
||||
coverUrl?: string // Presigned URL (for display)
|
||||
version?: string
|
||||
time?: string
|
||||
timeSubtitle?: string
|
||||
// Backend returns either a questions list (full test) or a questions count (list endpoint).
|
||||
questions: unknown[] | number
|
||||
statistics?: unknown
|
||||
packs?: TestPackInfo[]
|
||||
}
|
||||
41
admin/src/types/questions.test.ts
Normal file
41
admin/src/types/questions.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isMatrixQuestion,
|
||||
questionFromJson,
|
||||
questionToJson,
|
||||
QuestionType,
|
||||
} from './questions'
|
||||
|
||||
describe('questions (matrix)', () => {
|
||||
it('questionFromJson parses matrixSize', () => {
|
||||
const q = questionFromJson({
|
||||
id: 'q1',
|
||||
questionType: QuestionType.MATRIX,
|
||||
matrixSize: 4,
|
||||
word: '',
|
||||
answer: '',
|
||||
buttons: [],
|
||||
})
|
||||
|
||||
expect(q.questionType).toBe(QuestionType.MATRIX)
|
||||
expect(isMatrixQuestion(q)).toBe(true)
|
||||
if (isMatrixQuestion(q)) {
|
||||
expect(q.matrixSize).toBe(4)
|
||||
}
|
||||
})
|
||||
|
||||
it('questionToJson includes matrixSize', () => {
|
||||
const json = questionToJson({
|
||||
questionType: QuestionType.MATRIX,
|
||||
word: '',
|
||||
answer: '',
|
||||
options: [],
|
||||
matrixSize: 3,
|
||||
})
|
||||
|
||||
expect(json.questionType).toBe(QuestionType.MATRIX)
|
||||
expect(json.matrixSize).toBe(3)
|
||||
expect(json.buttons).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
161
admin/src/types/questions.ts
Normal file
161
admin/src/types/questions.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
export const QuestionType = {
|
||||
SIMPLE: 'simple',
|
||||
INPUT_BUTTONS: 'input_buttons',
|
||||
MATRIX: 'matrix',
|
||||
} as const
|
||||
|
||||
export type QuestionType = typeof QuestionType[keyof typeof QuestionType]
|
||||
|
||||
// Соответствует полю options в БД
|
||||
export interface TestButton {
|
||||
id: string
|
||||
text?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
// Базовый интерфейс вопроса
|
||||
export interface BaseQuestion {
|
||||
id?: string
|
||||
questionType: QuestionType
|
||||
orderIndex?: number
|
||||
|
||||
// Ключевые поля (хранятся отдельно в БД)
|
||||
word: string // TestQuestions.word
|
||||
answer: string // TestQuestions.answer
|
||||
options: TestButton[] // TestQuestions.options (JSON)
|
||||
|
||||
// UI данные (хранятся в TestQuestions.uiData как JSON)
|
||||
image?: string
|
||||
text?: string
|
||||
audio?: string
|
||||
}
|
||||
|
||||
export interface SimpleQuestion extends BaseQuestion {
|
||||
questionType: 'simple'
|
||||
}
|
||||
|
||||
export interface InputButtonsQuestion extends BaseQuestion {
|
||||
questionType: 'input_buttons'
|
||||
template: string // Хранится в uiData
|
||||
}
|
||||
|
||||
export interface MatrixQuestion extends BaseQuestion {
|
||||
questionType: 'matrix'
|
||||
matrixSize: number
|
||||
}
|
||||
|
||||
export type Question = SimpleQuestion | InputButtonsQuestion | MatrixQuestion
|
||||
|
||||
// Хелпер для преобразования в/из БД формата
|
||||
export interface QuestionDbFormat {
|
||||
id?: string
|
||||
testId: string
|
||||
orderIndex: number
|
||||
questionType: string
|
||||
word: string
|
||||
answer: string
|
||||
options: string // JSON string
|
||||
uiData: string // JSON string
|
||||
}
|
||||
|
||||
// Хелперы для работы с вопросами
|
||||
export function isSimpleQuestion(question: Question): question is SimpleQuestion {
|
||||
return question.questionType === QuestionType.SIMPLE
|
||||
}
|
||||
|
||||
export function isInputButtonsQuestion(
|
||||
question: Question,
|
||||
): question is InputButtonsQuestion {
|
||||
return question.questionType === QuestionType.INPUT_BUTTONS
|
||||
}
|
||||
|
||||
export function isMatrixQuestion(question: Question): question is MatrixQuestion {
|
||||
return question.questionType === QuestionType.MATRIX
|
||||
}
|
||||
|
||||
// Создать вопрос из JSON (как приходит с бэкенда)
|
||||
export function questionFromJson(json: unknown): Question {
|
||||
const obj: Record<string, unknown> =
|
||||
json && typeof json === 'object' ? (json as Record<string, unknown>) : {}
|
||||
|
||||
const parseQuestionType = (value: unknown): QuestionType => {
|
||||
if (value === QuestionType.SIMPLE) return QuestionType.SIMPLE
|
||||
if (value === QuestionType.INPUT_BUTTONS) return QuestionType.INPUT_BUTTONS
|
||||
if (value === QuestionType.MATRIX) return QuestionType.MATRIX
|
||||
return QuestionType.SIMPLE
|
||||
}
|
||||
|
||||
const asString = (value: unknown): string | undefined =>
|
||||
typeof value === 'string' ? value : undefined
|
||||
|
||||
const asNumber = (value: unknown): number | undefined =>
|
||||
typeof value === 'number' ? value : undefined
|
||||
|
||||
const asTestButtons = (value: unknown): TestButton[] =>
|
||||
Array.isArray(value) ? (value as TestButton[]) : []
|
||||
|
||||
const base: BaseQuestion = {
|
||||
id: asString(obj.id),
|
||||
questionType: parseQuestionType(obj.questionType),
|
||||
orderIndex: asNumber(obj.orderIndex),
|
||||
word: asString(obj.word) ?? '',
|
||||
answer: asString(obj.answer) ?? '',
|
||||
options: asTestButtons(obj.buttons ?? obj.options),
|
||||
image: asString(obj.image),
|
||||
text: asString(obj.text),
|
||||
audio: asString(obj.audio),
|
||||
}
|
||||
|
||||
if (base.questionType === QuestionType.MATRIX) {
|
||||
return {
|
||||
...base,
|
||||
questionType: QuestionType.MATRIX,
|
||||
matrixSize: Number(obj.matrixSize ?? 3),
|
||||
// For matrix questions, cards are auto-generated on backend if options are empty.
|
||||
options: base.options || [],
|
||||
}
|
||||
}
|
||||
|
||||
if (base.questionType === QuestionType.INPUT_BUTTONS) {
|
||||
return {
|
||||
...base,
|
||||
questionType: QuestionType.INPUT_BUTTONS,
|
||||
template: asString(obj.template) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
questionType: QuestionType.SIMPLE,
|
||||
}
|
||||
}
|
||||
|
||||
// Преобразовать вопрос в JSON для отправки на бэкенд
|
||||
export function questionToJson(question: Question): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = {
|
||||
questionType: question.questionType,
|
||||
word: question.word,
|
||||
answer: question.answer,
|
||||
buttons: question.options,
|
||||
}
|
||||
|
||||
if (question.id) {
|
||||
base.id = question.id
|
||||
}
|
||||
|
||||
// UI данные
|
||||
if (question.image) base.image = question.image
|
||||
if (question.text) base.text = question.text
|
||||
if (question.audio) base.audio = question.audio
|
||||
|
||||
// Специфичные для типа поля
|
||||
if (isInputButtonsQuestion(question)) {
|
||||
base.template = question.template
|
||||
}
|
||||
|
||||
if (isMatrixQuestion(question)) {
|
||||
base.matrixSize = question.matrixSize
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
77
admin/tailwind.config.js
Normal file
77
admin/tailwind.config.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
34
admin/tsconfig.app.json
Normal file
34
admin/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
admin/tsconfig.json
Normal file
7
admin/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
admin/tsconfig.node.json
Normal file
26
admin/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
22
admin/vite.config.ts
Normal file
22
admin/vite.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
},
|
||||
})
|
||||
|
|
@ -7,13 +7,17 @@ generator client {
|
|||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String? @unique
|
||||
name String?
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
email String? @unique
|
||||
name String?
|
||||
password String? // For optional email/password admin auth
|
||||
role UserRole @default(USER)
|
||||
telegramId String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Связи
|
||||
hostedRooms Room[] @relation("HostedRooms")
|
||||
|
|
@ -39,7 +43,7 @@ model Room {
|
|||
allowSpectators Boolean @default(true)
|
||||
timerEnabled Boolean @default(false)
|
||||
timerDuration Int @default(30)
|
||||
questionPackId String
|
||||
questionPackId String?
|
||||
autoAdvance Boolean @default(false)
|
||||
|
||||
// Состояние игры
|
||||
|
|
@ -57,7 +61,7 @@ model Room {
|
|||
// Связи
|
||||
host User @relation("HostedRooms", fields: [hostId], references: [id])
|
||||
participants Participant[]
|
||||
questionPack QuestionPack @relation(fields: [questionPackId], references: [id])
|
||||
questionPack QuestionPack? @relation(fields: [questionPackId], references: [id])
|
||||
gameHistory GameHistory?
|
||||
}
|
||||
|
||||
|
|
@ -89,6 +93,11 @@ enum ParticipantRole {
|
|||
SPECTATOR
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
USER
|
||||
ADMIN
|
||||
}
|
||||
|
||||
model QuestionPack {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
|
|
@ -122,3 +131,23 @@ model GameHistory {
|
|||
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model AdminAuthCode {
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
telegramId String?
|
||||
status CodeStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
|
||||
@@index([code])
|
||||
@@index([telegramId])
|
||||
}
|
||||
|
||||
enum CodeStatus {
|
||||
PENDING
|
||||
CLAIMED
|
||||
USED
|
||||
EXPIRED
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { QuestionsModule } from './questions/questions.module';
|
|||
import { GameModule } from './game/game.module';
|
||||
import { StatsModule } from './stats/stats.module';
|
||||
import { VoiceModule } from './voice/voice.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -20,6 +21,7 @@ import { VoiceModule } from './voice/voice.module';
|
|||
GameModule,
|
||||
StatsModule,
|
||||
VoiceModule,
|
||||
AdminModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller, Post, Get, Body, Param } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Body, Param, Patch } from '@nestjs/common';
|
||||
import { RoomsService } from './rooms.service';
|
||||
|
||||
@Controller('rooms')
|
||||
|
|
@ -6,7 +6,7 @@ export class RoomsController {
|
|||
constructor(private roomsService: RoomsService) {}
|
||||
|
||||
@Post()
|
||||
async createRoom(@Body() dto: { hostId: string; questionPackId: string; settings?: any }) {
|
||||
async createRoom(@Body() dto: { hostId: string; questionPackId?: string; settings?: any }) {
|
||||
return this.roomsService.createRoom(dto.hostId, dto.questionPackId, dto.settings);
|
||||
}
|
||||
|
||||
|
|
@ -22,4 +22,12 @@ export class RoomsController {
|
|||
) {
|
||||
return this.roomsService.joinRoom(roomId, dto.userId, dto.name, dto.role);
|
||||
}
|
||||
|
||||
@Patch(':roomId/question-pack')
|
||||
async updateQuestionPack(
|
||||
@Param('roomId') roomId: string,
|
||||
@Body() dto: { questionPackId: string }
|
||||
) {
|
||||
return this.roomsService.updateQuestionPack(roomId, dto.questionPackId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6);
|
|||
export class RoomsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async createRoom(hostId: string, questionPackId: string, settings?: any) {
|
||||
async createRoom(hostId: string, questionPackId?: string, settings?: any) {
|
||||
const code = nanoid();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ export class RoomsService {
|
|||
data: {
|
||||
code,
|
||||
hostId,
|
||||
questionPackId,
|
||||
questionPackId: questionPackId || null,
|
||||
expiresAt,
|
||||
...settings,
|
||||
},
|
||||
|
|
@ -75,4 +75,22 @@ export class RoomsService {
|
|||
data: { score },
|
||||
});
|
||||
}
|
||||
|
||||
async updateQuestionPack(roomId: string, questionPackId: string) {
|
||||
return this.prisma.room.update({
|
||||
where: { id: roomId },
|
||||
data: {
|
||||
questionPackId,
|
||||
currentQuestionIndex: 0,
|
||||
revealedAnswers: {},
|
||||
},
|
||||
include: {
|
||||
host: true,
|
||||
participants: {
|
||||
include: { user: true },
|
||||
},
|
||||
questionPack: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"html2canvas": "^1.4.1",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"jspdf": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
|
|
@ -1848,6 +1849,12 @@
|
|||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html5-qrcode": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
|
||||
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"html2canvas": "^1.4.1",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"jspdf": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
|
|
|
|||
182
src/components/QRModal.css
Normal file
182
src/components/QRModal.css
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
.qr-modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.qr-modal-content {
|
||||
background: rgba(20, 20, 30, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 20px;
|
||||
padding: clamp(20px, 3vh, 30px);
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
border: 2px solid rgba(255, 215, 0, 0.3);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.qr-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: clamp(15px, 2vh, 20px);
|
||||
}
|
||||
|
||||
.qr-modal-title {
|
||||
color: #ffd700;
|
||||
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
|
||||
margin: 0;
|
||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||
}
|
||||
|
||||
.qr-modal-close {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
color: #ff6b6b;
|
||||
border: 2px solid rgba(255, 107, 107, 0.5);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qr-modal-close:hover {
|
||||
background: rgba(255, 107, 107, 0.4);
|
||||
border-color: #ff6b6b;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.qr-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.qr-modal-description {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-align: center;
|
||||
font-size: clamp(0.95rem, 2vw, 1.1rem);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-modal-image-container {
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 15px;
|
||||
border: 2px solid rgba(255, 215, 0, 0.3);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qr-modal-image {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
max-width: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.qr-modal-code-info {
|
||||
text-align: center;
|
||||
padding: 15px 20px;
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
border: 2px solid rgba(255, 215, 0, 0.3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-modal-code-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.qr-modal-code-value {
|
||||
color: #ffd700;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.qr-modal-url-info {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-modal-url-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.qr-modal-url-link {
|
||||
color: #4ecdc4;
|
||||
font-size: 0.85rem;
|
||||
word-break: break-all;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
padding: 8px 12px;
|
||||
background: rgba(78, 205, 196, 0.1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(78, 205, 196, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.qr-modal-url-link:hover {
|
||||
background: rgba(78, 205, 196, 0.2);
|
||||
border-color: #4ecdc4;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.qr-modal-content {
|
||||
padding: 20px;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.qr-modal-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.qr-modal-close {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.qr-modal-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.qr-modal-code-value {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.qr-modal-url-link {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
63
src/components/QRModal.jsx
Normal file
63
src/components/QRModal.jsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
import './QRModal.css';
|
||||
|
||||
const QRModal = ({ isOpen, onClose, qrCode, roomCode }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleBackdropClick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Используем абсолютный URL для работы из любого приложения
|
||||
const origin = window.location.origin ||
|
||||
`${window.location.protocol}//${window.location.host}`;
|
||||
const joinUrl = `${origin}/join-room?code=${roomCode}`;
|
||||
|
||||
return (
|
||||
<div className="qr-modal-backdrop" onClick={handleBackdropClick}>
|
||||
<div className="qr-modal-content">
|
||||
<div className="qr-modal-header">
|
||||
<h2 className="qr-modal-title">QR-код для присоединения</h2>
|
||||
<button className="qr-modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="qr-modal-body">
|
||||
<p className="qr-modal-description">
|
||||
Отсканируйте этот QR-код, чтобы быстро присоединиться к комнате.
|
||||
Игроки могут использовать камеру своего телефона для сканирования.
|
||||
</p>
|
||||
|
||||
{qrCode && (
|
||||
<div className="qr-modal-image-container">
|
||||
<img src={qrCode} alt="QR Code" className="qr-modal-image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="qr-modal-code-info">
|
||||
<p className="qr-modal-code-label">Код комнаты:</p>
|
||||
<p className="qr-modal-code-value">{roomCode}</p>
|
||||
</div>
|
||||
|
||||
<div className="qr-modal-url-info">
|
||||
<p className="qr-modal-url-label">Или перейдите по ссылке:</p>
|
||||
<a
|
||||
href={joinUrl}
|
||||
className="qr-modal-url-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{joinUrl}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRModal;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue