接线资源筛选浮层并把资源卡旁两个浮层改成互斥
- index.tsx:新增 resourceFilterOpen 与 resourceFilterTags 两个 state,区域不新增 state(唯一新增的筛选相关 state 是已选标签) - index.tsx:可见资源由内联关键词过滤改为调用 filterCanvasResources,维度为区域 / 标签 / 关键词;区域由 resourceBookState 派生(不在分页栏目里时为「全部区域」),因此用户用滚轮 / 总览卡片 / 下一页换栏目时区域自动跟随,无需双向同步 - index.tsx:标签库按「区域 + 关键词」派生并故意不含已选标签——若拿已按标签收窄的集合派生,选了一个标签后列表只剩该标签,用户再也选不出第二个标签,AND 多选会形同废掉 - index.tsx:Dock 上紧邻搜索按钮新增筛选按钮,复用 game-resource-book-zoom-button 与 is-active 口径;两个浮层共用同一个右下角锚点,因此开筛选即收起搜索、开搜索即收起筛选 - index.tsx:挂载 ResourceFilterPanel,区域变更转交既有 openResourceBookChild(与栏目切换同一条路径),关键词与搜索浮层共用同一份 searchText - resourceCanvasFilterModel.ts:resourceFilterIsActive 只看关键词与标签、不含区域——区域就是画布当前栏目,用户换栏时它照样在变,算进高亮会让按钮在「只是换了一栏看」时也常亮 - 新增 resourceFloatingPanelModel.ts 与其测试:资源卡旁「快速编辑」与「生成动画」的互斥规则收在一处并被两个开启入口共用,修掉「先开生成动画再点快速编辑两个浮层并存」的缺口;关闭仍只关自己,生成收尾不会吞掉用户刚打开的另一个面板 - 断言:新增 5 条互斥用例(含两个方向各自一条与来回点击序列),加上既有筛选用例;变异验证实测——把互斥模型的 closed 写成恒为 quick-edit(等价只修一个方向)→ 4 failed,还原后 5 passed
This commit is contained in:
@@ -188,6 +188,13 @@ import {
|
||||
zoomResourceBookViewport,
|
||||
} from './resourceBookViewport';
|
||||
import { createResourceCanvasPageCategorySignature } from './resourceCanvasController';
|
||||
import {
|
||||
buildResourceFilterTagOptions,
|
||||
filterCanvasResources,
|
||||
RESOURCE_FILTER_ALL_REGION,
|
||||
resourceFilterIsActive,
|
||||
type ResourceFilterRegion,
|
||||
} from './resourceCanvasFilterModel';
|
||||
import {
|
||||
createResourceCanvasCardSizeByResourceId,
|
||||
fitResourceCanvasViewportToContent,
|
||||
@@ -231,6 +238,11 @@ import {
|
||||
resolveResourceEditRequestIdentity,
|
||||
type ResourceEditRequestIdentity,
|
||||
} from './resourceEditModel';
|
||||
import { ResourceFilterPanel } from './ResourceFilterPanel';
|
||||
import {
|
||||
openResourceCardFloatingPanelState,
|
||||
type ResourceCardFloatingPanelKind,
|
||||
} from './resourceFloatingPanelModel';
|
||||
import {
|
||||
ResourceInfoFieldsView,
|
||||
ResourceInfoPanelView,
|
||||
@@ -1402,6 +1414,15 @@ export default function ProjectDevelopmentView({
|
||||
*/
|
||||
const [resourceSearchOpen, setResourceSearchOpen] = useState(false);
|
||||
const resourceSearchId = useId();
|
||||
/**
|
||||
* 筛选浮层的开合。与搜索浮层同一个右下角锚点,两者不得同时展开。
|
||||
*
|
||||
* 这里**只**存开合与已选标签;「区域」不存第二份——它就是画布当前栏目,
|
||||
* 由下方 `resourceFilterRegion` 从 `resourceBookState` 派生。
|
||||
*/
|
||||
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
|
||||
const resourceFilterId = useId();
|
||||
const [resourceFilterTags, setResourceFilterTags] = useState<string[]>([]);
|
||||
const [resourceCanvasViewports, setResourceCanvasViewports] =
|
||||
useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports);
|
||||
const resourceCanvasViewportTargetsRef = useRef(
|
||||
@@ -1535,6 +1556,8 @@ export default function ProjectDevelopmentView({
|
||||
/** 右下角 Dock 里的搜索按钮:开合搜索浮层,也是浮层「点外部 / Esc」边界的一半。 */
|
||||
const resourceSearchTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const resourceSearchPopoverRef = useRef<HTMLLabelElement>(null);
|
||||
/** 右下角 Dock 里的筛选按钮:开合筛选浮层,也是该浮层「点外部 / Esc」边界的一半。 */
|
||||
const resourceFilterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const resourceFocusProjectPathRef = useRef(projectPath);
|
||||
/** 框选命中测试用的资源卡图层与当前画本视口,渲染期同步赋值。 */
|
||||
const marqueeHitTestLayersRef = useRef<CanvasLayer[]>([]);
|
||||
@@ -1576,6 +1599,24 @@ export default function ProjectDevelopmentView({
|
||||
resourceCharacterAnimationRequestRef.current = null;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 开启资源卡旁的浮层:**开一个必然收起另一个**。
|
||||
*
|
||||
* 「快速编辑」与「生成动画」挂在同一位置,两个都开就会叠在一起;互斥规则收在
|
||||
* `resourceFloatingPanelModel` 一处并被两个入口共用,因此不会只修好一边。
|
||||
*/
|
||||
const applyResourceCardFloatingPanelOpen = useCallback(
|
||||
(kind: ResourceCardFloatingPanelKind) => {
|
||||
const { closed } = openResourceCardFloatingPanelState(kind);
|
||||
if (closed === 'quick-edit') {
|
||||
closeResourceQuickEditPanel();
|
||||
} else {
|
||||
closeResourceCharacterAnimationPanel();
|
||||
}
|
||||
},
|
||||
[closeResourceCharacterAnimationPanel, closeResourceQuickEditPanel],
|
||||
);
|
||||
|
||||
/**
|
||||
* 清资源画布焦点:对齐美术画布 `clearCanvasFocus()`。
|
||||
*
|
||||
@@ -2212,25 +2253,56 @@ export default function ProjectDevelopmentView({
|
||||
.map((resource) => resource.id),
|
||||
);
|
||||
}, [resources, selectedResourceId]);
|
||||
const normalizedSearch = searchText.trim().toLowerCase();
|
||||
/**
|
||||
* 画布可见资源只由搜索框收窄:功能分类筛选条、画布标签筛选与左侧栏目大纲导航
|
||||
* 已按用户要求移除,分区栏目本身(`categoryOrder` / 栏目标题栏 / 总览缩略卡片)不受影响。
|
||||
* 筛选区域 = 画布当前栏目本身,**不另存一份区域状态**。
|
||||
*
|
||||
* 画布不在分页栏目里(总览态、或停在「所有资源」页)时视为「全部区域」;切换区域
|
||||
* 走下方既有 `openResourceBookChild`,与栏目切换完全同一条路径,因此滚轮 / 总览卡片 /
|
||||
* 下一页换栏目时这里自动跟随,不需要双向同步。
|
||||
*/
|
||||
const resourceFilterRegion: ResourceFilterRegion =
|
||||
resourceBookView === 'child' && resourceBookState.category !== null
|
||||
? (resourceBookState.category as ResourceFilterRegion)
|
||||
: RESOURCE_FILTER_ALL_REGION;
|
||||
/**
|
||||
* 画布可见资源由筛选浮层的三个维度收窄:区域、标签(AND)、关键词。
|
||||
*
|
||||
* 关键词与搜索浮层共用同一份 `searchText`;判据收在 `resourceCanvasFilterModel`
|
||||
* 一处,两个入口对同一关键词给出同一结果。分区栏目本身(`categoryOrder` /
|
||||
* 栏目标题栏 / 总览缩略卡片)不受影响,`visibleResourcesByCategory` 由这里派生,
|
||||
* 所以「区域=具体栏目时只筛该栏目」与「区域=全部时筛全量」共用同一份实现。
|
||||
*/
|
||||
const visibleResources = useMemo(
|
||||
() =>
|
||||
canvasResources.filter((resource) =>
|
||||
normalizedSearch
|
||||
? [
|
||||
resource.label,
|
||||
resource.path,
|
||||
resource.mediaType,
|
||||
resource.taskTitle ?? '',
|
||||
].some((value) => value.toLowerCase().includes(normalizedSearch))
|
||||
: true,
|
||||
),
|
||||
[canvasResources, normalizedSearch],
|
||||
filterCanvasResources(canvasResources, {
|
||||
region: resourceFilterRegion,
|
||||
tags: resourceFilterTags,
|
||||
keyword: searchText,
|
||||
}),
|
||||
[canvasResources, resourceFilterRegion, resourceFilterTags, searchText],
|
||||
);
|
||||
/**
|
||||
* 标签库按「区域 + 关键词」派生,**故意不含已选标签**。
|
||||
*
|
||||
* 若拿上面已经过标签收窄的 `visibleResources` 派生,选了一个标签后列表就只剩该标签,
|
||||
* 用户再也选不出第二个标签,AND 多选等于形同废掉。这里独立算一次区域 + 关键词的
|
||||
* 中间集合,保证「当前区域内、符合关键词的标签」始终可选。
|
||||
*/
|
||||
const resourceFilterTagItems = useMemo(
|
||||
() =>
|
||||
buildResourceFilterTagOptions(
|
||||
filterCanvasResources(canvasResources, {
|
||||
region: resourceFilterRegion,
|
||||
tags: [],
|
||||
keyword: searchText,
|
||||
}),
|
||||
),
|
||||
[canvasResources, resourceFilterRegion, searchText],
|
||||
);
|
||||
const resourceFilterActive = resourceFilterIsActive({
|
||||
tags: resourceFilterTags,
|
||||
keyword: searchText,
|
||||
});
|
||||
const visibleResourceIds = useMemo(
|
||||
() => new Set(visibleResources.map((resource) => resource.id)),
|
||||
[visibleResources],
|
||||
@@ -5348,6 +5420,8 @@ export default function ProjectDevelopmentView({
|
||||
if (!resource) {
|
||||
return;
|
||||
}
|
||||
// 与「生成动画」互斥:两个浮层挂在同一位置,开这个就收起那个。
|
||||
applyResourceCardFloatingPanelOpen('quick-edit');
|
||||
setQuickEditSourceLayer(layer);
|
||||
const panelDraft = createResourceQuickEditPanelDraft(layer);
|
||||
setQuickEditPanel(panelDraft);
|
||||
@@ -5359,7 +5433,7 @@ export default function ProjectDevelopmentView({
|
||||
};
|
||||
setResourceWorkbenchNotice('');
|
||||
},
|
||||
[canvasResources],
|
||||
[applyResourceCardFloatingPanelOpen, canvasResources],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -5542,7 +5616,8 @@ export default function ProjectDevelopmentView({
|
||||
if (!panelDraft) {
|
||||
return;
|
||||
}
|
||||
closeResourceQuickEditPanel();
|
||||
// 与「快速编辑」互斥:两个浮层挂在同一位置,开这个就收起那个。
|
||||
applyResourceCardFloatingPanelOpen('character-animation');
|
||||
setCharacterAnimationSourceLayer(layer);
|
||||
setCharacterAnimationPanel({
|
||||
...panelDraft,
|
||||
@@ -5556,7 +5631,7 @@ export default function ProjectDevelopmentView({
|
||||
};
|
||||
setResourceWorkbenchNotice('');
|
||||
},
|
||||
[canvasResources, closeResourceQuickEditPanel],
|
||||
[applyResourceCardFloatingPanelOpen, canvasResources],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -6367,10 +6442,30 @@ export default function ProjectDevelopmentView({
|
||||
title="搜索资源(Ctrl/Cmd+F)"
|
||||
aria-expanded={resourceSearchOpen}
|
||||
aria-controls={resourceSearchId}
|
||||
onClick={() => setResourceSearchOpen((current) => !current)}
|
||||
onClick={() => {
|
||||
// 搜索与筛选是同一个右下角锚点上的两个浮层,不得同时展开。
|
||||
setResourceFilterOpen(false);
|
||||
setResourceSearchOpen((current) => !current);
|
||||
}}
|
||||
>
|
||||
<Search size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
ref={resourceFilterTriggerRef}
|
||||
type="button"
|
||||
className={`game-resource-book-zoom-button${resourceFilterActive ? ' is-active' : ''}`}
|
||||
aria-label="筛选资源"
|
||||
title="筛选资源"
|
||||
aria-expanded={resourceFilterOpen}
|
||||
aria-controls={resourceFilterId}
|
||||
onClick={() => {
|
||||
// 与搜索浮层互斥:打开筛选即收起搜索,避免两个浮层叠在同一点。
|
||||
setResourceSearchOpen(false);
|
||||
setResourceFilterOpen((current) => !current);
|
||||
}}
|
||||
>
|
||||
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{resourceSearchOpen ? (
|
||||
<label
|
||||
@@ -6400,6 +6495,35 @@ export default function ProjectDevelopmentView({
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{resourceFilterOpen ? (
|
||||
<div id={resourceFilterId}>
|
||||
<ResourceFilterPanel
|
||||
onClose={() => setResourceFilterOpen(false)}
|
||||
keyword={searchText}
|
||||
onKeywordChange={(value) => {
|
||||
advanceFocusGeneration();
|
||||
setSearchText(value);
|
||||
setHiddenCommittedResourceId(null);
|
||||
}}
|
||||
region={resourceFilterRegion}
|
||||
onRegionChange={(region) => {
|
||||
// 区域即画布栏目:走既有栏目切换,不另维护一份区域状态。
|
||||
// 「全部区域」对应既有的「所有资源」分组页。
|
||||
openResourceBookChild(region);
|
||||
}}
|
||||
activeTags={resourceFilterTags}
|
||||
onToggleTag={(tag) => {
|
||||
setResourceFilterTags((current) =>
|
||||
current.includes(tag)
|
||||
? current.filter((item) => item !== tag)
|
||||
: [...current, tag],
|
||||
);
|
||||
}}
|
||||
tagItems={resourceFilterTagItems}
|
||||
triggerRef={resourceFilterTriggerRef}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="game-resource-book-notices">
|
||||
{resourceLayoutReadNotice ? (
|
||||
<div
|
||||
|
||||
+8
-6
@@ -128,13 +128,15 @@ export function filterCanvasResources(
|
||||
*
|
||||
* 供宿主给 Dock 按钮打高亮用:条件不会随浮层收起一起消失,按钮必须能看出
|
||||
* 「当前有筛选」,否则用户会以为资源丢了——沿用搜索按钮既有的 `is-active` 口径。
|
||||
*
|
||||
* **只看关键词与标签两个维度,不含区域**:区域就是画布当前栏目,用户用滚轮滚动
|
||||
* 栏目时它照样在变,把它算进高亮会让按钮在「只是换了一栏看」时也常亮,反而掩盖
|
||||
* 真正的筛选状态。
|
||||
*/
|
||||
export function resourceFilterIsActive(filter: ResourceCanvasFilter) {
|
||||
return (
|
||||
filter.region !== RESOURCE_FILTER_ALL_REGION ||
|
||||
filter.tags.length > 0 ||
|
||||
filter.keyword.trim().length > 0
|
||||
);
|
||||
export function resourceFilterIsActive(
|
||||
filter: Pick<ResourceCanvasFilter, 'tags' | 'keyword'>,
|
||||
) {
|
||||
return filter.tags.length > 0 || filter.keyword.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 资源卡旁那一组浮层——「快速编辑」与「生成动画」——的开关合成。
|
||||
*
|
||||
* 两者挂在同一个选中资源卡旁、同一位置:同时打开就会叠在一起。**互斥规则只有一处**,
|
||||
* 两个开启入口都读它,因此「开动画→点快速编辑」与「开快速编辑→点动画」不会只修好一边。
|
||||
*/
|
||||
export type ResourceCardFloatingPanelKind =
|
||||
| 'quick-edit'
|
||||
| 'character-animation';
|
||||
|
||||
/** 另一个浮层是什么。互斥就是「开 A 必然关 B」,所以只要求出 B 即可。 */
|
||||
export function opposingResourceCardFloatingPanel(
|
||||
kind: ResourceCardFloatingPanelKind,
|
||||
): ResourceCardFloatingPanelKind {
|
||||
return kind === 'quick-edit' ? 'character-animation' : 'quick-edit';
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启某个浮层时,两个浮层各自应当处于什么状态。
|
||||
*
|
||||
* 返回的 `open` 就是被点开的那个,`closed` 是必然被收起的另一个;因此结果里
|
||||
* **永远不可能两个都为真**。
|
||||
*/
|
||||
export function openResourceCardFloatingPanelState(
|
||||
kind: ResourceCardFloatingPanelKind,
|
||||
): {
|
||||
open: ResourceCardFloatingPanelKind;
|
||||
closed: ResourceCardFloatingPanelKind;
|
||||
} {
|
||||
return { open: kind, closed: opposingResourceCardFloatingPanel(kind) };
|
||||
}
|
||||
@@ -256,7 +256,7 @@ describe('资源画布筛选模型', () => {
|
||||
expect(resourceMatchesRegion(UI_RESOURCE, 'character')).toBe(false);
|
||||
});
|
||||
|
||||
it('筛选生效判据:区域非全部、或有标签、或有关键词', () => {
|
||||
it('筛选生效判据只看关键词与标签:区域只是画布当前栏目,不计入高亮', () => {
|
||||
expect(
|
||||
resourceFilterIsActive({ region: 'all', tags: [], keyword: '' }),
|
||||
).toBe(false);
|
||||
@@ -264,9 +264,10 @@ describe('资源画布筛选模型', () => {
|
||||
expect(
|
||||
resourceFilterIsActive({ region: 'all', tags: [], keyword: ' ' }),
|
||||
).toBe(false);
|
||||
// 区域切到别的栏目不等于「有筛选」:用户滚轮换栏时按钮不该常亮。
|
||||
expect(
|
||||
resourceFilterIsActive({ region: 'audio', tags: [], keyword: '' }),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
resourceFilterIsActive({ region: 'all', tags: ['主页'], keyword: '' }),
|
||||
).toBe(true);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
openResourceCardFloatingPanelState,
|
||||
opposingResourceCardFloatingPanel,
|
||||
type ResourceCardFloatingPanelKind,
|
||||
} from '../src/view/project-development/resourceFloatingPanelModel';
|
||||
|
||||
/**
|
||||
* 用一个真实的两布尔状态跑「开启某个浮层」这一步,模拟宿主里两个 state 的实际结果,
|
||||
* 这样断言的是「最终会不会两个都开着」,而不是只看模型返回值的形状。
|
||||
*/
|
||||
function openPanel(
|
||||
state: { quickEditOpen: boolean; characterAnimationOpen: boolean },
|
||||
kind: ResourceCardFloatingPanelKind,
|
||||
) {
|
||||
const { open, closed } = openResourceCardFloatingPanelState(kind);
|
||||
const next = { ...state };
|
||||
if (open === 'quick-edit') next.quickEditOpen = true;
|
||||
else next.characterAnimationOpen = true;
|
||||
if (closed === 'quick-edit') next.quickEditOpen = false;
|
||||
else next.characterAnimationOpen = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
const BOTH_CLOSED = { quickEditOpen: false, characterAnimationOpen: false };
|
||||
|
||||
describe('资源卡旁浮层的互斥开关', () => {
|
||||
it('互相是对方的对端,且对端是自身的逆', () => {
|
||||
expect(opposingResourceCardFloatingPanel('quick-edit')).toBe(
|
||||
'character-animation',
|
||||
);
|
||||
expect(opposingResourceCardFloatingPanel('character-animation')).toBe(
|
||||
'quick-edit',
|
||||
);
|
||||
for (const kind of [
|
||||
'quick-edit',
|
||||
'character-animation',
|
||||
] as const satisfies readonly ResourceCardFloatingPanelKind[]) {
|
||||
expect(
|
||||
opposingResourceCardFloatingPanel(
|
||||
opposingResourceCardFloatingPanel(kind),
|
||||
),
|
||||
).toBe(kind);
|
||||
}
|
||||
});
|
||||
|
||||
it('开一个必然关另一个:返回的 open 与 closed 永远不同', () => {
|
||||
for (const kind of [
|
||||
'quick-edit',
|
||||
'character-animation',
|
||||
] as const satisfies readonly ResourceCardFloatingPanelKind[]) {
|
||||
const { open, closed } = openResourceCardFloatingPanelState(kind);
|
||||
expect(open).toBe(kind);
|
||||
expect(closed).not.toBe(open);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 变异验证①:把 `openResourceCardFloatingPanelState` 的 `closed` 写成恒为
|
||||
* `'quick-edit'`(或删掉对端推导),下面这一条必须变红。
|
||||
*
|
||||
* 方向一:先开「生成动画」,再点「快速编辑」→ 动画必须被收起。
|
||||
*/
|
||||
it('开动画后再点快速编辑,动画被收起', () => {
|
||||
const afterAnimation = openPanel(BOTH_CLOSED, 'character-animation');
|
||||
expect(afterAnimation).toEqual({
|
||||
quickEditOpen: false,
|
||||
characterAnimationOpen: true,
|
||||
});
|
||||
|
||||
const afterQuickEdit = openPanel(afterAnimation, 'quick-edit');
|
||||
expect(afterQuickEdit).toEqual({
|
||||
quickEditOpen: true,
|
||||
characterAnimationOpen: false,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 变异验证②:同一条规则的另一半。
|
||||
*
|
||||
* 方向二:先开「快速编辑」,再点「生成动画」→ 快速编辑必须被收起。
|
||||
* 只修一个方向时这条会红。
|
||||
*/
|
||||
it('开快速编辑后再点动画,快速编辑被收起', () => {
|
||||
const afterQuickEdit = openPanel(BOTH_CLOSED, 'quick-edit');
|
||||
expect(afterQuickEdit).toEqual({
|
||||
quickEditOpen: true,
|
||||
characterAnimationOpen: false,
|
||||
});
|
||||
|
||||
const afterAnimation = openPanel(afterQuickEdit, 'character-animation');
|
||||
expect(afterAnimation).toEqual({
|
||||
quickEditOpen: false,
|
||||
characterAnimationOpen: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('反复来回点也永远不会两个同时开着', () => {
|
||||
let state = BOTH_CLOSED;
|
||||
const sequence: ResourceCardFloatingPanelKind[] = [
|
||||
'quick-edit',
|
||||
'character-animation',
|
||||
'character-animation',
|
||||
'quick-edit',
|
||||
'quick-edit',
|
||||
'character-animation',
|
||||
];
|
||||
for (const kind of sequence) {
|
||||
state = openPanel(state, kind);
|
||||
expect(state.quickEditOpen && state.characterAnimationOpen).toBe(false);
|
||||
expect(
|
||||
state.quickEditOpen || state.characterAnimationOpen,
|
||||
`${kind} 之后至少应有一个开着`,
|
||||
).toBe(true);
|
||||
// 被点开的那个必须是当前开着的那个。
|
||||
expect(
|
||||
kind === 'quick-edit'
|
||||
? state.quickEditOpen
|
||||
: state.characterAnimationOpen,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user