chore: upgrade dependencies, restructure shared libs, and fix UI
- Upgrade Nx 22.1.1 → 22.6.3 and all patch/minor dependencies - Restructure shared libs: move business logic from utils to service - Consolidate shadcn-ui into ui lib with atomic design pattern - Fix container centering for landing app (Tailwind v4 compatibility) - Fix button styling by updating @source directive in globals.css - Fix SiCss3 → SiCss rename in react-icons 5.6 - Fix duplicate useSession export conflict - Remove dead code, comments, and unused files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f68d97188c
commit
3f4461c65c
@@ -1,3 +1,4 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import {
|
||||
FC,
|
||||
ReactElement,
|
||||
@@ -6,62 +7,55 @@ import {
|
||||
} from 'react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
type TButtonVariant =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'danger'
|
||||
| 'text'
|
||||
| 'bordered';
|
||||
type TButtonSize = 'sm' | 'md' | 'lg';
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center font-[600] rounded-md px-[16px] py-[10px] transition-colors duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-primary-500 hover:bg-primary-600 text-white shadow-md',
|
||||
secondary:
|
||||
'bg-white dark:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700 text-primary-500 dark:text-primary-400 shadow-md dark:shadow-gray-900/50 border dark:border-gray-700',
|
||||
text: 'bg-transparent hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-800 text-primary-500 dark:text-primary-400',
|
||||
bordered:
|
||||
'border border-primary-500 dark:border-primary-400 hover:border-primary-600 dark:hover:border-primary-300 bg-transparent hover:text-primary-600 dark:hover:text-primary-300 hover:bg-gray-50 dark:hover:bg-gray-800 text-primary-500 dark:text-primary-400',
|
||||
success: 'bg-success-500 hover:bg-success-600 text-white shadow-md',
|
||||
danger:
|
||||
'bg-danger-100 dark:bg-danger-500/20 hover:bg-danger-200 dark:hover:bg-danger-500/30 text-danger-500 shadow-md dark:shadow-gray-900/50',
|
||||
},
|
||||
size: {
|
||||
sm: 'text-[12px] max-h-[36px]',
|
||||
md: 'text-[15px] max-h-[40px]',
|
||||
lg: 'text-[19px] max-h-[44px]',
|
||||
icon: 'h-9 w-9 p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
type TButtonProps = DetailedHTMLProps<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> & {
|
||||
variant?: TButtonVariant;
|
||||
size?: TButtonSize;
|
||||
};
|
||||
|
||||
const variantClasses: Record<TButtonVariant, string> = {
|
||||
primary: 'bg-primary-500 hover:bg-primary-600 text-white shadow-md',
|
||||
secondary:
|
||||
'bg-white dark:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700 text-primary-500 dark:text-primary-400 shadow-md dark:shadow-gray-900/50 border dark:border-gray-700',
|
||||
text: 'bg-transparent hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-800 text-primary-500 dark:text-primary-400',
|
||||
bordered:
|
||||
'border border-primary-500 dark:border-primary-400 hover:border-primary-600 dark:hover:border-primary-300 bg-transparent hover:text-primary-600 dark:hover:text-primary-300 hover:bg-gray-50 dark:hover:bg-gray-800 text-primary-500 dark:text-primary-400',
|
||||
success: 'bg-success-500 hover:bg-success-600 text-white shadow-md',
|
||||
danger:
|
||||
'bg-danger-100 dark:bg-danger-500/20 hover:bg-danger-200 dark:hover:bg-danger-500/30 text-danger-500 shadow-md dark:shadow-gray-900/50',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<TButtonSize, string> = {
|
||||
sm: 'text-[12px] max-h-[36px]',
|
||||
md: 'text-[15px] max-h-[40px]',
|
||||
lg: 'text-[19px] max-h-[44px]',
|
||||
};
|
||||
|
||||
const disabledClass = 'opacity-50 cursor-not-allowed';
|
||||
> &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
|
||||
export const Button: FC<TButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
variant,
|
||||
size,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
const mergedClassName = cn(
|
||||
'inline-flex items-center justify-center font-[600] rounded-md px-[16px] py-[10px]',
|
||||
'transition-colors duration-200 cursor-pointer',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
disabled && disabledClass,
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<button className={mergedClassName} disabled={disabled} {...rest}>
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card';
|
||||
|
||||
describe('Card', () => {
|
||||
it('renders card with content', () => {
|
||||
render(<Card>Card content</Card>);
|
||||
expect(screen.getByText('Card content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Card className="custom-class">Content</Card>);
|
||||
expect(screen.getByText('Content')).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('renders full card composition', () => {
|
||||
render(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Title</CardTitle>
|
||||
<CardDescription>Description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>Body</CardContent>
|
||||
<CardFooter>Footer</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('Body')).toBeInTheDocument();
|
||||
expect(screen.getByText('Footer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card';
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: 'Components/Card',
|
||||
component: Card,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Card>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Card className="w-[350px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Card Title</CardTitle>
|
||||
<CardDescription>Card description goes here.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>Card content body.</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p>Card footer</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
export const Simple: Story = {
|
||||
render: () => (
|
||||
<Card className="w-[350px] p-6">
|
||||
<p>Simple card with padding.</p>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card text-card-foreground shadow',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('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,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './card';
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from './dialog';
|
||||
|
||||
describe('Dialog', () => {
|
||||
it('opens dialog when trigger is clicked', async () => {
|
||||
render(
|
||||
<Dialog>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dialog Title</DialogTitle>
|
||||
<DialogDescription>Dialog description</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText('Open'));
|
||||
|
||||
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dialog description')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from './dialog';
|
||||
|
||||
const meta: Meta<typeof Dialog> = {
|
||||
title: 'Components/Dialog',
|
||||
component: Dialog,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Dialog>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Dialog>
|
||||
<DialogTrigger>Open Dialog</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<button>Cancel</button>
|
||||
<button>Continue</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import * as React from 'react';
|
||||
import { LuX } from 'react-icons/lu';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<LuX />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dialog';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from './drawer';
|
||||
|
||||
describe('Drawer', () => {
|
||||
it('opens drawer when trigger is clicked', async () => {
|
||||
render(
|
||||
<Drawer>
|
||||
<DrawerTrigger>Open Drawer</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Drawer Title</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText('Open Drawer'));
|
||||
|
||||
expect(screen.getByText('Drawer Title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerClose,
|
||||
} from './drawer';
|
||||
|
||||
const meta: Meta<typeof Drawer> = {
|
||||
title: 'Components/Drawer',
|
||||
component: Drawer,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Drawer>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Drawer>
|
||||
<DrawerTrigger>Open Drawer</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Drawer Title</DrawerTitle>
|
||||
<DrawerDescription>Drawer description goes here.</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerFooter>
|
||||
<DrawerClose>Close</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
|
||||
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
|
||||
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
|
||||
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './drawer';
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from './form';
|
||||
|
||||
function TestForm() {
|
||||
const form = useForm({ defaultValues: { name: '' } });
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Form', () => {
|
||||
it('renders form with label and input', () => {
|
||||
render(<TestForm />);
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from './form';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Form',
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
function ExampleForm() {
|
||||
const form = useForm({ defaultValues: { username: '' } });
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<input placeholder="Enter username" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>This is your public display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <ExampleForm />,
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from 'react-hook-form';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { Label } from '../label';
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn('data-[error=true]:text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? '') : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useFormField,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './form';
|
||||
@@ -1,5 +1,10 @@
|
||||
export * from './button';
|
||||
export * from './card';
|
||||
export * from './dialog';
|
||||
export * from './drawer';
|
||||
export * from './form';
|
||||
export * from './input';
|
||||
export * from './label';
|
||||
export * from './select';
|
||||
export * from './textarea';
|
||||
export * from './select'
|
||||
export * from './toggle';
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
FC,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './label';
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Label } from './label';
|
||||
|
||||
describe('Label', () => {
|
||||
it('renders label text', () => {
|
||||
render(<Label>Username</Label>);
|
||||
expect(screen.getByText('Username')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Label className="custom-class">Email</Label>);
|
||||
expect(screen.getByText('Email')).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('associates with input via htmlFor', () => {
|
||||
render(<Label htmlFor="email-input">Email</Label>);
|
||||
expect(screen.getByText('Email')).toHaveAttribute('for', 'email-input');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Label } from './label';
|
||||
|
||||
const meta: Meta<typeof Label> = {
|
||||
title: 'Components/Label',
|
||||
component: Label,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Label>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Email address',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInput: Story = {
|
||||
render: () => (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<input id="email" type="email" placeholder="Enter your email" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@imphnen-frontend-service/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 };
|
||||
@@ -16,13 +16,11 @@ export const RegisterMentorStep: FC<TRegisterMentorStep> = ({ step }): ReactElem
|
||||
|
||||
return (
|
||||
<div className="relative w-full px-8">
|
||||
{/* Garis dasar (abu/biru muda) */}
|
||||
<div className="absolute top-5 left-8 right-8 h-0.5 bg-blue-100 z-0" />
|
||||
|
||||
{/* Garis progress biru (dinamis) */}
|
||||
<div
|
||||
className="absolute top-5 left-8 h-0.5 bg-blue-500 z-0 transition-all duration-300"
|
||||
style={{ width: `calc(${progressPercent}% - 0.5rem)` }} // -0.5rem agar tidak overlap ke lingkaran
|
||||
style={{ width: `calc(${progressPercent}% - 0.5rem)` }}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between relative z-10">
|
||||
@@ -32,7 +30,6 @@ export const RegisterMentorStep: FC<TRegisterMentorStep> = ({ step }): ReactElem
|
||||
|
||||
return (
|
||||
<div key={index} className="flex flex-col items-center flex-1 text-center">
|
||||
{/* Lingkaran angka */}
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 ${
|
||||
isActive
|
||||
@@ -42,7 +39,6 @@ export const RegisterMentorStep: FC<TRegisterMentorStep> = ({ step }): ReactElem
|
||||
>
|
||||
<span className="font-bold">{currentStep}</span>
|
||||
</div>
|
||||
{/* Label */}
|
||||
<span className="text-xs text-blue-600 mt-2 leading-tight">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
import { Button } from '../../atoms';
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { cn, For, useSession } from '@imphnen-frontend-service/utils';
|
||||
import { cn, For } from '@imphnen-frontend-service/utils';
|
||||
import { useSession } from '@imphnen-frontend-service/service';
|
||||
|
||||
type MenuItem = {
|
||||
label: string;
|
||||
@@ -274,20 +275,16 @@ export const BackofficeSidebar: FC<SidebarProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Sidebar - visible on lg+, sticky */}
|
||||
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto shadow">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar - overlay */}
|
||||
{isOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Sidebar */}
|
||||
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
@@ -45,9 +45,6 @@ export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-x-6">
|
||||
{/* <Button type="button" variant="secondary" className="max-h-full p-3">
|
||||
<Icon icon="mdi:bell-outline" className="size-6" />
|
||||
</Button> */}
|
||||
<div className="flex items-center gap-x-6">
|
||||
<div className="text-neutral-600 font-medium">
|
||||
<p className="text-p3">{user?.fullname || 'Full Name'}</p>
|
||||
|
||||
@@ -23,7 +23,6 @@ interface DataTableProps<T extends RowData> {
|
||||
columns?: ColumnDef<T, unknown>[];
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
// server-side pagination props
|
||||
manualPagination?: boolean;
|
||||
pageCount?: number;
|
||||
currentPage?: number;
|
||||
@@ -47,7 +46,6 @@ export const DataTable = <T extends RowData>({
|
||||
});
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
|
||||
// Update pagination state when pageSize prop changes
|
||||
React.useEffect(() => {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
@@ -55,7 +53,6 @@ export const DataTable = <T extends RowData>({
|
||||
}));
|
||||
}, [pageSize]);
|
||||
|
||||
// Reset pagination when data changes to prevent out-of-bounds errors
|
||||
React.useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
setPagination((prev) => ({
|
||||
@@ -65,11 +62,9 @@ export const DataTable = <T extends RowData>({
|
||||
}
|
||||
}, [data.length]);
|
||||
|
||||
// Memoize data and columns to prevent unnecessary re-renders
|
||||
const memoizedData = React.useMemo(() => data, [data]);
|
||||
const memoizedColumns = React.useMemo(() => columns, [columns]);
|
||||
|
||||
// Memoize table configuration to prevent recreation on every render
|
||||
const tableConfig = React.useMemo(() => {
|
||||
const config: TableOptions<T> = {
|
||||
data: memoizedData,
|
||||
@@ -84,7 +79,6 @@ export const DataTable = <T extends RowData>({
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
// server-side pagination config
|
||||
manualPagination,
|
||||
pageCount: manualPagination ? pageCount : undefined,
|
||||
};
|
||||
@@ -99,11 +93,9 @@ export const DataTable = <T extends RowData>({
|
||||
pageCount,
|
||||
]);
|
||||
|
||||
// Prefer external table instance if provided; otherwise create an internal one
|
||||
const internalTable = useReactTable(tableConfig);
|
||||
const t = table ?? internalTable;
|
||||
|
||||
// Handle empty data state
|
||||
const isEmpty = t.getRowModel().rows.length === 0;
|
||||
|
||||
return (
|
||||
@@ -186,7 +178,6 @@ export const DataTable = <T extends RowData>({
|
||||
</table>
|
||||
</div>
|
||||
{manualPagination && onPageChange && pageCount ? (
|
||||
// Server-side pagination controls with numbered pages
|
||||
<div className="flex items-center justify-center gap-10">
|
||||
<button
|
||||
className="disabled:opacity-50 cursor-pointer"
|
||||
@@ -211,7 +202,6 @@ export const DataTable = <T extends RowData>({
|
||||
|
||||
<div className="flex gap-4 items-baseline">
|
||||
{pageCount <= 8 ? (
|
||||
// Show all pages if 8 or fewer
|
||||
Array.from({ length: pageCount }, (_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
@@ -226,7 +216,6 @@ export const DataTable = <T extends RowData>({
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
// Show ellipsis for many pages
|
||||
<>
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
@@ -294,7 +283,6 @@ export const DataTable = <T extends RowData>({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Client-side pagination (default)
|
||||
<Pagination table={t} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { MenuOutlined } from '@ant-design/icons';
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../../atoms/button';
|
||||
import { useModalLogin, useSession } from '@imphnen-frontend-service/utils';
|
||||
import { useModalLogin } from '@imphnen-frontend-service/utils';
|
||||
import { useSession } from '@imphnen-frontend-service/service';
|
||||
|
||||
export const Navbar: FC = (): ReactElement => {
|
||||
const { session, signOut, isAuthenticated } = useSession();
|
||||
|
||||
Reference in New Issue
Block a user