feat(ui): storefront filters and admin category/tag management (#23)
Storefront gains a Filters drawer holding the category tree, colour-coded tag pills, and a price range, with applied filters shown as removable chips. Filter state lives in the URL query string, so a filtered view is shareable and the back button works. Item cards now show their category and tags. Admin gains Categories and Tags tabs, and the item form gains a category TreeSelect plus a tags Select that creates new tags on the fly. The admin category tree tracks expansion in state rather than using defaultExpandAll: that prop is evaluated once at mount, so a branch added afterwards rendered collapsed and its children were unreachable. Creating or moving a node now expands its parent. Caught by the new admin e2e spec. The chip row is marked as a named group so its "Clear all" stays distinguishable from the drawer's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,39 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
|
||||
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs
|
||||
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
|
||||
TreeSelect, Select
|
||||
} from 'antd';
|
||||
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import '@uiw/react-md-editor/markdown-editor.css';
|
||||
import '@uiw/react-markdown-preview/markdown.css';
|
||||
import { Item, fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable } from '../api';
|
||||
import {
|
||||
Item, Category, Tag as TagRecord,
|
||||
fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable,
|
||||
fetchAdminCategories, fetchAdminTags
|
||||
} from '../api';
|
||||
import { buildCategoryTree, CategoryNode } from '../filters';
|
||||
import { useThemeMode } from '../theme/ThemeContext';
|
||||
import Customers from './Customers';
|
||||
import Settings from './Settings';
|
||||
import Categories from './Categories';
|
||||
import Tags from './Tags';
|
||||
|
||||
interface CategoryTreeOption {
|
||||
value: number;
|
||||
title: string;
|
||||
children?: CategoryTreeOption[];
|
||||
}
|
||||
|
||||
function toCategoryTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||
return nodes.map(node => ({
|
||||
value: node.id,
|
||||
title: node.name,
|
||||
children: node.children.length ? toCategoryTreeData(node.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
const { Title } = Typography;
|
||||
@@ -23,24 +45,41 @@ function Inventory() {
|
||||
const [form] = Form.useForm();
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [description, setDescription] = useState<string>('');
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [tags, setTags] = useState<TagRecord[]>([]);
|
||||
const { mode } = useThemeMode();
|
||||
|
||||
const load = () => fetchAdminItems().then(setItems);
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
// The item form needs the current category tree and tag list; both change
|
||||
// from the sibling tabs, so they're refetched whenever the modal opens.
|
||||
const loadOptions = () => Promise.all([
|
||||
fetchAdminCategories().then(setCategories),
|
||||
fetchAdminTags().then(setTags)
|
||||
]);
|
||||
|
||||
useEffect(() => { load(); loadOptions(); }, []);
|
||||
|
||||
function openNew() {
|
||||
setEditingItem(null);
|
||||
form.resetFields();
|
||||
setFileList([]);
|
||||
setDescription('');
|
||||
loadOptions();
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(item: Item) {
|
||||
setEditingItem(item);
|
||||
form.setFieldsValue({ name: item.name, price: item.price_cents / 100 });
|
||||
form.setFieldsValue({
|
||||
name: item.name,
|
||||
price: item.price_cents / 100,
|
||||
category_id: item.category_id ?? undefined,
|
||||
tags: item.tags.map(tag => tag.name)
|
||||
});
|
||||
setFileList([]);
|
||||
setDescription(item.description || '');
|
||||
loadOptions();
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
@@ -50,11 +89,16 @@ function Inventory() {
|
||||
fd.append('name', values.name);
|
||||
fd.append('description', description);
|
||||
fd.append('price', String(values.price));
|
||||
// An empty string clears the category server-side; undefined would be sent
|
||||
// as the literal text "undefined".
|
||||
fd.append('category_id', values.category_id == null ? '' : String(values.category_id));
|
||||
fd.append('tags', JSON.stringify(values.tags ?? []));
|
||||
fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); });
|
||||
await saveItem(editingItem?.id ?? null, fd);
|
||||
message.success(editingItem ? 'Item updated' : 'Item added');
|
||||
setModalOpen(false);
|
||||
load();
|
||||
loadOptions();
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
@@ -87,6 +131,19 @@ function Inventory() {
|
||||
) : null
|
||||
},
|
||||
{ title: 'Name', dataIndex: 'name' },
|
||||
{
|
||||
title: 'Category',
|
||||
dataIndex: 'category_name',
|
||||
render: (name: string | null) => name || <span style={{ opacity: 0.45 }}>Uncategorized</span>
|
||||
},
|
||||
{
|
||||
title: 'Tags',
|
||||
dataIndex: 'tags',
|
||||
render: (itemTags: Item['tags']) =>
|
||||
itemTags.length
|
||||
? itemTags.map(tag => <Tag key={tag.id} color={tag.color}>{tag.name}</Tag>)
|
||||
: null
|
||||
},
|
||||
{ title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
|
||||
{
|
||||
title: 'Status',
|
||||
@@ -129,6 +186,27 @@ function Inventory() {
|
||||
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category_id" label="Category">
|
||||
<TreeSelect
|
||||
allowClear
|
||||
placeholder="Uncategorized"
|
||||
treeDefaultExpandAll
|
||||
treeData={toCategoryTreeData(buildCategoryTree(categories))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="tags"
|
||||
label="Tags"
|
||||
extra="Pick existing tags or type a new one and press Enter to create it."
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="Add tags"
|
||||
style={{ width: '100%' }}
|
||||
options={tags.map(tag => ({ value: tag.name, label: tag.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
{editingItem && editingItem.images.length > 0 && (
|
||||
<Form.Item label="Existing Images (front / back / etc.)">
|
||||
<Space wrap>
|
||||
@@ -172,6 +250,8 @@ export default function Admin() {
|
||||
defaultActiveKey="inventory"
|
||||
items={[
|
||||
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
|
||||
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
||||
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
||||
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
||||
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
||||
]}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import Tree from 'antd/lib/tree';
|
||||
import Button from 'antd/lib/button';
|
||||
import Input from 'antd/lib/input';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Select from 'antd/lib/select';
|
||||
import Space from 'antd/lib/space';
|
||||
import Typography from 'antd/lib/typography';
|
||||
import Empty from 'antd/lib/empty';
|
||||
import Spin from 'antd/lib/spin';
|
||||
import message from 'antd/lib/message';
|
||||
import type { DataNode, TreeProps } from 'antd/es/tree';
|
||||
import {
|
||||
Category,
|
||||
fetchAdminCategories,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory
|
||||
} from '../api';
|
||||
import { buildCategoryTree, categoryPath, CategoryNode } from '../filters';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// Counts the whole branch, so the delete confirmation can say what a parent
|
||||
// takes down with it rather than only naming itself.
|
||||
function branchTotals(node: CategoryNode): { categories: number; items: number } {
|
||||
return node.children.reduce(
|
||||
(acc, child) => {
|
||||
const nested = branchTotals(child);
|
||||
return { categories: acc.categories + nested.categories, items: acc.items + nested.items };
|
||||
},
|
||||
{ categories: 1, items: node.item_count }
|
||||
);
|
||||
}
|
||||
|
||||
function findNode(nodes: CategoryNode[], id: number): CategoryNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
const nested = findNode(node.children, id);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Categories() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Category | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [parentId, setParentId] = useState<number | null>(null);
|
||||
// The tree stays mounted across reloads, so `defaultExpandAll` would only
|
||||
// ever apply to the categories present on first render — a branch added
|
||||
// afterwards would render collapsed and its children be unreachable.
|
||||
const [expandedKeys, setExpandedKeys] = useState<Key[]>([]);
|
||||
const expansionInitialized = useRef(false);
|
||||
|
||||
const tree = buildCategoryTree(categories);
|
||||
|
||||
function expand(id: number) {
|
||||
setExpandedKeys((keys) => (keys.includes(id) ? keys : [...keys, id]));
|
||||
}
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
return fetchAdminCategories()
|
||||
.then((list) => {
|
||||
setCategories(list);
|
||||
// Start fully expanded, then leave expansion to the user — reloading
|
||||
// must not silently re-open branches they collapsed.
|
||||
if (!expansionInitialized.current) {
|
||||
setExpandedKeys(list.map((category) => category.id));
|
||||
expansionInitialized.current = true;
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function openNew(parent: number | null) {
|
||||
setEditing(null);
|
||||
setName('');
|
||||
setParentId(parent);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(category: Category) {
|
||||
setEditing(category);
|
||||
setName(category.name);
|
||||
setParentId(category.parent_id);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
message.error('Name is required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editing) {
|
||||
await updateCategory(editing.id, { name: trimmed, parent_id: parentId });
|
||||
message.success('Category updated');
|
||||
} else {
|
||||
await createCategory(trimmed, parentId);
|
||||
message.success('Category added');
|
||||
}
|
||||
// Reveal where the category just landed instead of filing it out of sight.
|
||||
if (parentId !== null) expand(parentId);
|
||||
setModalOpen(false);
|
||||
load();
|
||||
} catch (err) {
|
||||
message.error((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(category: Category) {
|
||||
const node = findNode(tree, category.id);
|
||||
const totals = node ? branchTotals(node) : { categories: 1, items: category.item_count };
|
||||
const subcategories = totals.categories - 1;
|
||||
|
||||
Modal.confirm({
|
||||
title: `Delete "${category.name}"?`,
|
||||
content: (
|
||||
<span>
|
||||
This deletes {subcategories === 0 ? 'no subcategories' : `${subcategories} subcategor${subcategories === 1 ? 'y' : 'ies'}`}
|
||||
{' '}and uncategorizes {totals.items} item{totals.items === 1 ? '' : 's'}. The items themselves are kept.
|
||||
</span>
|
||||
),
|
||||
okText: 'Delete',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const result = await deleteCategory(category.id);
|
||||
message.success(
|
||||
`Deleted ${result.deleted_categories} categor${result.deleted_categories === 1 ? 'y' : 'ies'}, ` +
|
||||
`uncategorized ${result.uncategorized_items} item${result.uncategorized_items === 1 ? '' : 's'}`
|
||||
);
|
||||
load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Dragging a node onto another reparents it. The server rejects a move that
|
||||
// would put a node beneath its own descendant, so a refused drop just
|
||||
// reloads the unchanged tree.
|
||||
const handleDrop: TreeProps['onDrop'] = async (info) => {
|
||||
const dragId = Number(info.dragNode.key);
|
||||
const dropId = Number(info.node.key);
|
||||
const dropToGap = !info.dropToGap ? dropId : findNode(tree, dropId)?.parent_id ?? null;
|
||||
|
||||
try {
|
||||
await updateCategory(dragId, { parent_id: dropToGap });
|
||||
if (dropToGap !== null) expand(dropToGap);
|
||||
message.success('Category moved');
|
||||
} catch (err) {
|
||||
message.error((err as Error).message);
|
||||
}
|
||||
load();
|
||||
};
|
||||
|
||||
function toTreeData(nodes: CategoryNode[]): DataNode[] {
|
||||
return nodes.map((node) => ({
|
||||
key: node.id,
|
||||
title: (
|
||||
<span className="admin-category-node">
|
||||
<span>{node.name}</span>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{node.item_count} item{node.item_count === 1 ? '' : 's'}
|
||||
</Text>
|
||||
<Space size={4} onClick={(event) => event.stopPropagation()}>
|
||||
<Button size="small" type="link" onClick={() => openNew(node.id)}>Add child</Button>
|
||||
<Button size="small" type="link" onClick={() => openEdit(node)}>Rename</Button>
|
||||
<Button size="small" type="link" danger onClick={() => handleDelete(node)}>Delete</Button>
|
||||
</Space>
|
||||
</span>
|
||||
),
|
||||
children: node.children.length ? toTreeData(node.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
// A category may not be moved beneath itself or its own descendants, so those
|
||||
// options are withheld from the parent picker too.
|
||||
const forbidden = new Set<number>();
|
||||
if (editing) {
|
||||
const collect = (node: CategoryNode) => {
|
||||
forbidden.add(node.id);
|
||||
node.children.forEach(collect);
|
||||
};
|
||||
const node = findNode(tree, editing.id);
|
||||
if (node) collect(node);
|
||||
}
|
||||
|
||||
const parentOptions = [
|
||||
{ value: null as number | null, label: 'No parent (top level)' },
|
||||
...categories
|
||||
.filter((category) => !forbidden.has(category.id))
|
||||
.map((category) => ({ value: category.id as number | null, label: categoryPath(categories, category.id) }))
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>Categories</Title>
|
||||
<Button type="primary" onClick={() => openNew(null)}>Add Category</Button>
|
||||
</div>
|
||||
|
||||
{loading && !categories.length ? <Spin /> : null}
|
||||
{!loading && !categories.length ? (
|
||||
<Empty description="No categories yet. Add one to start organizing items." />
|
||||
) : (
|
||||
<Tree
|
||||
treeData={toTreeData(tree)}
|
||||
draggable
|
||||
blockNode
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={setExpandedKeys}
|
||||
selectable={false}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={editing ? 'Edit Category' : 'Add Category'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<label htmlFor="category-name">Name</label>
|
||||
<Input
|
||||
id="category-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onPressEnter={handleSave}
|
||||
/>
|
||||
<label htmlFor="category-parent">Parent</label>
|
||||
<Select
|
||||
id="category-parent"
|
||||
style={{ width: '100%' }}
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
options={parentOptions}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Table from 'antd/lib/table';
|
||||
import Button from 'antd/lib/button';
|
||||
import Input from 'antd/lib/input';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Select from 'antd/lib/select';
|
||||
import Space from 'antd/lib/space';
|
||||
import Tag from 'antd/lib/tag';
|
||||
import Typography from 'antd/lib/typography';
|
||||
import message from 'antd/lib/message';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Tag as TagRecord, fetchAdminTags, createTag, updateTag, deleteTag } from '../api';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// Mirrors TAG_COLORS in the backend's utils.ts — the server rejects anything
|
||||
// outside this set, so the two lists have to stay aligned.
|
||||
const TAG_COLORS = [
|
||||
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
||||
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
||||
];
|
||||
|
||||
export default function Tags() {
|
||||
const [tags, setTags] = useState<TagRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<TagRecord | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState<string>(TAG_COLORS[0]);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
return fetchAdminTags()
|
||||
.then(setTags)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function openNew() {
|
||||
setEditing(null);
|
||||
setName('');
|
||||
setColor(TAG_COLORS[0]);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(tag: TagRecord) {
|
||||
setEditing(tag);
|
||||
setName(tag.name);
|
||||
setColor(tag.color);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
message.error('Name is required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editing) {
|
||||
await updateTag(editing.id, { name: trimmed, color });
|
||||
message.success('Tag updated');
|
||||
} else {
|
||||
// New tags take the colour the server derives from the name; it can be
|
||||
// overridden straight afterwards by editing.
|
||||
await createTag(trimmed);
|
||||
message.success('Tag added');
|
||||
}
|
||||
setModalOpen(false);
|
||||
load();
|
||||
} catch (err) {
|
||||
message.error((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(tag: TagRecord) {
|
||||
Modal.confirm({
|
||||
title: `Delete "${tag.name}"?`,
|
||||
content: `This removes the tag from ${tag.item_count} item${tag.item_count === 1 ? '' : 's'}. The items themselves are kept.`,
|
||||
okText: 'Delete',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await deleteTag(tag.id);
|
||||
message.success('Tag deleted');
|
||||
load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const columns: ColumnsType<TagRecord> = [
|
||||
{
|
||||
title: 'Tag',
|
||||
dataIndex: 'name',
|
||||
render: (_: string, tag) => <Tag color={tag.color}>{tag.name}</Tag>
|
||||
},
|
||||
{ title: 'Colour', dataIndex: 'color' },
|
||||
{ title: 'Items', dataIndex: 'item_count' },
|
||||
{
|
||||
title: 'Actions',
|
||||
render: (_: unknown, tag) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => openEdit(tag)}>Edit</Button>
|
||||
<Button size="small" danger onClick={() => handleDelete(tag)}>Delete</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>Tags</Title>
|
||||
<Button type="primary" onClick={openNew}>Add Tag</Button>
|
||||
</div>
|
||||
|
||||
<Table rowKey="id" dataSource={tags} columns={columns} loading={loading} scroll={{ x: true }} />
|
||||
|
||||
<Modal
|
||||
title={editing ? 'Edit Tag' : 'Add Tag'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<label htmlFor="tag-name">Name</label>
|
||||
<Input
|
||||
id="tag-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onPressEnter={handleSave}
|
||||
/>
|
||||
{editing && (
|
||||
<>
|
||||
<label htmlFor="tag-color">Colour</label>
|
||||
<Select
|
||||
id="tag-color"
|
||||
style={{ width: '100%' }}
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
options={TAG_COLORS.map((option) => ({
|
||||
value: option,
|
||||
label: <Tag color={option}>{option}</Tag>
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user