admin and qr

This commit is contained in:
Dmitry 2026-01-06 23:12:36 +03:00
parent 7df70be593
commit 7ad18d53f3
105 changed files with 20181 additions and 44 deletions

View file

@ -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
View file

@ -0,0 +1,9 @@
.git
.gitignore
node_modules
dist
.vscode
.idea
.cursor
*.log
**/.DS_Store

24
admin/.gitignore vendored Normal file
View 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?

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

59
admin/package.json Normal file
View 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
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
admin/public/vite.svg Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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
)
}
},
}

View 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')
})
})

View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
},
}

View 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

View 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()
})
})
})

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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'])
})
})
})

View 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>
)
}

View 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>
)
}

View 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}</>
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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,
}

View 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 }

View 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>
)
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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('')
})
})

View 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>
)
}

View 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,
}

View 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>
)
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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
View 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
View 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>,
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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[]
}

View 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([])
})
})

View 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
View 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
View 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
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
admin/tsconfig.node.json Normal file
View 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
View 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',
},
})

View file

@ -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
}

View file

@ -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],

View file

@ -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);
}
}

View file

@ -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
View file

@ -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",

View file

@ -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
View 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;
}
}

View 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