feat(ai-character): 커뮤니티 생성 미디어 흐름 개선
This commit is contained in:
37
src/shared/ui/__tests__/can-price-field.test.tsx
Normal file
37
src/shared/ui/__tests__/can-price-field.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { CanPriceField } from "@/shared/ui/can-price-field";
|
||||
|
||||
test("CanPriceField renders the native numeric CAN price contract", () => {
|
||||
render(<CanPriceField error={undefined} errorId="price-error" onChange={() => undefined} value="0" />);
|
||||
|
||||
const input = screen.getByLabelText("가격");
|
||||
expect(input).toHaveAttribute("type", "number");
|
||||
expect(input).toHaveAttribute("inputmode", "numeric");
|
||||
expect(input).toHaveAttribute("min", "0");
|
||||
expect(input).toHaveAttribute("step", "1");
|
||||
expect(input).toHaveAccessibleDescription("단위: 캔");
|
||||
});
|
||||
|
||||
test.each([
|
||||
"-1",
|
||||
"1.5",
|
||||
])("CanPriceField emits the raw input value %s unchanged", (rawValue) => {
|
||||
const onChange = vi.fn<(value: string) => void>();
|
||||
render(<CanPriceField error={undefined} errorId="price-error" onChange={onChange} value="0" />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(rawValue);
|
||||
});
|
||||
|
||||
test("CanPriceField links the visible error and unit description to the invalid input", () => {
|
||||
render(<CanPriceField error="가격 오류" errorId="price-error" onChange={() => undefined} value="100000" />);
|
||||
|
||||
const input = screen.getByLabelText("가격");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(input.getAttribute("aria-describedby")?.split(" ")).toContain("price-error");
|
||||
expect(input).toHaveAccessibleDescription("단위: 캔 가격 오류");
|
||||
expect(screen.getByRole("alert")).toHaveAttribute("id", "price-error");
|
||||
});
|
||||
@@ -1,8 +1,36 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { StrictMode } from "react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { FileField } from "@/shared/ui/file-field";
|
||||
|
||||
const createObjectUrl = vi.fn<(blob: Blob) => string>();
|
||||
const revokeObjectUrl = vi.fn<(url: string) => void>();
|
||||
let originalCreateObjectUrl: PropertyDescriptor | undefined;
|
||||
let originalRevokeObjectUrl: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
createObjectUrl.mockReset().mockImplementation(() => `blob:preview-${createObjectUrl.mock.calls.length}`);
|
||||
revokeObjectUrl.mockReset();
|
||||
originalCreateObjectUrl = Object.getOwnPropertyDescriptor(URL, "createObjectURL");
|
||||
originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL");
|
||||
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectUrl });
|
||||
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectUrl });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCreateObjectUrl === undefined) {
|
||||
Reflect.deleteProperty(URL, "createObjectURL");
|
||||
} else {
|
||||
Object.defineProperty(URL, "createObjectURL", originalCreateObjectUrl);
|
||||
}
|
||||
if (originalRevokeObjectUrl === undefined) {
|
||||
Reflect.deleteProperty(URL, "revokeObjectURL");
|
||||
} else {
|
||||
Object.defineProperty(URL, "revokeObjectURL", originalRevokeObjectUrl);
|
||||
}
|
||||
});
|
||||
|
||||
test("FileField exposes label, description, error, accept guidance, keyboard file input, and controlled value", () => {
|
||||
const onChange = vi.fn();
|
||||
const value = new File(["image"], "profile.png", { type: "image/png" });
|
||||
@@ -23,9 +51,11 @@ test("FileField exposes label, description, error, accept guidance, keyboard fil
|
||||
expect(input).toHaveAttribute("accept", "image/png");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다.");
|
||||
expect(screen.getByText("프로필 이미지를 선택하세요.")).toHaveClass("break-keep");
|
||||
expect(screen.getByText("PNG만 업로드할 수 있습니다.")).toHaveClass("break-keep");
|
||||
expect(screen.getByRole("button", { name: "대표 이미지 파일 선택" })).toBeInTheDocument();
|
||||
expect(screen.getByText("profile.png")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "선택 취소" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "대표 이미지 선택 취소" })).toHaveTextContent("선택 취소");
|
||||
});
|
||||
|
||||
test("FileField emits File or null and clear selection without owning upload policy", () => {
|
||||
@@ -37,10 +67,62 @@ test("FileField emits File or null and clear selection without owning upload pol
|
||||
expect(onChange).toHaveBeenCalledWith(selected);
|
||||
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={selected} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "오디오 선택 취소" }));
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test("FileField clears the native selection when the controlled value is cleared externally", () => {
|
||||
// Given
|
||||
const selected = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
|
||||
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
|
||||
const input = screen.getByLabelText<HTMLInputElement>("오디오");
|
||||
fireEvent.change(input, { target: { files: [selected] } });
|
||||
let nativeValue = "C:\\fakepath\\voice.mp3";
|
||||
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={selected} />);
|
||||
expect(input.files?.[0]).toBe(selected);
|
||||
expect(input.value).toBe("C:\\fakepath\\voice.mp3");
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
|
||||
|
||||
// Then
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
test("FileField clears a rejected native selection when only the error changes", () => {
|
||||
// Given
|
||||
const rejected = new File(["audio"], "voice.wav", { type: "audio/wav" });
|
||||
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
|
||||
const input = screen.getByLabelText<HTMLInputElement>("오디오");
|
||||
fireEvent.change(input, { target: { files: [rejected] } });
|
||||
let nativeValue = "C:\\fakepath\\voice.wav";
|
||||
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" error="지원하지 않는 파일입니다." label="오디오" onChange={vi.fn()} value={null} />);
|
||||
|
||||
// Then
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
test("FileField clears the native selection when the controlled value is a different File", () => {
|
||||
// Given
|
||||
const selected = new File(["source"], "profile.png", { type: "image/png" });
|
||||
const cropped = new File(["cropped"], "profile.png", { type: "image/png" });
|
||||
const { rerender } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={null} />);
|
||||
const input = screen.getByLabelText<HTMLInputElement>("대표 이미지");
|
||||
fireEvent.change(input, { target: { files: [selected] } });
|
||||
let nativeValue = "C:\\fakepath\\profile.png";
|
||||
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={cropped} />);
|
||||
|
||||
// Then
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
test("FileField gives same visible file buttons field-specific accessible names", () => {
|
||||
render(
|
||||
<>
|
||||
@@ -66,3 +148,102 @@ test("FileField visible container exposes the focus-within ring contract", () =>
|
||||
expect(input).toHaveFocus();
|
||||
expect(container.firstElementChild).toHaveClass("focus-within:ring-2", "focus-within:ring-ring", "focus-within:ring-offset-2");
|
||||
});
|
||||
|
||||
test("FileField reserves a bounded preview frame for the current image value", async () => {
|
||||
// Given
|
||||
const firstImage = new File(["first"], "first.png", { type: "image/png" });
|
||||
const secondImage = new File(["second"], "second.png", { type: "image/png" });
|
||||
createObjectUrl.mockImplementation((blob) => blob === firstImage ? "blob:first" : "blob:second");
|
||||
const { rerender } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={firstImage} />);
|
||||
|
||||
// When
|
||||
const frame = screen.getByRole("figure", { name: "대표 이미지 업로드 미리보기 영역" });
|
||||
|
||||
// Then
|
||||
expect(frame).toHaveClass("aspect-video", "max-h-64", "w-full", "max-w-md");
|
||||
expect(await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:first");
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={secondImage} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("figure", { name: "대표 이미지 업로드 미리보기 영역" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:second"));
|
||||
});
|
||||
|
||||
test("FileField revokes every object URL created under StrictMode", async () => {
|
||||
// Given
|
||||
const image = new File(["image"], "profile.png", { type: "image/png" });
|
||||
|
||||
// When
|
||||
const { unmount } = render(
|
||||
<StrictMode>
|
||||
<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={image} />
|
||||
</StrictMode>,
|
||||
);
|
||||
await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
|
||||
unmount();
|
||||
|
||||
// Then
|
||||
const createdUrls = createObjectUrl.mock.results.map(({ value }) => value);
|
||||
const revokedUrls = revokeObjectUrl.mock.calls.map(([url]) => url);
|
||||
expect(new Set(revokedUrls)).toEqual(new Set(createdUrls));
|
||||
});
|
||||
|
||||
test("FileField previews an image and revokes each owned object URL when its controlled value changes", async () => {
|
||||
// Given
|
||||
const firstImage = new File(["first"], "first.png", { type: "image/png" });
|
||||
const secondImage = new File(["second"], "second.png", { type: "image/png" });
|
||||
const onChange = vi.fn();
|
||||
const { rerender, unmount } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={firstImage} />);
|
||||
|
||||
// When
|
||||
const preview = await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
|
||||
const firstUrl = preview.getAttribute("src");
|
||||
|
||||
// Then
|
||||
expect(firstUrl).toMatch(/^blob:preview-/);
|
||||
expect(createObjectUrl).toHaveBeenCalledWith(firstImage);
|
||||
expect(preview).toHaveClass("h-full", "w-full", "object-contain");
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={secondImage} />);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" }).getAttribute("src")).not.toBe(firstUrl));
|
||||
const secondUrl = screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" }).getAttribute("src");
|
||||
expect(createObjectUrl).toHaveBeenCalledWith(secondImage);
|
||||
expect(revokeObjectUrl).toHaveBeenCalledWith(firstUrl);
|
||||
|
||||
// When
|
||||
fireEvent.click(screen.getByRole("button", { name: "대표 이미지 선택 취소" }));
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={null} />);
|
||||
|
||||
// Then
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
expect(screen.queryByRole("img", { name: "대표 이미지 업로드 미리보기" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(revokeObjectUrl).toHaveBeenCalledWith(secondUrl));
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={firstImage} />);
|
||||
const finalPreview = await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
|
||||
const finalUrl = finalPreview.getAttribute("src");
|
||||
unmount();
|
||||
|
||||
// Then
|
||||
expect(revokeObjectUrl).toHaveBeenCalledWith(finalUrl);
|
||||
});
|
||||
|
||||
test("FileField keeps non-image files filename-only without creating object URLs", () => {
|
||||
// Given
|
||||
const audio = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
|
||||
|
||||
// When
|
||||
render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={audio} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("voice.mp3")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
expect(createObjectUrl).not.toHaveBeenCalled();
|
||||
expect(revokeObjectUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ test("PageState exposes accessible loading, empty, error, retry, and content sta
|
||||
const { rerender } = render(<PageState description="자료를 불러오는 중입니다." state="loading" title="불러오는 중" />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("불러오는 중");
|
||||
expect(screen.getByRole("heading", { name: "불러오는 중" })).toHaveClass("break-keep");
|
||||
expect(screen.getByText("자료를 불러오는 중입니다.")).toHaveClass("break-keep");
|
||||
|
||||
rerender(<PageState description="조건에 맞는 자료가 없습니다." state="empty" title="자료 없음" />);
|
||||
|
||||
|
||||
33
src/shared/ui/can-price-field.tsx
Normal file
33
src/shared/ui/can-price-field.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useId } from "react";
|
||||
|
||||
export type CanPriceFieldProps = {
|
||||
readonly error: string | undefined;
|
||||
readonly errorId: string;
|
||||
readonly onChange: (value: string) => void;
|
||||
readonly value: string;
|
||||
};
|
||||
|
||||
export function CanPriceField({ error, errorId, onChange, value }: CanPriceFieldProps) {
|
||||
const inputId = useId();
|
||||
const unitId = `${inputId}-unit`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold" htmlFor={inputId}>가격</label>
|
||||
<input
|
||||
aria-describedby={`${unitId}${error === undefined ? "" : ` ${errorId}`}`}
|
||||
aria-invalid={error === undefined ? undefined : true}
|
||||
className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground"
|
||||
id={inputId}
|
||||
inputMode="numeric"
|
||||
min="0"
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
step="1"
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground" id={unitId}>단위: 캔</p>
|
||||
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId, useRef } from "react";
|
||||
import { useId, useLayoutEffect, useRef } from "react";
|
||||
|
||||
export type FileFieldProps = {
|
||||
readonly accept: string;
|
||||
@@ -16,8 +16,33 @@ export function FileField({ accept, acceptDescription, description, error, label
|
||||
const acceptId = useId();
|
||||
const errorId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const previewRef = useRef<HTMLImageElement>(null);
|
||||
const isImage = value !== null && value.type.startsWith("image/");
|
||||
const describedBy = [description === undefined ? null : descriptionId, acceptId, error === undefined ? null : errorId].filter((id): id is string => id !== null).join(" ");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (value === null || !value.type.startsWith("image/")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(value);
|
||||
const previewElement = previewRef.current;
|
||||
previewElement?.setAttribute("src", objectUrl);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
previewElement?.removeAttribute("src");
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const input = inputRef.current;
|
||||
const selectedFile = input?.files?.[0];
|
||||
if (input !== null && selectedFile !== undefined && selectedFile !== value) {
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = event.currentTarget.files;
|
||||
onChange(files === null ? null : files[0] ?? null);
|
||||
@@ -33,16 +58,21 @@ export function FileField({ accept, acceptDescription, description, error, label
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
||||
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
|
||||
{description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
|
||||
<p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
|
||||
{description === undefined ? null : <p className="break-keep text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
|
||||
<p className="break-keep text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
|
||||
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="sr-only" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
|
||||
<button aria-label={`${label} 파일 선택`} className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => inputRef.current?.click()} type="button">파일 선택</button>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{value === null ? "선택된 파일 없음" : value.name}</span>
|
||||
{value === null ? null : (
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button">선택 취소</button>
|
||||
<button aria-label={`${label} 선택 취소`} className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button">선택 취소</button>
|
||||
)}
|
||||
</div>
|
||||
{isImage ? (
|
||||
<figure aria-label={`${label} 업로드 미리보기 영역`} className="aspect-video max-h-64 w-full max-w-md overflow-hidden rounded-md border border-border bg-muted p-2">
|
||||
<img alt={`${label} 업로드 미리보기`} className="h-full w-full object-contain" ref={previewRef} />
|
||||
</figure>
|
||||
) : null}
|
||||
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,22 +37,22 @@ export function PageState(props: PageStateProps) {
|
||||
case "loading":
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card p-6" role="status">
|
||||
<h2 className="text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
|
||||
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm text-muted-foreground">{props.description}</p>}
|
||||
</section>
|
||||
);
|
||||
case "empty":
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card p-6" role="status">
|
||||
<h2 className="text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
|
||||
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm text-muted-foreground">{props.description}</p>}
|
||||
</section>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<section className="rounded-lg border border-destructive bg-card p-6 text-destructive" role="alert">
|
||||
<h2 className="text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 text-sm">{props.description}</p>}
|
||||
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm">{props.description}</p>}
|
||||
{props.onRetry === undefined ? null : (
|
||||
<button className="mt-4 rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={props.onRetry} type="button">
|
||||
다시 시도
|
||||
|
||||
Reference in New Issue
Block a user