feat : use custom color picker
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
@@ -80,6 +81,16 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
ColorPicker,
|
||||
ColorPickerHue,
|
||||
ColorPickerSelection,
|
||||
} from "@/components/ui/color-picker";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
@@ -559,17 +570,49 @@ function ColorField({
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const invalid = !normalizeHex(value);
|
||||
const handlePickerChange = useCallback(
|
||||
(color: number[] | Record<string, number>) => {
|
||||
if (!Array.isArray(color)) return;
|
||||
const hex = color
|
||||
.slice(0, 3)
|
||||
.map((channel) => Math.round(channel).toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
onChange(`#${hex.toUpperCase()}`);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Field data-invalid={invalid}>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={`${label} picker`}
|
||||
type="color"
|
||||
value={normalizeHex(value) ?? "#000000"}
|
||||
onChange={(event) => onChange(event.target.value.toUpperCase())}
|
||||
className="w-14 shrink-0 p-1"
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={`เลือก${label}`}
|
||||
>
|
||||
<span
|
||||
className="size-5 rounded-sm border"
|
||||
style={{ backgroundColor: normalizeHex(value) ?? "#000000" }}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-64">
|
||||
<ColorPicker
|
||||
value={normalizeHex(value) ?? "#000000"}
|
||||
onChange={handlePickerChange}
|
||||
className="h-auto"
|
||||
>
|
||||
<ColorPickerSelection className="h-32" />
|
||||
<ColorPickerHue />
|
||||
</ColorPicker>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
"use client"
|
||||
|
||||
import Color from "color"
|
||||
import { PipetteIcon } from "lucide-react"
|
||||
import { Slider } from "radix-ui"
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ColorPickerContextValue {
|
||||
hue: number
|
||||
saturation: number
|
||||
lightness: number
|
||||
alpha: number
|
||||
mode: string
|
||||
setHue: (hue: number) => void
|
||||
setSaturation: (saturation: number) => void
|
||||
setLightness: (lightness: number) => void
|
||||
setAlpha: (alpha: number) => void
|
||||
setMode: (mode: string) => void
|
||||
}
|
||||
|
||||
const ColorPickerContext = createContext<ColorPickerContextValue | undefined>(undefined)
|
||||
|
||||
export const useColorPicker = () => {
|
||||
const context = useContext(ColorPickerContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useColorPicker must be used within a ColorPickerProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export type ColorPickerProps = HTMLAttributes<HTMLDivElement> & {
|
||||
value?: Parameters<typeof Color>[0]
|
||||
defaultValue?: Parameters<typeof Color>[0]
|
||||
onChange?: (value: Parameters<typeof Color.rgb>[0]) => void
|
||||
}
|
||||
|
||||
export const ColorPicker = ({
|
||||
value,
|
||||
defaultValue = "#000000",
|
||||
onChange,
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerProps) => {
|
||||
const selectedColor = Color(value)
|
||||
const defaultColor = Color(defaultValue)
|
||||
|
||||
const [hue, setHue] = useState(selectedColor.hue() || defaultColor.hue() || 0)
|
||||
const [saturation, setSaturation] = useState(
|
||||
selectedColor.saturationl() || defaultColor.saturationl() || 100,
|
||||
)
|
||||
const [lightness, setLightness] = useState(
|
||||
selectedColor.lightness() || defaultColor.lightness() || 50,
|
||||
)
|
||||
const [alpha, setAlpha] = useState(selectedColor.alpha() * 100 || defaultColor.alpha() * 100)
|
||||
const [mode, setMode] = useState("hex")
|
||||
|
||||
// Update color when controlled value changes
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const color = Color(value).hsl()
|
||||
|
||||
setHue(color.hue() || 0)
|
||||
setSaturation(color.saturationl())
|
||||
setLightness(color.lightness())
|
||||
setAlpha(color.alpha() * 100)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Notify parent of changes
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
const color = Color.hsl(hue, saturation, lightness).alpha(alpha / 100)
|
||||
const rgba = color.rgb().array()
|
||||
|
||||
onChange([rgba[0], rgba[1], rgba[2], alpha / 100])
|
||||
}
|
||||
}, [hue, saturation, lightness, alpha, onChange])
|
||||
|
||||
return (
|
||||
<ColorPickerContext.Provider
|
||||
value={{
|
||||
hue,
|
||||
saturation,
|
||||
lightness,
|
||||
alpha,
|
||||
mode,
|
||||
setHue,
|
||||
setSaturation,
|
||||
setLightness,
|
||||
setAlpha,
|
||||
setMode,
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex size-full flex-col gap-4", className)} {...(props as any)} />
|
||||
</ColorPickerContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type ColorPickerSelectionProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const ColorPickerSelection = memo(({ className, ...props }: ColorPickerSelectionProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [positionX, setPositionX] = useState(0)
|
||||
const [positionY, setPositionY] = useState(0)
|
||||
const { hue, setSaturation, setLightness } = useColorPicker()
|
||||
|
||||
const backgroundGradient = useMemo(() => {
|
||||
return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)),
|
||||
linear-gradient(90deg, rgba(255,255,255,1), rgba(255,255,255,0)),
|
||||
hsl(${hue}, 100%, 50%)`
|
||||
}, [hue])
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: PointerEvent) => {
|
||||
if (!(isDragging && containerRef.current)) {
|
||||
return
|
||||
}
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
|
||||
const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))
|
||||
setPositionX(x)
|
||||
setPositionY(y)
|
||||
setSaturation(x * 100)
|
||||
const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x)
|
||||
const lightness = topLightness * (1 - y)
|
||||
|
||||
setLightness(lightness)
|
||||
},
|
||||
[isDragging, setSaturation, setLightness],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerUp = () => setIsDragging(false)
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener("pointermove", handlePointerMove)
|
||||
window.addEventListener("pointerup", handlePointerUp)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove)
|
||||
window.removeEventListener("pointerup", handlePointerUp)
|
||||
}
|
||||
}, [isDragging, handlePointerMove])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative size-full cursor-crosshair rounded", className)}
|
||||
onPointerDown={e => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
handlePointerMove(e.nativeEvent)
|
||||
}}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
background: backgroundGradient,
|
||||
}}
|
||||
{...(props as any)}
|
||||
>
|
||||
<div
|
||||
className="-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute h-4 w-4 rounded-full border-2 border-white"
|
||||
style={{
|
||||
left: `${positionX * 100}%`,
|
||||
top: `${positionY * 100}%`,
|
||||
boxShadow: "0 0 0 1px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ColorPickerSelection.displayName = "ColorPickerSelection"
|
||||
|
||||
export type ColorPickerHueProps = ComponentProps<typeof Slider.Root>
|
||||
|
||||
export const ColorPickerHue = ({ className, ...props }: ColorPickerHueProps) => {
|
||||
const { hue, setHue } = useColorPicker()
|
||||
|
||||
return (
|
||||
<Slider.Root
|
||||
className={cn("relative flex h-4 w-full touch-none", className)}
|
||||
max={360}
|
||||
onValueChange={([hue]) => setHue(hue)}
|
||||
step={1}
|
||||
value={[hue]}
|
||||
{...(props as any)}
|
||||
>
|
||||
<Slider.Track className="relative my-0.5 h-3 w-full grow rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)]">
|
||||
<Slider.Range className="absolute h-full" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</Slider.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export type ColorPickerAlphaProps = ComponentProps<typeof Slider.Root>
|
||||
|
||||
export const ColorPickerAlpha = ({ className, ...props }: ColorPickerAlphaProps) => {
|
||||
const { alpha, setAlpha } = useColorPicker()
|
||||
|
||||
return (
|
||||
<Slider.Root
|
||||
className={cn("relative flex h-4 w-full touch-none", className)}
|
||||
max={100}
|
||||
onValueChange={([alpha]) => setAlpha(alpha)}
|
||||
step={1}
|
||||
value={[alpha]}
|
||||
{...(props as any)}
|
||||
>
|
||||
<Slider.Track
|
||||
className="relative my-0.5 h-3 w-full grow rounded-full"
|
||||
style={{
|
||||
background:
|
||||
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-transparent to-black/50" />
|
||||
<Slider.Range className="absolute h-full rounded-full bg-transparent" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</Slider.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export type ColorPickerEyeDropperProps = ComponentProps<typeof Button>
|
||||
|
||||
export const ColorPickerEyeDropper = ({ className, ...props }: ColorPickerEyeDropperProps) => {
|
||||
const { setHue, setSaturation, setLightness, setAlpha } = useColorPicker()
|
||||
|
||||
const handleEyeDropper = async () => {
|
||||
try {
|
||||
// @ts-expect-error - EyeDropper API is experimental
|
||||
const eyeDropper = new EyeDropper()
|
||||
const result = await eyeDropper.open()
|
||||
const color = Color(result.sRGBHex)
|
||||
const [h, s, l] = color.hsl().array()
|
||||
|
||||
setHue(h)
|
||||
setSaturation(s)
|
||||
setLightness(l)
|
||||
setAlpha(100)
|
||||
} catch (error) {
|
||||
console.error("EyeDropper failed:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0 text-muted-foreground", className)}
|
||||
onClick={handleEyeDropper}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...(props as any)}
|
||||
>
|
||||
<PipetteIcon size={16} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type ColorPickerOutputProps = ComponentProps<typeof SelectTrigger>
|
||||
|
||||
const formats = ["hex", "rgb", "css", "hsl"]
|
||||
|
||||
export const ColorPickerOutput = ({ className, ...props }: ColorPickerOutputProps) => {
|
||||
const { mode, setMode } = useColorPicker()
|
||||
|
||||
return (
|
||||
<Select onValueChange={setMode} value={mode}>
|
||||
<SelectTrigger className="h-8 w-20 shrink-0 text-xs" {...(props as any)}>
|
||||
<SelectValue placeholder="Mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formats.map(format => (
|
||||
<SelectItem className="text-xs" key={format} value={format}>
|
||||
{format.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
type PercentageInputProps = ComponentProps<typeof Input>
|
||||
|
||||
const PercentageInput = ({ className, ...props }: PercentageInputProps) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
readOnly
|
||||
type="text"
|
||||
{...(props as any)}
|
||||
className={cn(
|
||||
"h-8 w-[3.25rem] rounded-l-none bg-secondary px-2 text-xs shadow-none",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
<span className="-translate-y-1/2 absolute top-1/2 right-2 text-muted-foreground text-xs">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ColorPickerFormatProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const ColorPickerFormat = ({ className, ...props }: ColorPickerFormatProps) => {
|
||||
const { hue, saturation, lightness, alpha, mode } = useColorPicker()
|
||||
const color = Color.hsl(hue, saturation, lightness, alpha / 100)
|
||||
|
||||
if (mode === "hex") {
|
||||
const hex = color.hex()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"-space-x-px relative flex w-full items-center rounded-md shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...(props as any)}
|
||||
>
|
||||
<Input
|
||||
className="h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none"
|
||||
readOnly
|
||||
type="text"
|
||||
value={hex}
|
||||
/>
|
||||
<PercentageInput value={alpha} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === "rgb") {
|
||||
const rgb = color
|
||||
.rgb()
|
||||
.array()
|
||||
.map(value => Math.round(value))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("-space-x-px flex items-center rounded-md shadow-sm", className)}
|
||||
{...(props as any)}
|
||||
>
|
||||
{rgb.map((value, index) => (
|
||||
<Input
|
||||
className={cn(
|
||||
"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none",
|
||||
index && "rounded-l-none",
|
||||
className,
|
||||
)}
|
||||
key={index}
|
||||
readOnly
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
<PercentageInput value={alpha} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === "css") {
|
||||
const rgb = color
|
||||
.rgb()
|
||||
.array()
|
||||
.map(value => Math.round(value))
|
||||
|
||||
return (
|
||||
<div className={cn("w-full rounded-md shadow-sm", className)} {...(props as any)}>
|
||||
<Input
|
||||
className="h-8 w-full bg-secondary px-2 text-xs shadow-none"
|
||||
readOnly
|
||||
type="text"
|
||||
value={`rgba(${rgb.join(", ")}, ${alpha}%)`}
|
||||
{...(props as any)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === "hsl") {
|
||||
const hsl = color
|
||||
.hsl()
|
||||
.array()
|
||||
.map(value => Math.round(value))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("-space-x-px flex items-center rounded-md shadow-sm", className)}
|
||||
{...(props as any)}
|
||||
>
|
||||
{hsl.map((value, index) => (
|
||||
<Input
|
||||
className={cn(
|
||||
"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none",
|
||||
index && "rounded-l-none",
|
||||
className,
|
||||
)}
|
||||
key={index}
|
||||
readOnly
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
<PercentageInput value={alpha} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Demo
|
||||
export function Demo() {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center p-8">
|
||||
<ColorPicker defaultValue="#6366f1" className="h-auto w-64">
|
||||
<ColorPickerSelection className="h-40 rounded-lg" />
|
||||
<ColorPickerHue />
|
||||
<ColorPickerAlpha />
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorPickerEyeDropper />
|
||||
<ColorPickerOutput />
|
||||
<ColorPickerFormat />
|
||||
</div>
|
||||
</ColorPicker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
Reference in New Issue
Block a user