install packages
This commit is contained in:
82
src/components/UI/BottomSheet.tsx
Normal file
82
src/components/UI/BottomSheet.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useBottomSheetStore } from "@/stores/useBottomSheetStore";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const BottomSheet = () => {
|
||||
const { isOpen, content, options, closeBottomSheet } = useBottomSheetStore();
|
||||
|
||||
const interactive = options?.interactiveBackground ?? false;
|
||||
|
||||
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0;
|
||||
|
||||
// Escape key (only active for modal mode)
|
||||
useEffect(() => {
|
||||
if (!isOpen || interactive) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeBottomSheet();
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen, interactive, closeBottomSheet]);
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Non-interactive background mode (modal) */}
|
||||
{!interactive && (
|
||||
<motion.div
|
||||
key="modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="fixed inset-0 z-50 bg-black/50"
|
||||
onClick={closeBottomSheet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* The sheet (works in both modes) */}
|
||||
<motion.div
|
||||
key="sheet"
|
||||
initial={{ y: viewportHeight }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: viewportHeight }}
|
||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||
drag="y"
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.25 }}
|
||||
onDragEnd={(e, info) => {
|
||||
if (info.offset.y > 100 || info.velocity.y > 500) {
|
||||
closeBottomSheet();
|
||||
}
|
||||
}}
|
||||
className="bg-pwa-primary fixed inset-x-0 bottom-0 z-[60] mx-auto w-full max-w-md rounded-t-xl shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-center py-2">
|
||||
<div className="h-1 w-12 rounded-full bg-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4" style={{ maxHeight: "90vh" }} dir="rtl">
|
||||
{content}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomSheet;
|
||||
161
src/components/UI/Calendar.tsx
Normal file
161
src/components/UI/Calendar.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from "react";
|
||||
import { Chevron, DayButton, getDefaultClassNames } from "react-day-picker";
|
||||
import { DayPicker } from "react-day-picker/persian";
|
||||
|
||||
interface CalendarHijriProps {
|
||||
selected?: Date;
|
||||
onSelect?: (date: Date | undefined) => void;
|
||||
defaultMonth?: Date;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CalendarHijri({ selected, onSelect, defaultMonth, className }: CalendarHijriProps) {
|
||||
return (
|
||||
<Calendar
|
||||
mode="single"
|
||||
defaultMonth={defaultMonth ?? selected ?? new Date()}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
className={cn("rounded-lg shadow-sm", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("bg-text text-desktop-primary p-3 [--cell-size:--spacing(10)]", className)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
|
||||
month: cn("flex flex-col w-full gap-3", defaultClassNames.month),
|
||||
nav: cn("flex items-center w-full absolute top-0 inset-x-0 justify-between", defaultClassNames.nav),
|
||||
button_previous: cn(
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
"size-(--cell-size) aria-disabled:opacity-50 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-text-desktop-primary [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex items-center justify-center", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-neo-aqua rounded-md flex-1 font-normal text-sm select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
|
||||
week_number: cn("text-sm select-none text-desktop-primary", defaultClassNames.week_number),
|
||||
day: cn(
|
||||
"relative w-full h-full text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-neo-aqua/10 text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-text-desktop-primary aria-selected:text-text-desktop-primary",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn("text-text-desktop-primary opacity-50", defaultClassNames.disabled),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => (
|
||||
<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
|
||||
),
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return <Chevron className={cn("size-4 rotate-180", className)} {...props} />;
|
||||
}
|
||||
if (orientation === "right") {
|
||||
return <Chevron className={cn("size-4 justify-self-end", className)} {...props} />;
|
||||
}
|
||||
return <Chevron className={cn("size-4 rotate-90", className)} {...props} />;
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
),
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-neo-aqua data-[selected-single=true]:text-text data-[range-middle=true]:bg-neo-aqua/20 data-[range-middle=true]:text-text data-[range-start=true]:bg-neo-aqua/20 data-[range-start=true]:text-text data-[range-end=true]:bg-neo-aqua/20 data-[range-end=true]:text-text flex aspect-square size-auto w-full min-w-(--cell-size) flex-col items-center justify-center gap-1 rounded leading-none group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:scale-90 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
src/components/UI/Modal.tsx
Normal file
66
src/components/UI/Modal.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useModalStore } from "@/stores/useModalStore";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const Modal = () => {
|
||||
const isOpen = useModalStore((s) => s.isOpen);
|
||||
const content = useModalStore((s) => s.content);
|
||||
const closeModal = useModalStore((s) => s.closeModal);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "hidden"; // Prevent scrolling
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen, closeModal]);
|
||||
|
||||
const handleClickOutside = (e: React.MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={handleClickOutside}
|
||||
>
|
||||
<motion.div
|
||||
ref={modalRef}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="relative rounded-lg shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
42
src/components/UI/Popover.tsx
Normal file
42
src/components/UI/Popover.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
147
src/components/UI/Select.tsx
Normal file
147
src/components/UI/Select.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, ChevronIcon } from "@/assets";
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronIcon className="text-text size-4 rotate-270" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon size={4} />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronIcon className="text-desktop-primary size-4 rotate-90" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronIcon className="text-desktop-primary size-4 rotate-270" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
14
src/components/UI/Separator.tsx
Normal file
14
src/components/UI/Separator.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
type SeparatorProps = {
|
||||
className?: HTMLAttributes<HTMLDivElement> | string | undefined;
|
||||
vertical?: boolean;
|
||||
};
|
||||
|
||||
export default function Separator({ className, vertical }: SeparatorProps) {
|
||||
if (vertical) {
|
||||
return <div className={cn("border-text/15 h-full border-r", className)} />;
|
||||
}
|
||||
return <div className={cn("border-text/15 w-full border-b", className)} />;
|
||||
}
|
||||
492
src/components/UI/Sortable.tsx
Normal file
492
src/components/UI/Sortable.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type Announcements,
|
||||
closestCenter,
|
||||
closestCorners,
|
||||
DndContext,
|
||||
type DndContextProps,
|
||||
type DragEndEvent,
|
||||
type DraggableAttributes,
|
||||
type DraggableSyntheticListeners,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
type DropAnimation,
|
||||
defaultDropAnimationSideEffects,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
type ScreenReaderInstructions,
|
||||
TouchSensor,
|
||||
type UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToHorizontalAxis, restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import {
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
type SortableContextProps,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { useComposedRefs } from "@/lib/compose-refs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const orientationConfig = {
|
||||
vertical: {
|
||||
modifiers: [restrictToVerticalAxis, restrictToParentElement],
|
||||
strategy: verticalListSortingStrategy,
|
||||
collisionDetection: closestCenter,
|
||||
},
|
||||
horizontal: {
|
||||
modifiers: [restrictToHorizontalAxis, restrictToParentElement],
|
||||
strategy: horizontalListSortingStrategy,
|
||||
collisionDetection: closestCenter,
|
||||
},
|
||||
mixed: {
|
||||
modifiers: [restrictToParentElement],
|
||||
strategy: undefined,
|
||||
collisionDetection: closestCorners,
|
||||
},
|
||||
};
|
||||
|
||||
const ROOT_NAME = "Sortable";
|
||||
const CONTENT_NAME = "SortableContent";
|
||||
const ITEM_NAME = "SortableItem";
|
||||
const ITEM_HANDLE_NAME = "SortableItemHandle";
|
||||
const OVERLAY_NAME = "SortableOverlay";
|
||||
|
||||
interface SortableRootContextValue<T> {
|
||||
id: string;
|
||||
items: UniqueIdentifier[];
|
||||
modifiers: DndContextProps["modifiers"];
|
||||
strategy: SortableContextProps["strategy"];
|
||||
activeId: UniqueIdentifier | null;
|
||||
setActiveId: (id: UniqueIdentifier | null) => void;
|
||||
getItemValue: (item: T) => UniqueIdentifier;
|
||||
flatCursor: boolean;
|
||||
}
|
||||
|
||||
const SortableRootContext = React.createContext<SortableRootContextValue<unknown> | null>(null);
|
||||
|
||||
function useSortableContext(consumerName: string) {
|
||||
const context = React.useContext(SortableRootContext);
|
||||
if (!context) {
|
||||
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface GetItemValue<T> {
|
||||
/**
|
||||
* Callback that returns a unique identifier for each sortable item. Required for array of objects.
|
||||
* @example getItemValue={(item) => item.id}
|
||||
*/
|
||||
getItemValue: (item: T) => UniqueIdentifier;
|
||||
}
|
||||
|
||||
type SortableRootProps<T> = DndContextProps &
|
||||
(T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>) & {
|
||||
value: T[];
|
||||
onValueChange?: (items: T[]) => void;
|
||||
onMove?: (event: DragEndEvent & { activeIndex: number; overIndex: number }) => void;
|
||||
strategy?: SortableContextProps["strategy"];
|
||||
orientation?: "vertical" | "horizontal" | "mixed";
|
||||
flatCursor?: boolean;
|
||||
};
|
||||
|
||||
function SortableRoot<T>(props: SortableRootProps<T>) {
|
||||
const {
|
||||
value,
|
||||
onValueChange,
|
||||
collisionDetection,
|
||||
modifiers,
|
||||
strategy,
|
||||
onMove,
|
||||
orientation = "vertical",
|
||||
flatCursor = false,
|
||||
getItemValue: getItemValueProp,
|
||||
accessibility,
|
||||
...sortableProps
|
||||
} = props;
|
||||
|
||||
const id = React.useId();
|
||||
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor),
|
||||
useSensor(TouchSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
const config = React.useMemo(() => orientationConfig[orientation], [orientation]);
|
||||
|
||||
const getItemValue = React.useCallback(
|
||||
(item: T): UniqueIdentifier => {
|
||||
if (typeof item === "object" && !getItemValueProp) {
|
||||
throw new Error("getItemValue is required when using array of objects");
|
||||
}
|
||||
return getItemValueProp ? getItemValueProp(item) : (item as UniqueIdentifier);
|
||||
},
|
||||
[getItemValueProp]
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
return value.map((item) => getItemValue(item));
|
||||
}, [value, getItemValue]);
|
||||
|
||||
const onDragStart = (event: DragStartEvent) => {
|
||||
sortableProps.onDragStart?.(event);
|
||||
|
||||
if (event.activatorEvent.defaultPrevented) return;
|
||||
|
||||
setActiveId(event.active.id);
|
||||
};
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
sortableProps.onDragEnd?.(event);
|
||||
|
||||
if (event.activatorEvent.defaultPrevented) return;
|
||||
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over?.id) {
|
||||
const activeIndex = value.findIndex((item) => getItemValue(item) === active.id);
|
||||
const overIndex = value.findIndex((item) => getItemValue(item) === over.id);
|
||||
|
||||
if (onMove) {
|
||||
onMove({ ...event, activeIndex, overIndex });
|
||||
} else {
|
||||
onValueChange?.(arrayMove(value, activeIndex, overIndex));
|
||||
}
|
||||
}
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const onDragCancel = (event: DragEndEvent) => {
|
||||
sortableProps.onDragCancel?.(event);
|
||||
|
||||
if (event.activatorEvent.defaultPrevented) return;
|
||||
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const announcements: Announcements = React.useMemo(
|
||||
() => ({
|
||||
onDragStart({ active }) {
|
||||
const activeValue = active.id.toString();
|
||||
return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`;
|
||||
},
|
||||
onDragOver({ active, over }) {
|
||||
if (over) {
|
||||
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||
const moveDirection = overIndex > activeIndex ? "down" : "up";
|
||||
const activeValue = active.id.toString();
|
||||
return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
|
||||
}
|
||||
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
|
||||
},
|
||||
onDragEnd({ active, over }) {
|
||||
const activeValue = active.id.toString();
|
||||
if (over) {
|
||||
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||
return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`;
|
||||
}
|
||||
return `Sortable item "${activeValue}" dropped. No changes were made.`;
|
||||
},
|
||||
onDragCancel({ active }) {
|
||||
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||
const activeValue = active.id.toString();
|
||||
return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`;
|
||||
},
|
||||
onDragMove({ active, over }) {
|
||||
if (over) {
|
||||
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||
const moveDirection = overIndex > activeIndex ? "down" : "up";
|
||||
const activeValue = active.id.toString();
|
||||
return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
|
||||
}
|
||||
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
|
||||
},
|
||||
}),
|
||||
[value]
|
||||
);
|
||||
|
||||
const screenReaderInstructions: ScreenReaderInstructions = React.useMemo(
|
||||
() => ({
|
||||
draggable: `
|
||||
To pick up a sortable item, press space or enter.
|
||||
While dragging, use the ${orientation === "vertical" ? "up and down" : orientation === "horizontal" ? "left and right" : "arrow"} keys to move the item.
|
||||
Press space or enter again to drop the item in its new position, or press escape to cancel.
|
||||
`,
|
||||
}),
|
||||
[orientation]
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
id,
|
||||
items,
|
||||
modifiers: modifiers ?? config.modifiers,
|
||||
strategy: strategy ?? config.strategy,
|
||||
activeId,
|
||||
setActiveId,
|
||||
getItemValue,
|
||||
flatCursor,
|
||||
}),
|
||||
[id, items, modifiers, strategy, config.modifiers, config.strategy, activeId, getItemValue, flatCursor]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableRootContext.Provider value={contextValue as SortableRootContextValue<unknown>}>
|
||||
<DndContext
|
||||
collisionDetection={collisionDetection ?? config.collisionDetection}
|
||||
modifiers={modifiers ?? config.modifiers}
|
||||
sensors={sensors}
|
||||
{...sortableProps}
|
||||
id={id}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={onDragCancel}
|
||||
accessibility={{
|
||||
announcements,
|
||||
screenReaderInstructions,
|
||||
...accessibility,
|
||||
}}
|
||||
/>
|
||||
</SortableRootContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const SortableContentContext = React.createContext<boolean>(false);
|
||||
|
||||
interface SortableContentProps extends React.ComponentProps<"div"> {
|
||||
strategy?: SortableContextProps["strategy"];
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
withoutSlot?: boolean;
|
||||
}
|
||||
|
||||
function SortableContent(props: SortableContentProps) {
|
||||
const { strategy: strategyProp, asChild, withoutSlot, children, ref, ...contentProps } = props;
|
||||
|
||||
const context = useSortableContext(CONTENT_NAME);
|
||||
|
||||
const ContentPrimitive = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<SortableContentContext.Provider value={true}>
|
||||
<SortableContext items={context.items} strategy={strategyProp ?? context.strategy}>
|
||||
{withoutSlot ? (
|
||||
children
|
||||
) : (
|
||||
<ContentPrimitive data-slot="sortable-content" {...contentProps} ref={ref}>
|
||||
{children}
|
||||
</ContentPrimitive>
|
||||
)}
|
||||
</SortableContext>
|
||||
</SortableContentContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableItemContextValue {
|
||||
id: string;
|
||||
attributes: DraggableAttributes;
|
||||
listeners: DraggableSyntheticListeners | undefined;
|
||||
setActivatorNodeRef: (node: HTMLElement | null) => void;
|
||||
isDragging?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SortableItemContext = React.createContext<SortableItemContextValue | null>(null);
|
||||
|
||||
function useSortableItemContext(consumerName: string) {
|
||||
const context = React.useContext(SortableItemContext);
|
||||
if (!context) {
|
||||
throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface SortableItemProps extends React.ComponentProps<"div"> {
|
||||
value: UniqueIdentifier;
|
||||
asHandle?: boolean;
|
||||
asChild?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function SortableItem(props: SortableItemProps) {
|
||||
const { value, style, asHandle, asChild, disabled, className, ref, ...itemProps } = props;
|
||||
|
||||
const inSortableContent = React.useContext(SortableContentContext);
|
||||
const inSortableOverlay = React.useContext(SortableOverlayContext);
|
||||
|
||||
if (!inSortableContent && !inSortableOverlay) {
|
||||
throw new Error(`\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``);
|
||||
}
|
||||
|
||||
if (value === "") {
|
||||
throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`);
|
||||
}
|
||||
|
||||
const context = useSortableContext(ITEM_NAME);
|
||||
const id = React.useId();
|
||||
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: value,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const composedRef = useComposedRefs(ref, (node) => {
|
||||
if (disabled) return;
|
||||
setNodeRef(node);
|
||||
if (asHandle) setActivatorNodeRef(node);
|
||||
});
|
||||
|
||||
const composedStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
return {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
...style,
|
||||
};
|
||||
}, [transform, transition, style]);
|
||||
|
||||
const itemContext = React.useMemo<SortableItemContextValue>(
|
||||
() => ({
|
||||
id,
|
||||
attributes,
|
||||
listeners,
|
||||
setActivatorNodeRef,
|
||||
isDragging,
|
||||
disabled,
|
||||
}),
|
||||
[id, attributes, listeners, setActivatorNodeRef, isDragging, disabled]
|
||||
);
|
||||
|
||||
const ItemPrimitive = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<SortableItemContext.Provider value={itemContext}>
|
||||
<ItemPrimitive
|
||||
id={id}
|
||||
data-disabled={disabled}
|
||||
data-dragging={isDragging ? "" : undefined}
|
||||
data-slot="sortable-item"
|
||||
{...itemProps}
|
||||
{...(asHandle && !disabled ? attributes : {})}
|
||||
{...(asHandle && !disabled ? listeners : {})}
|
||||
ref={composedRef}
|
||||
style={composedStyle}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden",
|
||||
{
|
||||
"touch-none select-none": asHandle,
|
||||
"cursor-default": context.flatCursor,
|
||||
"data-dragging:cursor-grabbing": !context.flatCursor,
|
||||
"cursor-grab": !isDragging && asHandle && !context.flatCursor,
|
||||
"opacity-50": isDragging,
|
||||
"pointer-events-none opacity-50": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</SortableItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableItemHandleProps extends React.ComponentProps<"button"> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function SortableItemHandle(props: SortableItemHandleProps) {
|
||||
const { asChild, disabled, className, ref, ...itemHandleProps } = props;
|
||||
|
||||
const context = useSortableContext(ITEM_HANDLE_NAME);
|
||||
const itemContext = useSortableItemContext(ITEM_HANDLE_NAME);
|
||||
|
||||
const isDisabled = disabled ?? itemContext.disabled;
|
||||
|
||||
const composedRef = useComposedRefs(ref, (node) => {
|
||||
if (!isDisabled) return;
|
||||
itemContext.setActivatorNodeRef(node);
|
||||
});
|
||||
|
||||
const HandlePrimitive = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<HandlePrimitive
|
||||
type="button"
|
||||
aria-controls={itemContext.id}
|
||||
data-disabled={isDisabled}
|
||||
data-dragging={itemContext.isDragging ? "" : undefined}
|
||||
data-slot="sortable-item-handle"
|
||||
{...itemHandleProps}
|
||||
{...(isDisabled ? {} : itemContext.attributes)}
|
||||
{...(isDisabled ? {} : itemContext.listeners)}
|
||||
ref={composedRef}
|
||||
className={cn(
|
||||
"select-none disabled:pointer-events-none disabled:opacity-50",
|
||||
context.flatCursor ? "cursor-default" : "cursor-grab data-dragging:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SortableOverlayContext = React.createContext(false);
|
||||
|
||||
const dropAnimation: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: "0.4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
interface SortableOverlayProps extends Omit<React.ComponentProps<typeof DragOverlay>, "children"> {
|
||||
container?: Element | DocumentFragment | null;
|
||||
children?: ((params: { value: UniqueIdentifier }) => React.ReactNode) | React.ReactNode;
|
||||
}
|
||||
|
||||
function SortableOverlay(props: SortableOverlayProps) {
|
||||
const { container: containerProp, children, ...overlayProps } = props;
|
||||
|
||||
const context = useSortableContext(OVERLAY_NAME);
|
||||
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
React.useLayoutEffect(() => setMounted(true), []);
|
||||
|
||||
const container = containerProp ?? (mounted ? globalThis.document?.body : null);
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<DragOverlay
|
||||
dropAnimation={dropAnimation}
|
||||
modifiers={context.modifiers}
|
||||
className={cn(!context.flatCursor && "cursor-grabbing")}
|
||||
{...overlayProps}
|
||||
>
|
||||
<SortableOverlayContext.Provider value={true}>
|
||||
{context.activeId
|
||||
? typeof children === "function"
|
||||
? children({ value: context.activeId })
|
||||
: children
|
||||
: null}
|
||||
</SortableOverlayContext.Provider>
|
||||
</DragOverlay>,
|
||||
container
|
||||
);
|
||||
}
|
||||
|
||||
export { SortableRoot as Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay };
|
||||
48
src/components/UI/Tooltip.tsx
Normal file
48
src/components/UI/Tooltip.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-text text-desktop-primary z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-text fill-text z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-xs" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
Reference in New Issue
Block a user